Compare commits

..

423 Commits

Author SHA1 Message Date
Samuel Paccoud - DINUM 49c89da2da add flag for e2e encoded documents 2024-09-12 16:20:17 +02:00
Anthony LC 97fa5b8532 🧑‍💻(makefile) add build frontend dev
docker build frontend dev was lacking.
If dependencies were updated, the change were
not reflected in the frontend container.
2024-09-12 08:06:17 +02:00
Anthony LC a092c2915b ♻️(frontend) adapt doc visibility to new api
We updated the way we handle the visibility of a doc
in the backend. Now we use a new api to update
the visibility (documents/{id}/link-configuration/)
of a doc. We adapted the frontend to use this new api.
We changed the types to reflect the new api and
to keep the same logic.
2024-09-11 22:31:30 +02:00
Samuel Paccoud - DINUM 9b44e021fd ♻️(models) allow null titles on documents
We want to make it as fast as possible to create a new document.
We should not have any modal asking the title before creating the
document but rather show an "untitle document" title and let the
owner set it on the already created document.
2024-09-11 22:31:30 +02:00
Samuel Paccoud - DINUM 2c3eef4dd9 (api) allow forcing ID when creating a document via API endpoint
We need to be able to force the ID when creating a document via
the API endpoint. This is usefull for documents that are created
offline as synchronization is achieved by replaying stacked requests.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Changed:
- ♻️(frontend) Change site from Impress to Docs (#76)
- (frontend) Generate PDF from a modal (#68)
- 🔧(helm) sticky session by request_uri for signaling server (#78)
- ♻️(frontend) change logo (#84)
- ♻️(frontend) pdf has title doc (#84)
- ️(e2e) unique login between tests (#80)
- ️(CI) improve e2e job (#86)
- ♻️(frontend) improve the error and message info ui (#93)
- ✏️(frontend) change all occurences of pad to doc (#99)

Fixed:
- 🐛(frontend) Fix the break line when generate PDF (#84)

Delete:
- 💚(CI) Remove trigger workflow on push tags on CI (#68)
- 🔥(frontend) Remove coming soon page (#121)
2024-07-03 17:08:59 +02:00
Anthony LC 8f5c413482 ️(e2e) remove unnecessary page.goto
Multiple page.goto can cause flakiness.
An page.goto is already called in the beforeEach,
so we don't need to call it again in the test.
2024-07-03 14:55:54 +02:00
Anthony LC a66231d72e 🗃️(service-worker) cache successfull update in doc-item
When we updated the doc successfully then directly
pass offline, the cache was not updated.
It is because a successful update didn't
update the cache of the doc-item.
2024-07-03 11:49:53 +02:00
Anthony LC 2e0b6b2a2a ♻️(service-worker) admin NetworkOnly
We will use the NetworkOnly strategy
for the admin part.
2024-07-03 10:03:15 +02:00
Anthony LC edffbbaf99 🐛(frontend) fix issue when no template
A bug occured when no template was
available. This commit fixes this issue.
2024-07-03 10:03:15 +02:00
Anthony LC a393cd4df0 ♻️(service-worker) external url NetworkFirst
We will use the NetworkFirst strategy
for external URLs, if a patch is made from
the external resource, the resource will be
updated directly.
2024-07-03 10:03:15 +02:00
Anthony LC e26b806965 🐛(service-worker) networkFirst on api request
Some api requests were served by the cache first,
which caused the data to be outdated.
This change makes sure that the api requests
are always served by the network first.
2024-07-03 10:03:15 +02:00
Jacques ROUSSEL aef19cfddd 🔧(helm) manage htaccess
- add htaccess
- fix issue with kind
2024-07-03 09:22:31 +02:00
Anthony LC b7bd7b7772 ⚗️(frontend) add Beta badge to header
We add a beta badge to the header to indicate
that the app is in beta.
2024-07-02 10:52:15 +02:00
Anthony LC 43ba36af61 🔥(frontend) remove coming-soon
Remove the coming-soon page and the redirection to it.
2024-07-02 10:52:15 +02:00
Samuel Paccoud - DINUM b4e4ba3516 ♻️(documents) store document file in object storage in a folder
We will need to store more than a file for a document: multiple languages,
images, etc. For this, the document ID should be a folder and the content
a file in this folder.
2024-07-01 22:32:00 +02:00
Anthony LC 9c19b22a66 ♻️(service-worker) create useSWRegister hook
Create useSWRegister hook, which will be used to
register the service worker in the app.
2024-06-28 11:07:21 +02:00
Anthony LC d87c278cce 🚚(service-worker) move service worker to features
The service worker is now in the features folder.
It is more a feature than a core part of the app.
2024-06-28 11:07:21 +02:00
Anthony LC 287a5b3bff 🔧(service-worker) add conf file with dev urls
Add a conf files to the service worker to store
the development urls. If someone wants to change
the urls, they can do it in the conf file, it is
more intuitive than changing the urls in the
service worker file.
2024-06-28 11:07:21 +02:00
Anthony LC cfb979a411 (service-worker) add unit tests
Add unit tests for:
- ApiPlugin
- RequestSerializer
- SyncManager
2024-06-28 11:07:21 +02:00
Anthony LC d588ae847f 🐛(frontend) change saving behavior firefox
Firefox does not trigger the request everytime the
user leaves the page. Plus the request is not
intercepted by the service worker.
So we prevent the default behavior to have
the popup asking the user if he wants to leave the
page, by adding the popup, we let the time to the
request to be sent, and intercepted by the
service worker (for the offline part).

We also add a toast to inform the user that the
document has been saved, it will make it more obvious
that the document has been saved if a firefox user
come back from the popup to the page.
2024-06-28 11:07:21 +02:00
Anthony LC 08a39c7ebd 🔧(frontend) add a buildId to service worker
We add a buildId to the service worker to force
the browser to update the service worker when
the buildId changes.
Before we were on the tag version, but
the browser was not updating the service worker
easily on the staging environment.
2024-06-28 11:07:21 +02:00
Anthony LC aef5dd51fc ♻️(backend) override document perform_create
We override the perform_create method of
the DocumentViewSet to save the document with
the id provided if a id is provided in the request.
We do that because in offline mode we will create
the document locally and we will need to save it
with the id created locally to have our next
requests to the server to be able to find the
document with the id provided.
2024-06-28 11:07:21 +02:00
Anthony LC bf10fbd25f (service-worker) add offline docs delete
Add offline docs delete to the service worker.
We use the Network fisrt strategy, if
the network is down, we will delete the
doc in the indexDB.
When the connection is back, we will remove
the doc to the server.
2024-06-28 11:07:21 +02:00
Anthony LC f5948959f2 (service-worker) add offline docs create
Add offline docs create to the service worker.
We use the Network fisrt strategy, if
the network is down, we will create the
doc in the indexDB and serve it from there.
When the connection is back, we will send
the doc to the server.
2024-06-28 11:07:21 +02:00
Anthony LC a0644efb45 ♻️(service-worker) add DocsDB to store docs in indexedDB
Export the storage of docs to a separate
class `DocsDB` that will be responsible for
storing / retrieving / synching docs from indexedDB.
2024-06-28 11:07:21 +02:00
Anthony LC b4e8db9050 (service-worker) add offline docs update
Add offline docs update to the service worker.
We use the Network fisrt strategy, if
the network is down, we will update the
doc in the indexDB and serve it from there.
When the connection is back, we will send
the doc to the server.
2024-06-28 11:07:21 +02:00
Anthony LC 96bfbfabec (service-worker) add service worker api to impress app
This commit adds the service worker api to the
impress app.
The service worker api will cache the api calls
in the indexdb. We are using the network first
strategy to fetch the data. If the network is
not available, we will fetch the data from the
indexdb.
To do that, we create a custom plugin (ApiPlugin).
2024-06-28 11:07:21 +02:00
Anthony LC c2aa5be8ff 🧑‍💻(frontend) env var service-worker dev mode
Add a new environment variable to enable or disable
the service worker in development mode.
By default, the service worker is disabled
in development mode, it can cause problems
when developing the application with the hmr.
It can creates useless log in the console
as well.
We can easily enable it by setting
NEXT_PUBLIC_SW_DEACTIVATED to false in the
.env.development file.
We will use this new var to not use the service
worder with the CI as well.
2024-06-28 11:07:21 +02:00
Anthony LC 3b77e1254e 🔥(frontend) remove grapesjs dependencies
Some grapesjs dependencies were still present.
We don't use these anymore, so we can remove them.
2024-06-27 12:42:52 +02:00
Anthony LC b436a418ff ⬆️(frontend) upgrade react to 18.3.1
A recent upgrade of next.js fixed the compatibility
issue with react 18.3.1.
2024-06-27 12:42:52 +02:00
Anthony LC 9fdf87b100 ⬇️(frontend) downgrading fetch-mock to 9.11.0
fetch-mock released the version 10, but it is
causing some issues with jest because of
esm support.
2024-06-27 12:42:52 +02:00
renovate[bot] 6943f2158a ⬆️(dependencies) update js dependencies 2024-06-27 12:42:52 +02:00
renovate[bot] 872cc8c110 ⬆️(dependencies) update requests to v2.32.2 [SECURITY] 2024-06-27 11:04:34 +02:00
Anthony LC c03e1ffe8b 💄(frontend) change docs logo
Change the docs logo to a more accurate one.
2024-06-25 15:38:40 +02:00
Anthony LC 5454da5c70 ✏️(frontend) change all occurences of pad to doc
The initial doc was named pad in reference to
the "NotePad de l'Etat", but we renamed it
doc to clearly dissociate the 2 sites.
Doc is also a small reference of google doc.
2024-06-25 15:07:36 +02:00
Anthony LC 5b3c91f6dd 🌐(app-impress) translate impress in french
Add french translation to impress.
2024-06-25 13:47:56 +02:00
Anthony LC f1ac0954ed 🧑‍💻(i18n) run automatically prettier after import
After an import the translation file is now
automatically formatted with prettier.
2024-06-25 13:47:56 +02:00
Anthony LC fc80c2ec2a 🌐(pages) translate legal pages to english
They should be translated to French using the i18n tooling and not
directly in the code.
2024-06-25 13:47:56 +02:00
renovate[bot] 8ba64322be ⬆️(dependencies) update ws to v8.17.1 [SECURITY] 2024-06-24 12:29:30 +02:00
Anthony LC ae9555284d (e2e) remove some flakiness
Service worker added some flakiness, we
activate the sw only if not from the CI.
We add as well the email of the user added in a
doc to the toast message. This way we can check
if the user was added correctly, this will remove
some flakiness in some tests.
2024-06-14 12:26:04 +02:00
Anthony LC 14a91a5698 🐛(frontend) mismatch between yjs dependency
Mismatch between yjs dependency, we set the
dependency globally to not have this problem
anymore.
2024-06-14 12:26:04 +02:00
Anthony LC 9f6dac53d4 (frontend) add offline mode support
Add a offline mode support from a service worker.
The service worker will cache the assets and the
visited pages to be able to work offline.
Created a fallback page for when the user is offline
and tries to access a page that is not cached.
2024-06-14 12:26:04 +02:00
Anthony LC 5ba35dbc1d ♻️(frontend) improve the ui error and message info ui
Improve the ui error and message info ui:
- Can use a icon in TextErrors component
- use mode the Alert component to display message info
2024-06-14 11:50:13 +02:00
Anthony LC 62098ec753 🐛(frontend) fix bug multi ws connection
In collaborative mode, only the first document
connected was being shared. This was due to the
fact that the webrtc server was managing only
one connection at a time. This commit fixes this
bug by managing multiple connections.
2024-06-13 12:20:23 +02:00
Jacques ROUSSEL 9e318f88be 🐛(CI) improve submodule
- remove deplucate declaration
- simplify helmfile
- use symlink
2024-06-11 10:40:39 +02:00
Anthony LC 63b565e720 💄(frontend) max img width with container width
The img could be much bigger than the page width
in a generated pdf. We limit the max img width
to the container width.
2024-06-10 13:28:38 +02:00
Anthony LC e7fe153a81 🐛(frontend) fix break line on firefox
On firefox with collaborative mode, the last
upgrade of ProseMirror added a new line when
a another user was typing. This commit fixes this
issue.
2024-06-10 13:28:38 +02:00
Anthony LC 8e0850b13e 🐛(app-desk) fix fetchPriority warning
The upgrade to react@18.3.1 has a compatibility
issue with next@14.2.3. It creates a error warning
about the fetchPriority prop. This commit fixes the
issue by downgrading react to 18.2.0 as it was
before the last upgrade.
The next.js team is already aware of the issue and
it will be fixed in the next release.
2024-06-10 13:28:38 +02:00
Anthony LC 8fb18ed186 (frontend) add "@testing-library/dom
Recent update of @testing-library/react requires
@testing-library/dom to be installed as well.
2024-06-10 13:28:38 +02:00
renovate[bot] bec6ff3d22 ⬆️(dependencies) update js dependencies 2024-06-10 13:28:38 +02:00
Anthony LC 0571aaabce ️(CI) improve docker build cache
Improve the caching by using docker buildx,
it should speed up the build process by using the
cache from the previous build.
2024-06-07 15:01:47 +02:00
Jacques ROUSSEL e4bed84343 🐛(CI) fix ci issue
- fix get secret in ci
2024-06-07 14:05:51 +02:00
Jacques ROUSSEL 37f02893ed 🐛(CI) purge secret from repository
- Remove *.enc.*
- Adapt helmfile
- Adapt CI
2024-06-07 10:11:19 +02:00
Anthony LC 4e4e2e23e3 ️(e2e) unique login between tests
We will cache the login token in the browser
storage and reuse it between tests.
This will speed up the tests and reduce the
load on the server.
2024-06-06 22:31:43 +02:00
Anthony LC b68015852c 🔊(changelog) add recents changes
Add some recents changes:
- (frontend) manage members (update role / list / remove)
- (frontend) change logo
- (frontend) pdf has title doc
- Fix the break line when generate PDF
2024-06-06 16:09:07 +02:00
Anthony LC 2f79d03acc ⬇️(frontend) downgrade @testing-library/react@16.0.0
downgrade @testing-library/react@16.0.0 to
@testing-library/react@15.0.7.

Some modules are not available in the latest
version, their documentation is not available yet.
2024-06-06 16:09:07 +02:00
Anthony LC 8762a7bc92 ♻️(frontend) adapt BlockNote to the last version
A recent relase of the BlockNote component
has changed the way it is used. This commit
adapts the BlockNoteEditor and BlockNoteToolbar
components to the new version of the BlockNote.
2024-06-06 16:09:07 +02:00
Anthony LC 589b0eab32 🐛(app-desk) fix fetchPriority warning
The upgrade to react@18.3.1 has a compatibility
issue with next@14.2.3. It creates a error warning
about the fetchPriority prop. This commit fixes the
issue by downgrading react to 18.2.0 as it was
before the last upgrade.
The next.js team is already aware of the issue and
it will be fixed in the next release.
2024-06-06 16:09:07 +02:00
renovate[bot] 44f4a37814 ⬆️(dependencies) update js dependencies 2024-06-06 16:09:07 +02:00
Anthony LC 6c76f7faab ♻️(frontend) improve html for pdf
Blocknote json to html is not perfect, so we need
to improve it to provide a better html for
the pdf generation.
It is the first step to improve the html,
we will continue to improve it in the future.
We have now the break line.
It is e2e tested so if blocknote change the conversion
we will know it.
2024-06-06 12:43:25 +02:00
Anthony LC 201d776d1b ♻️(frontend) doc title as title for pdf export
Our pdf export was named impress-document.pdf,
now it is named after the document title.
We sanitize the title by removing special characters,
replacing spaces with underscores and putting it
in lowercase.
2024-06-06 12:43:25 +02:00
Anthony LC e7f2317b41 ♻️(backend) simplify the template
For the first version, we will use a simple template.
Only the gouv logo will be displayed.
2024-06-06 12:43:25 +02:00
Anthony LC 206c7f11ae (e2e) flaky saves the doc when we quit pages
Stop to check "saves the doc when we quit pages"
with webkit browser. It is flaky.
2024-06-06 12:43:25 +02:00
Anthony LC 615f92ded3 💄(frontend) change logo with the pad one
Change the logo with the pad one.
2024-06-06 12:43:25 +02:00
Anthony LC da3ad91283 ♻️(frontend) add api versionning per request
We were using the version of the api from the
.env file, but we could have different versions
of the api in the same app. So we now use the
version from the request.
2024-06-06 12:43:25 +02:00
Anthony LC 197b16c5d0 (frontend) add modal to delete a member
Add modal to delete a member.
2024-06-05 21:50:30 +02:00
Anthony LC cce0331b68 (frontend) add modal to update role of a member
Add modal to update role of a member.
2024-06-05 21:50:30 +02:00
Anthony LC 380ac0cbcf (frontend) add members grid
Add members grid to display all members
of a doc and their roles.
2024-06-05 21:50:30 +02:00
Anthony LC 1779b7bab4 🏷️(frontend) adapt accesses types
We updated the types of the accesses to
get more information about the accesses.
2024-06-05 21:50:30 +02:00
Anthony LC 7b5698a370 👔(backend) object user on DocumentAccessSerializer
user field was displaying the userid, but we
need to return the user object on the
DocumentAccessSerializer, so we can show the
user email on the frontend.
We add the user_id field in write_only mode, so
we can keep create and update.
2024-06-05 21:50:30 +02:00
Anthony LC f039b2aa3b 🌐(frontend) legal pages translatable
Makes the legal pages translatable.
2024-06-05 10:37:02 +02:00
Anthony LC 8531e0dd08 🐛(i18n) comma in keys
Having a comma in the keys broke the parser.
This commit allows the comma in keys.
2024-06-05 10:37:02 +02:00
Anthony LC e2586b1d8e 🚚(frontend) move to members/members-add
Move addUsers to members/members-add.
Rename some of the occurences of user to member.
2024-05-31 11:51:29 +02:00
Anthony LC 327a5f2ed4 🤡(demo) generate dummy user
To search users we need to have some dummy data users.
We add some dummy users when we run the `make demo`
command.
2024-05-31 11:51:29 +02:00
Anthony LC 1e62320803 💚(app-desk) add template mail for e2e
The tests e2e need the mail template to
send the invitation to the user.
We are now caching the mail template instead
of using a artifact, thanks to that we can
build only the mail template if the cache is
invalidated, so we save time and resources.
2024-05-31 11:51:29 +02:00
Anthony LC ff7341e544 (frontend) improve some tests
Improve some tests to check the states depend
the user's role.
2024-05-31 11:51:29 +02:00
Anthony LC 038c3d6207 🧑‍💻(mail) make commands windows friendly
Make the commands windows friendly.
2024-05-31 11:51:29 +02:00
Anthony LC 2b67d0bd26 (frontend) add or invite users to a doc
Add or invite a user to a doc.
If the user is already in the system,
the user will be added to the doc,
otherwise, the user will be invited to the doc.
2024-05-31 11:51:29 +02:00
Anthony LC d30ac8ee4e 🐛(CI) fix job check changelog
The job was everytime failing.
This commit fixes the issue.
2024-05-31 10:34:15 +02:00
Anthony LC c05b98ce0d 🔧(helm) create ingress_ws
The ingress was the same for the frontend, the
backend and the websocket, but the websocket
needs to be handled differently, so we created
a new ingress specifically for the websocket.
2024-05-31 10:34:15 +02:00
Anthony LC 3d594a99d5 🔨(frontend) add room to ws signaling url
Add the room to the ws signaling url so that
the ingress controller can route the request
to the correct pod.
2024-05-31 10:34:15 +02:00
Anthony LC b47e60d6ec 🔧(helm) sticky session by request_uri
It helps to keep the same pod for the same
request_uri, so that multiple users from the
same room are not dispatched to different pods.
2024-05-31 10:34:15 +02:00
Anthony LC f40f0d35cb 🧑‍💻(tilt) sync only y-webrtc-signaling app
We were listening all the frontend, but we can
just listen to the y-webrtc-signaling app.
This app doesn't change often moreover.
2024-05-31 10:34:15 +02:00
Anthony LC f1227ed037 🚨(CI) fix linter backend
The linter in the CI was checking only the
impress directory. We were passing near some lint
issues. Now the linter checks the whole backend
directory.
2024-05-31 10:12:28 +02:00
Anthony LC dbd4477e9a 👔(backend) change field displayed on users endpoint
Change the field displayed on the users endpoint.
We need the email field to be displayed.
2024-05-31 10:12:28 +02:00
Anthony LC b4b308bda9 (backend) search users
We need to search users by their email.
For that we will use the trigram similarity algorithm
provided by PostgreSQL. To use it we have to
activate the pg_trgm extension in postgres db.
To query the email we will use the query param
`q`.
We have another query param `document_id`, it is
necessary to exclude the users that have already
access to the document.
2024-05-31 10:12:28 +02:00
Jacques ROUSSEL 2b456c231f 💚(CI) improve changelog ci
Disable changelog when the label is not present
2024-05-30 16:57:45 +02:00
Jacques ROUSSEL 72bb079f10 👷(helm) preprod configuration
This PR adds the preprod configuration
for the helm chart.
2024-05-30 16:57:45 +02:00
Anthony LC 06af320d61 🚀(docker) dockerize frontend dev
Dockerize the frontend development environment
to make it easy to display the frontend
development environment in the browser.

We don't mix it with the command `make run` to
let it kind of optional to run the frontend
in a docker container. We let it optional because
the hot reload doesn't work well in the docker
container. The volume synch make it a bit slower
as well. So, we let the developer decide to run
the frontend in a docker container or not.
2024-05-30 15:42:34 +02:00
Samuel Paccoud - DINUM 926fe37e85 ♻️(models) rename document/template access rights
The "member" access right does not make sense for documents and templates.
What we really need are "editor" and "reader" access rights.
2024-05-29 19:25:46 +02:00
Anthony LC 51325df7d9 💄(frontend) change favicon
Change Next favicon for the Gouvernment one.
2024-05-27 13:51:18 +02:00
Anthony LC 0727fe0273 💬(frontend) change literal occurances of pad to document
- change urls from /pads to /docs
- change part of the litteral occurances of pad to document
2024-05-27 13:51:18 +02:00
Anthony LC d0d0b44f57 💬(frontend) change site name to Docs
The site name was Impress, we change it to Docs.
2024-05-27 13:51:18 +02:00
Anthony LC 6e46b686ab 💄(frontend) change gouvernement logo
Change the gourvernement logo.
2024-05-27 13:51:18 +02:00
Anthony LC 38eabe2fc8 🛂(frontend) right pad delete
Manage the right on the pad delete.
If a use cannot delete a pad, we will not display
the delete button in the dropdown menu.
2024-05-27 10:20:20 +02:00
Anthony LC effa77dc10 🛂(frontend) right pad update
Manage the right on the pad update.
If a use cannot update a pad, we will not display
the update button in the dropdown menu.
2024-05-27 10:20:20 +02:00
Anthony LC d5d374c311 🛂(frontend) right pad editor
Manage the right on the pad editor.
If a use cannot edit a pad, the pad editor will
be read-only. It will not save automatically.
A message will be displayed to the user.
2024-05-27 10:20:20 +02:00
Jacques ROUSSEL dd33c99532 💚(CI) add git hook
Add git hook to avoid secret leaks
2024-05-24 17:01:25 +02:00
Anthony LC f4146fbe4e 🚚(frontend) rename pad to pad-editor
Rename the pad feature to pad-editor.
2024-05-24 16:12:45 +02:00
Anthony LC dce040f4c3 🚚(frontend) move types and pad api hooks
Move types and pad api hooks to pad-management.
2024-05-24 16:12:45 +02:00
Anthony LC 3a68c75feb 🚚(frontend) rename pads-create to pad-management
Rename the pads-create feature to pad-management.
This feature will not only create but gives tools
to update and remove pads as well.
2024-05-24 16:12:45 +02:00
Anthony LC 29577c0419 (frontend) remove document
We can now remove a document from a modal.
2024-05-24 16:12:45 +02:00
Anthony LC 2813b2ca27 (frontend) update document
We can now update a pad from a modal.
2024-05-24 16:12:45 +02:00
Anthony LC ef2f4d3250 💚(CI) remove trigger workflow on push tags
We were starting the workflow on push tags,
it is needed for the docker-hub workflow,
but the other workflows should not be triggered
on push tags.
2024-05-24 16:12:45 +02:00
Anthony LC 996cea49b4 ♻️(frontend) add PDF generation inside a modal
Refacto the pad tools, we will use modals to handle
the different actions on the pad. We start by
implementing the PDF generation inside a modal.
2024-05-24 16:12:45 +02:00
Anthony LC eb2936f48b 🔐(helm) add djangoSuperUserEmail secret
To fit with the production environment, we need to add the
djangoSuperUserEmail secret to the staging environment.
2024-05-24 12:47:38 +02:00
Anthony LC 10c53d2a4e 🔖(minor) initial release to 0.1.0
Added
- Coming Soon page
- Impress, project to manage your documents easily
  and collaboratively
2024-05-24 10:49:09 +02:00
Anthony LC 5c9e4ab3e6 🏷️(backend) accept string as saved document
Saved documents has to be a string now.
Before it has to be a json object.
2024-05-24 10:27:34 +02:00
Anthony LC 7aeea18202 🗃️(frontend) add initial pad to webrtcserver
It is the webrtc server that will be responsible
for managing the data of the BlockNote Editor.

So to restore the data of a saved pads, we have to
add the initial pad to the room of the webrtc
server, it will then update the data of BlockNote
Editor.
The webrtc server accept Y.Doc, so we
convert our base64 data from Minio to make it
compatible with the server.

By doing so, we avoid the problem of
data lost when multiple users are connecting
and one user already updated the data of the
BlockNote Editor before saving it.
2024-05-24 10:27:34 +02:00
Anthony LC 64d508c260 (frontend) pad persistance
This commit manage the persistance of the pad.
We save the pad in different ways:
- when the user close the tab or the browser
- when the user leave the page (go to another pad
  by example)
- every 1 minute
----
- We save the pad only if the pad has been modified.
- Pads are collaborative, to not save multiple
  times the same pad, we save the pad only if
  the user is the last to have modified the pad.
----
Because of the collaborative aspect of the pads,
the best way to store our pad is to save the
Y.Doc, to do so the recommended way is to convert
the Y.Doc to a Uint8Array and then to a string
(base64). Our pad are saved as a string in a
object in a Minio bucket.
2024-05-24 10:27:34 +02:00
Anthony LC d75649d18a 🏷️(frontend) adapt some types
Adapt some pad types.
2024-05-24 10:27:34 +02:00
Anthony LC a851720441 (frontend) create useUpdatePad react query hook
Create a react query hook to update a pad.
PATCH /documents/:id
2024-05-24 10:27:34 +02:00
Anthony LC 29f13f5f4b 🚨(frontend) stop throwing errors on id pages
We stopped throwing errors on id pages, often
Next.js just need a rerender to get the id
from the router correctly.
2024-05-24 10:27:34 +02:00
Samuel Paccoud - DINUM 515b686795 (models/api) allow inviting external users to a document by their email
We want to be able to share a document with a person even if this person
does not have an account in impress yet.

This code is ported from https://github.com/numerique-gouv/people.
2024-05-24 08:20:28 +02:00
Samuel Paccoud - DINUM 125284456f 🐛(docker-compose) run node container as logged-in user
Running the container as root will install npm modules with root
access rights and generate issues when running the application with
the logged in user as we do it with Docker Compose.
2024-05-24 08:20:28 +02:00
Anthony LC b7b3c6c058 🧑‍💻(backend) add document in django admin
Add document in django admin to be able
to manage the document in the admin panel.
2024-05-23 10:44:52 +02:00
Anthony LC 644920e732 (frontend) coming soon
Add a coming soon page bind to the
docs.numerique.gouv.fr domain.
2024-05-23 10:44:52 +02:00
Anthony LC dc319578b6 👷(helm) production configuration
This PR adds the production configuration
for the helm chart.
2024-05-23 10:44:52 +02:00
Lebaud Antoine abf08fe3b3 🔧(helm) setup logout flow from Agent Connect
Add the relevant environment configurations to make sure the backend
in dev and staging environments log out the user from Agent Connect.
2024-05-22 16:08:41 +02:00
Lebaud Antoine 76e50ac02f (backend) support Agent Connect Logout flow
The default Logout view provided by Mozilla Django OIDC is not suitable
for the Agent Connect Logout flow.

Previously, when a user was logging-out, only its Django session was ended.
However, its session in the OIDC provider was still active.

Agent Connect implements a 'session/end' endpoint, that allows services to
end user session when they logout.

Agent Connect logout triggers cannot work with the default views implemented
by the dependency Mozilla Django OIDC. In their implementation, they decided
to end Django Session before redirecting to the OIDC provider.

The Django session needs to be retained during the logout process.

An OIDC state is saved to the request session, pass to Agent Connect Logout
endpoint, and verified when the backend receives the Logout callback from Agent
Connect. It seems to follow OIDC specifications.

If for any reason, the Logout flow cannot be initiated with Agent Connect,
(missing ID token in cache, unauthenticated user, etc), the user is redirected
to the final URL, without interacting with Agent Connect.
2024-05-22 16:08:41 +02:00
Lebaud Antoine 5f7e3e620a 🚚(backend) create a dedicated authentication package
Prepare adding advanced authentication features. Create a dedicated
authentication Python package within the core app.

This code organization will be more extensible.
2024-05-22 16:08:41 +02:00
Anthony LC 004a4edfe7 (frontend) add logout button
Rework the header based on latest Johann's design, which introduced a
dropdown menu to manage user account.

In this menu, you can find a logout button, which ends up the backend
session by calling the logout endpoint. Please that automatic redirection
when receiving the backend response were disabled. We handle it in our
custom hook, which reload the page.

Has the session cookie have been cleared, on reloading the page, a new
loggin flow is initiated, and the user is redirected to the OIDC provider.
2024-05-22 16:08:41 +02:00
Anthony LC d221ebe4f9 💚(CI) increase timeout e2e job
It can happen that the e2e job takes longer
than 10 minutes to finish, we increase the
timeout to 15 minutes to avoid the job to fail.
2024-05-14 16:30:49 +02:00
Anthony LC 9903b43b72 ♻️(app-desk) change header html structure
Change the html structure of the header
component to be more aligned.
Remove dummy profil picture.
2024-05-14 16:12:36 +02:00
Anthony LC d23426679a 🐛(app-desk) fix fetchPriority warning
The upgrade to react@18.3.1 has a compatibility
issue with next@14.2.3. It creates a error warning
about the fetchPriority prop. This commit fixes the
issue by downgrading react to 18.2.0 as it was
before the last upgrade.
2024-05-14 16:12:36 +02:00
Anthony LC e2e34a011d (app-impress) add La Gaufre
Add La Gaufre.
It is a button to access easily to the
La Suite products.
2024-05-14 16:12:36 +02:00
Anthony LC c1dd34512b 🚸(app-impress) footer legales pages
- Add legales pages.
- Add links to the footer for the new pages.

Legales pages are based from
https://lasuite.numerique.gouv.fr/
2024-05-14 11:57:19 +02:00
Anthony LC 88d6d8977c (app-impress) add footer
Add footer to the impress app.
2024-05-14 11:57:19 +02:00
Anthony LC 4ffcf164d8 💄(app-impress) lighter border pad editor
Make border pad editor a bit lighter.
2024-05-14 11:04:45 +02:00
Anthony LC c25814b060 ♻️(app-impress) add transition props Box component
Add transition props to Box component.
2024-05-14 11:04:45 +02:00
Anthony LC f88d625622 💄(app-impress) move title document to the top
Move teh title document to the top of the pad editor.
We let the white card only for the pad content.
2024-05-14 11:04:45 +02:00
Anthony LC a26ee3b868 💬(app-impress) replace recent with documents
Replace Recent literal by Documents.
2024-05-14 11:04:45 +02:00
Anthony LC c0d919ae52 💄(app-impress) re-design icon close panel
We deleted the menu, so we have to retouch
a bit the icon close panel to fit the new design.
We change the icon to have something more
intuitive by using material icons.
2024-05-14 11:04:45 +02:00
Anthony LC 944385c83f 🔥(app-impress) remove menu
For the moment we only have 1 menu item,
so we can remove the menu.
2024-05-14 11:04:45 +02:00
Anthony LC ccb6d4cb91 (app-impress) add @blocknote/mantine
The new version of blocknote dissociates
the ux from the @blocknote/react package.
It is now in a separate package @blocknote/mantine.
We add this package to the impress app.
We adapt the toolbar to fit the new package.
2024-05-13 17:21:58 +02:00
renovate[bot] 5853adb8eb ⬆️(dependencies) update js dependencies 2024-05-13 17:21:58 +02:00
Anthony LC 70994de914 💚(ci) improve secrets for createsuperuser
Avoid secrets to be visible from running deployments.
2024-05-13 12:12:52 +02:00
Anthony LC df0ff61faf 👷(CI) configure Minio with the CI
Add the minio container to the CI on the
job test-back. Adapt the conf to have
Minio working with the backend.
2024-05-13 12:12:52 +02:00
Anthony LC b94f08d275 🧑‍💻(helm) add minio in staging environment
Add minio in staging environment.
Set the env var for the backend.
2024-05-13 12:12:52 +02:00
Anthony LC a764e5989c 🧑‍💻(helm) add minio in dev environment
Add minio in dev environment.
Set the env var for the backend.
2024-05-13 12:12:52 +02:00
Samuel Paccoud - DINUM 130e7a8c99 (documents) allow retrieving versions (list and detail)
Versions are retrieved directly from object storage and served on API
endpoints. We make sure a user who is given access to a document will
only see versions that were created after s.he gained access.
2024-05-13 12:12:52 +02:00
Samuel Paccoud - DINUM 8e262da8f5 (documents) add content field as an S3 object
The content field is a writable property on the model which is persisted
in object storage. We take advantage of the versioning, robustness and
scalability of S3.
2024-05-13 12:12:52 +02:00
Samuel Paccoud - DINUM 4956beb040 (docker-compose) add minio for object storage
We will need object storage to store our document content which comes
as a big json object. Storing it in object storage will offer us
versioning, robustness and scalability.
2024-05-13 12:12:52 +02:00
Samuel Paccoud - DINUM 397b9efbce (models/api) add document model and API
We do this by making copies of existing Template and TemplateAccess
models and API. A little refactoring is done to try to limit duplicate
code.
2024-05-13 12:12:52 +02:00
Jacques ROUSSEL 7c6e48975d 💚(ci) improve local deployment with tilt
Change the way we start django app in order to be abble to use django
admin on local deployment
2024-05-13 11:15:43 +02:00
Anthony LC f0a4de361e 👷(helm) command createsuperuser
We sometimes need a superuser in the Django
application, to fix eventual db issues.
This commit adds a superuser on the pods.
2024-05-13 11:15:43 +02:00
Anthony LC f0b3090a73 ♻️(frontend) frontend environment free
Until now, the front had to know at build time
the url of the backend and the webrtc server
to be able to communicate with them.
It is not optimal because it means that we need
multiple docker image (1 per environment) to have
the app working, it is not very flexible.

This commit will make the frontend "environment free"
by determining these urls at runtime.
2024-05-13 11:04:06 +02:00
Anthony LC 88109623ba ♻️(app-desk) replace classname spacings
Replace the classname spacings with the new
spacing system based on props.
2024-05-03 15:47:36 +02:00
Anthony LC d8e7f29072 🎨(app-desk) add margin and padding to Box
Add margin and padding system to Box component.
It proposes the autocompletion.
It is bind with the Cunninghams spacing system.
2024-05-03 15:47:36 +02:00
Jacques ROUSSEL 0c2d097d8d 💚(ci) improve secrets for k8s deployment
Avoid secrets to be visible from running deployments
2024-05-02 16:06:58 +02:00
Anthony LC 5fbb5106a9 🔥(backend) remove code_editor field
code_editor field was removed from the backend.
This filed was used to store the code editor to
generate templates.
2024-05-02 15:24:51 +02:00
Anthony LC c7a5b5f3fb 🤡(backend-demo) remove code_editor from demo
We removed the template code_editor from app, so
we redapt the demo to fit the old template.
The field code_editor is not needed anymore.
2024-05-02 15:24:51 +02:00
Anthony LC d967896874 🔥(app-impress) remove templates
We will generate the templates internally.
We remove the template editor.
2024-05-02 15:24:51 +02:00
Anthony LC b98cc01592 🚚(app-impress) move templates api to pad-tools
Before removing the templates feature, we move the
necessary api to the pad feature.
2024-05-02 15:24:51 +02:00
Anthony LC d9fa75b764 ⬇️(frontend) react 18.3.1 -> 18.2.0
Downgrade react to 18.2.0.
It seems to have a compatibility issue
with @openfun/cunningham-react.
2024-04-29 15:21:25 +02:00
Anthony LC 33b39ce315 ⬇️(eslint-config-people) eslint 9.0.0 -> 8.57.0
Downgrade eslint to 8.57.0.
9.0.0 has breaking changes, the adoption
is still very low, better to wait.
Add it in the renovate.json file.
2024-04-29 15:21:25 +02:00
renovate[bot] f5ddce6b47 ⬆️(dependencies) update js dependencies 2024-04-29 15:21:25 +02:00
renovate[bot] 8d48ded46b ⬆️(dependencies) update gunicorn to v22 [SECURITY] 2024-04-29 10:53:14 +02:00
Jacques ROUSSEL f9bec19c7d 🐛(ci) improve docker-hub
Avoid to notify argocd for nothing
2024-04-29 10:29:56 +02:00
Anthony LC 89aa048fc1 🐛(app-desk) force redirect on resources/[id]
When we use the static mode, Next.js want that we
build our resource pages at build time. But we can't
do that because users can create a resource at runtime.

So we redirect thanks to ngnix when we see that
a resource page is not found.
2024-04-19 13:38:28 +02:00
Anthony LC b2cc789665 ️(docker) separate deps y-webrtc-signaling
We separate the install part of the signaling server
from the frontend-dep stage. It will improve
the build time of both of them.
2024-04-19 13:38:28 +02:00
Anthony LC 9489264555 🔥(eslint) remove uneeded yarn.lock file
The yarn.lock doesn't seem necessary for this
package, so we're removing it.
Update the frontend yarn.lock
2024-04-19 13:38:28 +02:00
Anthony LC e1b94e3443 🌐(app-impress) translate impress in french
Add french translation to impress.
2024-04-19 13:38:28 +02:00
Anthony LC 90c8338a59 🛂(app-impress) allow writing permission on editor
allow writing permission on editor when we are not
the owner to test the POC.
2024-04-19 13:38:28 +02:00
Anthony LC 895aaff2d3 📝(readme) steps to start frontend
Define the steps to start the frontend in
development mode.
2024-04-19 13:38:28 +02:00
Anthony LC bbafce64e7 🐛(app-impress) refresh when multiple Blocknote
When multiple blocknote were created, the editor
didn't display the content of the selected blocknote
but the content of the last blocknote with a new
provider.
We were actually storing the editor, so if the
editor is stored already, we serve it instead of
creating a new one.
2024-04-19 13:38:28 +02:00
Anthony LC e901103c8b 🔨(Tilt) add y-webrtc-signaling
Add docker_build config to build y-webrtc-signaling.
2024-04-19 12:41:06 +02:00
Anthony LC f3f5f190a5 🚀(helm) webrtc helm chart
Make the webrtc helm chart.
The webrtc server is a simple websocket server
that forwards messages between clients.
The server uses the port 443 and the protocol wss.
local_url: wss://impress.127.0.0.1.nip.io/ws
2024-04-19 12:41:06 +02:00
Anthony LC 2dc2341052 🚚(docker) move frontend docker config
Move frontend docker config to
src/frontend/Dockerfile.
2024-04-19 12:41:06 +02:00
Anthony LC c2fb55506d 🧱(docker) dockerhub lasuite/impress-y-webrtc-signaling
Add the signaling server to dockerhub.
We create a special image for the signaling server,
it will be easier for scaling and maintenance.
2024-04-19 12:41:06 +02:00
Anthony LC 428cd74366 🚚(k8s) move tilt config to bin
Move tilt config to bin directory
to keep the root directory clean.
Add makefile cmd about k8s.
2024-04-19 12:41:06 +02:00
Anthony LC 0575c0f086 (app-impress) can duplicate template
Template with mode "is_public" are not editable, except
for the owner. We give the possibility to duplicate a
template, so that the user can edit the new one.
2024-04-18 18:13:59 +02:00
Anthony LC 35d500c08b 👔(backend) display css and code in template endpoint
We would like to duplicate a template from the frontend.
For that we need to access the css and code of the template.
So we add the css and code to the template endpoint.
2024-04-18 18:13:59 +02:00
Anthony LC bf83bf99b3 ♻️(app-impress) create TemplateTools component
A component that will be used to display
the tools for the template editor.
2024-04-18 18:13:59 +02:00
Anthony LC 964efe4c2f 💄(app-impress) simplify the template builder
Remove some features from the template builder
that are not necessary for the MVP.
2024-04-18 18:13:59 +02:00
Anthony LC 75801bf95f 🤡(backend) add template editor json in demo
For the demo to be compatible with the new template
editor, we need to add the json file that setup
correctly the editor.
We are able to read and use it, but not edit it,
because the demo is in is_public mode.
2024-04-18 18:13:59 +02:00
Anthony LC 4de79c2388 💫(ap-impress) improve transition
Improve transition on Box component.
2024-04-18 15:49:42 +02:00
Anthony LC f61047dd6a 💄(app-impress) keep highlighting menu when sub menu
When sub menu was open, the parent menu was not
highlighted.
This commit fixes this issue.
2024-04-18 15:49:42 +02:00
Anthony LC d3b4a576c8 🗑️(app-impress) clean the menu
- replace pad and template icon
- remove unused icons
- remove unused pages
- remove menu items
2024-04-18 15:49:42 +02:00
Anthony LC 7107caf50b 🚸(app-impress) warning when resource not editable
When a resource is public, the users can see it
but not edit it. This commit adds a warning message
to the user to tell them that the resource
is not editable.
2024-04-18 14:44:12 +02:00
Anthony LC 82463be8ec 🐛(app-impress) fix all the resources were public
All the resources were public.
We now update correctly depend the user's choice.
We add a test.
2024-04-18 14:44:12 +02:00
Anthony LC 64cc2a9f2b 🐛(app-impress) fix bug rerendering when empty data
If the data is empty, the editor kept the previous
data and didn't rerender correctly the current editor.
This commit fixes this issue.
2024-04-18 12:48:15 +02:00
Anthony LC b687ad6974 🚚(app-impress) rename PrintToPDFButton to PDFButton
- rename PrintToPDFButton to PDFButton
- rename button "Print the pad" to "Generate PDF"
2024-04-18 12:48:15 +02:00
Anthony LC 5d6ae870fa (app-impress) select template on pad
We add a template select component to the pad toolbox.
This component is a dropdown that allows the user
to select a template for the pad to add in a PDF.
2024-04-18 12:48:15 +02:00
Anthony LC ef2ebf596a 👔(app-impress) add is_public switch button
Add a switch button to the create pad form to
allow the user to set the pad as public or private.
2024-04-17 17:45:03 +02:00
Anthony LC 7463735a38 👔(backend) is_public in document and template serializer
Add is_public field to document and template serializer.
2024-04-17 17:45:03 +02:00
Anthony LC d9cdeb5c68 🚸(app-impress) add warning when body tag not in template
In order to work with the pad, the body tag
must be in the template.
This commit adds a warning to the template editor
when the body tag is not in the template.
2024-04-17 17:15:53 +02:00
Anthony LC 5d25aed765 💫(app-desk) add effect prop on Box component
Add the effect prop on Box component.
2 effects are available: 'show' and 'hide'.
2024-04-17 17:15:53 +02:00
Anthony LC a8248f10d2 👔(app-impress) button to save html and css of the template
We created a button to save the template's html and css.
With the template saved we will be able to use it
in the near future with our pads.
2024-04-17 17:15:53 +02:00
Anthony LC faeb8d137b 👔(backend) add css and code in template serializer
We want to be able to update the css and code of
a template from the update endpoint.
This commit adds the css and code fields to the
TemplateSerializer.
2024-04-17 17:15:53 +02:00
Anthony LC 5074b271ad 🐛(backend) add partial_update action to the abilities
partial_update action was missing for the permission.
So when we were patching the template,
it was not working.
This commit adds the partial_update action
to the abilities.
2024-04-17 17:15:53 +02:00
Anthony LC e13ecdd38d 👔(app-impress) save the templates editor automatically
On almost each change in the code editor,
the template is saved automatically.
It will restore as well the editor when we arrive
on the template editor page.
2024-04-17 17:15:53 +02:00
Anthony LC 3aaa3e179d 👔(backend) add code_editor column on Template
To save the template code editor content,
we need to add a new column on the Template model.
It is a JSONField that will store the code editor content.

We could in the future make an implementation to
save the code editor content in Minio.
2024-04-17 17:15:53 +02:00
Anthony LC 7922c702cd 🚀(app-impress) add template builder editor
We add the template builder editor to
the template feature.
It will be used to create and edit templates.
2024-04-17 17:15:53 +02:00
Anthony LC 8d2a78cf8d (app-impress) create Templates feature
We created the features templates.
It will be used to create, edit and
delete templates.
It is a naive copy of pads.
2024-04-17 17:15:53 +02:00
Anthony LC 7fb8a62e63 🔥(app-impress) remove La Gauffre button
The web host of the La Gauffre app is down,
La Gauffre was not rendered correctly.
La Gauffre button is removed from the header
of the Impress app, in waiting for the
web host to be up again.
2024-04-16 15:01:05 +02:00
Anthony LC 45bf99178b (app-impress) button editor convert from markdown
Some people prefer to write in markdown,
or had already written stuff in markdown,
so this commit adds a button to the editor t
hat will convert the markdown to the editor's format.
2024-04-16 15:01:05 +02:00
Anthony LC 8305cfcf99 🏷️(app-impress) add body type on generate-document endpoint
Adapt the generate-document endpoint
to serve body as html as well.
We add a type to the body parameter to
specify the type of the body.
2024-04-16 13:03:25 +02:00
Anthony LC 1df7c43dd3 🏷️(backend) add body type on generate-document endpoint
We were converting from markdown to html, but the
frontend can provide the body in html format, so
wa can avoid the conversion.

Solution:
Add body type on generate-document endpoint
to allow to choose between markdown and html.
2024-04-16 13:03:25 +02:00
Anthony LC f9705c6ce9 ♻️(backend) api resources list ordering
Give the possibility to order the resources
list by creation date (documents / templates).
By default the list is ordered by
creation date descending.
2024-04-16 11:37:11 +02:00
Anthony LC db9c9ecfe3 🤡(app-impress) remove mock pad endpoints
The backend was updated to include the
pad endpoints.
The mock pad endpoints were removed
in favor of the real ones.
We adapted the types as well to match
the real ones.
2024-04-16 11:37:11 +02:00
Samuel Paccoud - DINUM 3e0739cd0a (models/api) add document model and API
We do this by making copies of existing Template and TemplateAccess
models and API. A little refactoring is done to try to limit duplicate
code.
2024-04-16 11:37:11 +02:00
Anthony LC 0024cc5814 🚨(backend) fix linting issue
Fix linting issue in the backend codebase.
2024-04-12 16:24:23 +02:00
Anthony LC 5587cf69dd 🔧(helm) add command create_demo
We need to get dummy data in the system
for the moment, it will insert a template
for the PDF endpoint.
We need to run this command after the
migration is made.
2024-04-12 16:24:23 +02:00
Anthony LC 68c130bd84 🔧(backend) activate https on oidc redirection
mozilla-django-oidc didn't add the https://
prefix to the redirect_uri.
We set the option SECURE_PROXY_SSL_HEADER to
('HTTP_X_FORWARDED_PROTO', 'https') in the
settings.py file to force the https prefix.
2024-04-12 16:24:23 +02:00
Anthony LC 22e79e7583 🔧(backend) activate container liveness probes
Enabled Dockerflow Django app by activating liveness probes. The previously
unavailable routes such as `__heartbeat__` and `__lbheartbeat__` are now
accessible. New endpoints include:
* GET /__version__
* GET /__heartbeat__
* GET /__lbheartbeat__
2024-04-12 16:24:23 +02:00
Anthony LC 056bad08a5 🔧(backend) configure RedisCache in production settings
In development, sessions are saved in local memory. It's working well,
however it doesn't adapt to a kubernetized setup. Several pods need
to access the current sessions, which need to be stored in a single
source of truth.

With a local memory cache, pods cannot read session saved in other pods.
We end up returning 401 errors, because we cannot authenticate the user.

I preferred setting up a proper cache than storing sessions in database,
because in the long run it would be a performance bottleneck. Cache will
decrease data access latency when reading current sessions.

I added a Redis cache backend to the production settings. Sessions would
be persisted to Redis. In K8s, a Redis operator will make sure the cached
data are not lost.

Two new dependencies were added, redis and django-redis.

I followed the installation guide of django-redis dependency. These
setting were tested deploying the app to a local K8s cluster.
2024-04-12 16:24:23 +02:00
Anthony LC 2f4dd4ee7a 🔐(impress) update impress oidc secrets
Update oidc secrets in dev and staging environments
to match agent connect with impress settings.
2024-04-12 16:24:23 +02:00
Anthony LC 51babc07af 🚚(project) rename desk occurences to impress
Rename desk occurences to impress.
2024-04-12 16:24:23 +02:00
Anthony LC dbb592bc9a 🔨(CI) add Tilt
Tilt is a tool for local Kubernetes development.
It makes it easy to see your changes as you
make them, and it rebuilds and redeploys
your app as you change it.
It is a great tool for local development
and testing of Kubernetes applications.
2024-04-12 16:24:23 +02:00
Anthony LC 0dc1be4cc7 (e2e) test the pdf export of the impress app
Add a test to check the pdf export of the
impress app.
It intercept the download of the PDF and
read its content.
We can assert that the template is correctly
rendered, and that the pad text is present as
well.
2024-04-11 09:21:35 +02:00
Anthony LC a772d8fc29 🤡(backend) demo template
Create the create_demo command.
It creates a demo template for the backend.
We add it in the makefile bootstrap.

We force the template id, this id is the same as
used in the frontend for the moment.
When the template feature will be created in
the frontend side we will not have to force
it anymore.
2024-04-11 09:21:35 +02:00
Anthony LC c6c0c1355d 🙈(project) add .devcontainer in gitignore
.devcontainer is used to create virtual environment
for development. It is not necessary to be
included in the git repository.
2024-04-11 09:21:35 +02:00
Anthony LC cc64b8c8b5 🐛(backend) format html when generate document
When we converted from markdown to html,
we didn't format the html, so it was displayed
like a string in our pdf.
We now format_html the html before we generate
the pdf.
2024-04-11 09:21:35 +02:00
Anthony LC 2665b707db (app-impress) print to pdf
Feature to print the pad to pdf.

It display a button in the pad toolbar,
when clicked it will convert the pad to markdown,
send the markdown with a template to the backend
to convert it to pdf and then download the pdf.
2024-04-11 09:21:35 +02:00
Anthony LC e28a4294fa (app-desk) add useCreatePdfFromMarkdown
Add the hook useCreatePdfFromMarkdown,
it will be used to generate a pdf from a
a template and a markdown.
2024-04-11 09:21:35 +02:00
Anthony LC 9ca63baeeb 🔨(backend) add local CSRF_TRUSTED_ORIGINS
To allow the frontend to make requests to the backend,
we need to add the frontend's origin
to the CSRF_TRUSTED_ORIGINS setting.
2024-04-11 09:21:35 +02:00
Anthony LC a5a8a64350 ♻️(app-impress) refacto pad store to add the editor
To spread the data editor to different feature
of our app, we add the editor in the pad store.
It will give an easy access to the editor data.
2024-04-11 09:21:35 +02:00
Anthony LC 272b18ae02 (y-webrtc-signaling) test signaling server
Create a e2e testcase to test the signaling server.
2024-04-09 16:56:21 +02:00
Anthony LC 37367f2d22 🎨(app-impress) different color per user
We generate a color for each user.
This color is used to highlight the
user's cursor and selection.
2024-04-09 16:56:21 +02:00
Anthony LC 2070a159e1 ️(app-impress) create pad store
Better to store the WebrtcProvider, we will
reuse the provider if one is already created.
2024-04-09 16:56:21 +02:00
Anthony LC a4e8bbd26e 🚚(app-impress) rename usePadStore to usePadPanelStore
This commit renames the `usePadStore`
to `usePadPanelStore` to better
reflect the purpose of the store.
2024-04-09 16:56:21 +02:00
Anthony LC 75bb4b36d4 🔨(y-webrtc-signaling) dockerize y-webrtc-signaling
Dockerize y-webrtc-signaling for futur deployment.
Add a docker-compose file to run easily
the signaling server.
2024-04-09 16:56:21 +02:00
Anthony LC f4e4cbaa56 (y-webrtc-signaling) create signaling server
Create signaling server for WebRTC.
It will be used for the collaborative editor, client
will connect to this server to see their
changes in real-time.
2024-04-09 16:56:21 +02:00
Anthony LC 6b567a0f69 🔧(sops) add secrets
Add secrets to the repository using sops.
2024-04-05 12:42:33 +02:00
Anthony LC 007a9ae4f4 💚(docker) adapt dockerfile with people updates
Adapt the dockerfile to include the new
people updates, plus adapt to impress project.
2024-04-05 12:42:33 +02:00
Anthony LC ca0571cb21 (CI) copy docker-hub github-actions from people
Copy of docker-hub github-actions
from people repository to impress repository.
Replace occurence of people by impress.
2024-04-05 12:42:33 +02:00
Anthony LC 560dbf1c2e 🔧(helm) replace occurences of desk and people
Replace naively all occurences of
desk and people with impress.
2024-04-05 12:42:33 +02:00
Anthony LC 17729e231b (CI) copy helm config from people
Naive copy of helm config from people repository
to impress repository.
2024-04-05 12:42:33 +02:00
Anthony LC 409691d466 🚨(app-desk) improve linter
The linter was passing near the ts errors in
the tests, we improve the linter by adding
a ts checkup.
2024-04-04 17:40:27 +02:00
Anthony LC 603ece466d 🤡(app-impress) mock BlockNoteEditor
BlockNoteEditor uses many libraries not compatible
with Jest.
This commit mocks the BlockNoteEditor
component to allow Jest to run without errors.
2024-04-04 17:40:27 +02:00
Anthony LC 79831154be (app-impress) add basic blocknotes editor
Create PadEditor, the parent component for the editors.
We integrate the BlockNoteEditor into the PadEditor.
2024-04-04 17:40:27 +02:00
Anthony LC da273d6b2b (app-markdown) add necessary library for pad editor
Add the necessary library for the pad editor:
- blocknote editor (Notion like editor)
- yjs and y-webrtc for real-time collaboration
2024-04-04 17:40:27 +02:00
Anthony LC 5ce77c2138 🤡(app-desk) mock pad and pads panel
In order to access to the pad page we mocked
the api calls of the pad and pads panel.
2024-04-04 17:40:27 +02:00
renovate[bot] b7663cca2d ⬆️(dependencies) update js dependencies 2024-04-04 12:38:11 +02:00
Anthony LC 80335ffd15 🔥(markdownPDF) remove versionning app mardownPDF
Remove the app mardownPDF from the versionning,
this app is not used anymore, we don't want
to maintain it.
2024-04-04 12:05:54 +02:00
Anthony LC 6de828c049 🚨(frontend) fix CI probs
The CI highlighted some issues with the tests.
This commit fixes the issues.
2024-04-03 17:36:54 +02:00
Anthony LC 352d4f6d27 🔥(app-impress) remove teams pages
Remove teams pages from the next router.
2024-04-03 17:36:54 +02:00
Anthony LC 3e6f20aedd ️(frontend) clean yarn.lock
The yarn.lock file get full of garbage and old
dependencies after a while. This commit cleans it.
2024-04-03 17:36:54 +02:00
Anthony LC bf19c50146 💚(CI) add frontend jobs
Add the frontend jobs to the CI pipeline.
2024-04-03 17:36:54 +02:00
Anthony LC 1d3f1f793e (e2e) adapt tests to impress app
Adapt e2e test to works with Impress.
2024-04-03 17:36:54 +02:00
renovate[bot] cad206cccf ⬆️(dependencies) update django to v5.0.3 [SECURITY] 2024-04-03 13:10:09 +02:00
Anthony LC 3cd1852b93 🔧(sops) add secrets
Add secrets to the repository using sops.
2024-04-03 13:07:05 +02:00
Anthony LC d19c7f7fd8 👷(project) rename i18n-back to i18n-crowdin
Rename i18n-back to i18n-crowdin.
Change the job to use the 'age' env.
2024-04-03 13:07:05 +02:00
Anthony LC 8afc003004 🐛(docker) switch CMD form from Shell to Exec
`backend-development` and `backend-production` CMD syntaxes were
using a Shell Form. Shell form prevented Unix signals from reaching
our container correctly, such as SIGTERM. Also, the shell process
ends up being the PID 1, instead of our Python scripts.

Docker recommends to use the exec form whenever possible.
2024-04-03 13:07:05 +02:00
Anthony LC 23797f2953 🔧(project) add frontend cmd to Makefile
add frontend cmd to Makefile.
2024-04-03 13:07:05 +02:00
Anthony LC 78bccafb93 🐛(backend) generate po file
Generate the PO file.
It will create the locale folder, and so
init correctly LOCALE_PATHS env.
This should fix the CI.
2024-04-03 13:07:05 +02:00
Anthony LC 42bea9940e 🔥(app-impress) remove addMembers feature
addMembers feature was a part of the People
project, we don't need it in the Impress project.
2024-04-03 11:57:36 +02:00
Anthony LC 55e2a2073d 🔥(app-impress) remove Members feature
Members feature was a part of the People
project, we don't need it in the Impress project.
2024-04-03 11:57:36 +02:00
Anthony LC 1f804ad1f9 🔥(app-impress) remove Teams feature
Teams feature was a part of the People
project, we don't need it in the Impress project.
2024-04-03 11:57:36 +02:00
Anthony LC 3ba388bc49 ⬆️(project) update renovate.json config
Update the renovate config to fit with
what we setup in People project.
2024-04-03 11:57:36 +02:00
Anthony LC b517b0bc39 (app-impress) create feature pad
Create a new feature pad for the app impress.
It will be used to display a pad in the app.
2024-04-03 11:57:36 +02:00
Anthony LC 1cb138ecb3 (app-impress) create feature pads-create
Create a new feature pads-create for the app impress.
It will be used to create a pad in the app.
2024-04-03 11:57:36 +02:00
Anthony LC 1dd33706c0 (app-impress) create feature pads-panel
Create a new feature pads-panel for the app impress.
It will be used to display the pads in the app.
2024-04-03 11:57:36 +02:00
Anthony LC 6fe5f3ef72 🚚(app-impress) rename icon-desk.svg to icon-impress.svg
Rename icon-desk.svg to icon-impress.svg.
2024-04-03 11:57:36 +02:00
Anthony LC 1bf391f26f (app-impress) add layouts
Add layouts folder to the app-impress,
it will contain all the layouts for the app.
For the moment:
- MainLayout.tsx
- PadLayout.tsx
2024-04-03 11:57:36 +02:00
Anthony LC dfafd335f4 🌐(i18n) create package i18n
The package i18n will handle the
internationalization of the applications.
It will parse the frontend code and extract
the translations to be send to the crowdin
platform.
2024-04-02 16:39:17 +02:00
Anthony LC 3098b9f4fc 🚀(app-impress) create the e2e app impress
Create the e2e app impress, it will be used
to test the app impress.
2024-04-02 16:39:17 +02:00
Anthony LC 8fda0bc9b9 🚀(app-impress) create the base app impress
Create the base app impress, based on the
people app.
2024-04-02 16:39:17 +02:00
Anthony LC c64b66c797 🚨(frontend) add lib eslint-config-impress
Create the eslint-config-impress package to share
eslint configuration with all impress projects.
2024-04-02 16:39:17 +02:00
Anthony LC c3e354d727 🚚(frontend) move previous app to mardownPDF app
We will start an monorepo archi, we move the previous
app to a new folder called mardownPDF.
2024-04-02 16:39:17 +02:00
Samuel Paccoud - DINUM ab7d9933e0 ♻️(project) rename project from "publish" to "impress"
The repository was renamed to "impress" but the code was still
mentionning "publish".
2024-03-08 08:53:56 +01:00
Samuel Paccoud - DINUM f581eb8abd (models/api) add RBAC on templates linking accesses to a team name
We want to be able to control who can access a template via roles.
I added this feature on the TeamAccess model assuming that the teams
to which a user belongs can be retrieved via a `get_teams` method on
the user model. The idea is that this method will get the teams either
via a call to an external API or directly from the OIDC token upon
user login. This list of teams will probably have to be cached for
each user.
2024-03-07 21:21:18 +01:00
Lebaud Antoine a23118bee4 🚨(backend) fix minor linting issues
Lint backend tests and sources.
2024-03-03 08:29:00 +01:00
Lebaud Antoine 47aeab76a5 (backend) test the authentication class
Add tests on get_or_create method.
2024-03-03 08:29:00 +01:00
Lebaud Antoine 551468470f 🐛(project) run production image locally with docker-compose
The local deployment of the Production image through docker-compose was
failing due to issues in the Django configurations, influenced by Joanie.

The bug stemmed from a dependency on a development-specific package
(drf-spectacular-sidecar) while attempting to run the application in
production mode.

Changes Made:
- Introduced new Django settings for local demo environments.
2024-03-03 08:29:00 +01:00
Lebaud Antoine 36d6735798 ✏️(project) fix minor typos
Found typos and fixed them.
2024-03-03 08:29:00 +01:00
Lebaud Antoine b1892ded17 (backend) drop JWT authentication in API tests
Force login to bypass authorization checks when necessary.

Note: Generating a session cookie through OIDC flow
is not supported while testing our API.
2024-03-03 08:29:00 +01:00
Lebaud Antoine b9eee3e643 🔧(backend) configure Authorization Code authentication
Integrate 'mozilla-django-oidc' dependency, to support
Authorization Code flow, which is required by Agent Connect.

Thus, we provide a secure back channel OIDC flow, and return
to the client only a session cookie.

Done:
- Replace JWT authentication by Session based authentication in DRF
- Update Django settings to make OIDC configurations easily editable
- Add 'mozilla-django-oidc' routes to our router
- Implement a custom Django Authentication class to adapt
'mozilla-django-oidc' to our needs

'mozilla-django-oidc' routes added are:
- /authenticate
- /callback (the redirect_uri called back by the Idp)
- /logout
2024-03-03 08:29:00 +01:00
Lebaud Antoine 23e92d12fb (project) configure Keycloak server to support OIDC
Create a realm 'impress' and configure it to support
the authorization code flow.
2024-03-03 08:29:00 +01:00
Samuel Paccoud - DINUM 0f9327a1de ♻️(backend) refactor post hackathon to a first working version
This project was copied and hacked to make a POC in a 2-day hackathon.
We need to clean and refactor things in order to get a first version
of the product we want.
2024-02-23 18:41:36 +01:00
Samuel Paccoud 0a3e26486e Merge pull request #1 from numerique-gouv/dependabot/npm_and_yarn/src/mail/semver-5.7.2
Bump semver from 5.7.1 to 5.7.2 in /src/mail
2024-02-16 19:15:23 +01:00
Samuel Paccoud ae2ba59c82 Merge pull request #3 from numerique-gouv/dependabot/pip/src/backend/django-5.0.2
Bump django from 5.0 to 5.0.2 in /src/backend
2024-02-16 19:14:44 +01:00
dependabot[bot] 0f0b0f0de1 Bump django from 5.0 to 5.0.2 in /src/backend
Bumps [django](https://github.com/django/django) from 5.0 to 5.0.2.
- [Commits](https://github.com/django/django/compare/5.0...5.0.2)

---
updated-dependencies:
- dependency-name: django
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-07 17:37:18 +00:00
Samuel Paccoud - DINUM 44ff62c08f (backend) parse YAML metadata
YAML header is processed, to fill Django
templates with the right parametrized values.

Used an external dependency, frontmatter.
2024-02-05 22:59:14 +01:00
Lebaud Antoine 312a680b66 (frontend) set up Vite-based frontend project
Chose Vite for static output efficiency, aligning with project needs.

All API interactions are currently unauthenticated. SSO support
planned soon, using ProConnect.

UX is minimalistic, and showcases the core idea.

Components introduced:
* AppProvider
* Select and TextArea Rhf inputs

API hooks introduced:
* useGeneratePDF, generates a PDF, and downloads it in the client.
* useTemplates, fetches available templates to populate Select options.
2024-01-16 16:35:11 +01:00
dependabot[bot] 7eab2b61a5 Bump semver from 5.7.1 to 5.7.2 in /src/mail
Bumps [semver](https://github.com/npm/node-semver) from 5.7.1 to 5.7.2.
- [Release notes](https://github.com/npm/node-semver/releases)
- [Changelog](https://github.com/npm/node-semver/blob/v5.7.2/CHANGELOG.md)
- [Commits](https://github.com/npm/node-semver/compare/v5.7.1...v5.7.2)

---
updated-dependencies:
- dependency-name: semver
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-09 15:30:25 +00:00
Samuel Paccoud - DINUM 62df0524ac (project) first proof of concept printing pdf from markdown
This is a boilerplate inspired from https://github.com/openfun/joanie
2024-01-09 15:30:36 +01:00
587 changed files with 30877 additions and 20681 deletions
+1 -1
View File
@@ -32,5 +32,5 @@ db.sqlite3
.pylint.d
.pytest_cache
# Frontend dependencies
# Frontend
node_modules
+1 -1
View File
@@ -18,7 +18,7 @@ A clear and concise description of what you expected to happen (or code).
3. And then the bug happens!
**Environment**
- People version:
- Impress version:
- Platform:
**Possible Solution**
+2 -2
View File
@@ -9,9 +9,9 @@ We primarily use GitHub as an issue tracker. If however you're encountering an i
---
Please make sure you have read our [main Readme](https://github.com/numerique-gouv/people).
Please make sure you have read our [main Readme](https://github.com/numerique-gouv/impress).
Also make sure it was not already answered in [an open or close issue](https://github.com/numerique-gouv/people/issues).
Also make sure it was not already answered in [an open or close issue](https://github.com/numerique-gouv/impress/issues).
If your question was not covered, and you feel like it should be, fire away! We'd love to improve our docs! 👌
+2 -2
View File
@@ -18,7 +18,7 @@ jobs:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "people,secrets"
repositories: "impress,secrets"
-
name: Checkout repository
uses: actions/checkout@v2
@@ -29,7 +29,7 @@ jobs:
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/people/secrets.enc.env
secret-file: secrets/numerique-gouv/impress/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
-
name: Call argocd github webhook
+66 -19
View File
@@ -25,25 +25,25 @@ jobs:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "people,secrets"
repositories: "impress,secrets"
-
name: Checkout repository
uses: actions/checkout@v2
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
-
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/impress/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
-
name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: lasuite/people-backend
-
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/people/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
images: lasuite/impress-backend
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
@@ -69,25 +69,25 @@ jobs:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "people,secrets"
repositories: "impress,secrets"
-
name: Checkout repository
uses: actions/checkout@v2
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
-
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/impress/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
-
name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: lasuite/people-frontend
-
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/people/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
images: lasuite/impress-frontend
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
@@ -97,18 +97,65 @@ jobs:
uses: docker/build-push-action@v5
with:
context: .
file: ./src/frontend/Dockerfile
target: frontend-production
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-and-push-y-provider:
runs-on: ubuntu-latest
steps:
-
uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "impress,secrets"
-
name: Checkout repository
uses: actions/checkout@v2
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
-
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/impress/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
-
name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: lasuite/impress-y-provider
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
-
name: Build and push
uses: docker/build-push-action@v5
with:
context: .
file: ./src/frontend/Dockerfile
target: y-provider
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
notify-argocd:
needs:
- build-and-push-frontend
- build-and-push-backend
runs-on: ubuntu-latest
if: github.event_name != 'pull_request'
if: |
github.event_name != 'pull_request'
steps:
-
uses: actions/create-github-app-token@v1
@@ -117,7 +164,7 @@ jobs:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "people,secrets"
repositories: "impress,secrets"
-
name: Checkout repository
uses: actions/checkout@v2
@@ -128,7 +175,7 @@ jobs:
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/people/secrets.enc.env
secret-file: secrets/numerique-gouv/impress/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
-
name: Call argocd github webhook
+253
View File
@@ -0,0 +1,253 @@
name: Frontend Workflow
on:
push:
branches:
- main
pull_request:
branches:
- "*"
jobs:
install-front:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "18.x"
- 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: Install dependencies
if: steps.front-node_modules.outputs.cache-hit != 'true'
run: cd src/frontend/ && yarn install --frozen-lockfile
- name: Cache install frontend
if: steps.front-node_modules.outputs.cache-hit != 'true'
uses: actions/cache@v4
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
build-front:
runs-on: ubuntu-latest
needs: install-front
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Restore the frontend cache
uses: actions/cache@v4
id: front-node_modules
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
- name: Build CI App
run: cd src/frontend/ && yarn ci:build
- name: Cache build frontend
uses: actions/cache@v4
with:
path: src/frontend/apps/impress/out/
key: build-front-${{ github.run_id }}
test-front:
runs-on: ubuntu-latest
needs: install-front
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Restore the frontend cache
uses: actions/cache@v4
id: front-node_modules
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
- name: Test App
run: cd src/frontend/ && yarn app:test
lint-front:
runs-on: ubuntu-latest
needs: install-front
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Restore the frontend cache
uses: actions/cache@v4
id: front-node_modules
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
- name: Check linting
run: cd src/frontend/ && yarn lint
test-e2e-chromium:
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 chromium
- name: Run e2e tests
run: cd src/frontend/ && yarn e2e:test --project='chromium'
- uses: actions/upload-artifact@v3
if: always()
with:
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
+207
View File
@@ -0,0 +1,207 @@
name: Main Workflow
on:
push:
branches:
- main
pull_request:
branches:
- "*"
jobs:
lint-git:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' # Makes sense only for pull requests
steps:
- name: Checkout repository
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: show
run: git log
- name: Enforce absence of print statements in code
run: |
! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- . ':(exclude)**/impress.yml' | grep "print("
- name: Check absence of fixup commits
run: |
! git log | grep 'fixup!'
- name: Install gitlint
run: pip install --user requests gitlint
- name: Lint commit messages added to main
run: ~/.local/bin/gitlint --commits origin/${{ github.event.pull_request.base.ref }}..HEAD
check-changelog:
runs-on: ubuntu-latest
if: |
contains(github.event.pull_request.labels.*.name, 'noChangeLog') == false &&
github.event_name == 'pull_request'
steps:
- name: Checkout repository
uses: actions/checkout@v3
with:
fetch-depth: 50
- name: Check that the CHANGELOG has been modified in the current branch
run: git diff --name-only ${{ github.event.pull_request.base.sha }} ${{ github.event.after }} | grep 'CHANGELOG.md'
lint-changelog:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v2
- name: Check CHANGELOG max line length
run: |
max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L)
if [ $max_line_length -ge 80 ]; then
echo "ERROR: CHANGELOG has lines longer than 80 characters."
exit 1
fi
build-mails:
runs-on: ubuntu-latest
defaults:
run:
working-directory: src/mail
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: "18"
- 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: Install yarn
if: steps.mail-templates.outputs.cache-hit != 'true'
run: npm install -g yarn
- name: Install node dependencies
if: steps.mail-templates.outputs.cache-hit != 'true'
run: yarn install --frozen-lockfile
- name: Build mails
if: steps.mail-templates.outputs.cache-hit != 'true'
run: yarn build
- name: Cache mail templates
if: steps.mail-templates.outputs.cache-hit != 'true'
uses: actions/cache@v4
with:
path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
lint-back:
runs-on: ubuntu-latest
defaults:
run:
working-directory: src/backend
steps:
- name: Checkout repository
uses: actions/checkout@v2
- name: Install Python
uses: actions/setup-python@v3
with:
python-version: "3.10"
- name: Install development dependencies
run: pip install --user .[dev]
- name: Check code formatting with ruff
run: ~/.local/bin/ruff format . --diff
- name: Lint code with ruff
run: ~/.local/bin/ruff check .
- name: Lint code with pylint
run: ~/.local/bin/pylint .
test-back:
runs-on: ubuntu-latest
needs: build-mails
defaults:
run:
working-directory: src/backend
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: impress
POSTGRES_USER: dinum
POSTGRES_PASSWORD: pass
ports:
- 5432:5432
# needed because the postgres container does not provide a healthcheck
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
env:
DJANGO_CONFIGURATION: Test
DJANGO_SETTINGS_MODULE: impress.settings
DJANGO_SECRET_KEY: ThisIsAnExampleKeyForTestPurposeOnly
OIDC_OP_JWKS_ENDPOINT: /endpoint-for-test-purpose-only
DB_HOST: localhost
DB_NAME: impress
DB_USER: dinum
DB_PASSWORD: pass
DB_PORT: 5432
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
AWS_S3_ENDPOINT_URL: http://localhost:9000
AWS_S3_ACCESS_KEY_ID: impress
AWS_S3_SECRET_ACCESS_KEY: password
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Create writable /data
run: |
sudo mkdir -p /data/media && \
sudo mkdir -p /data/static
- 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: Start Minio
run: |
docker pull minio/minio
docker run -d --name minio \
-p 9000:9000 \
-e "MINIO_ACCESS_KEY=impress" \
-e "MINIO_SECRET_KEY=password" \
-v /data/media:/data \
minio/minio server --console-address :9001 /data
- name: Configure MinIO
run: |
MINIO=$(docker ps | grep minio/minio | sed -E 's/.*\s+([a-zA-Z0-9_-]+)$/\1/')
docker exec ${MINIO} sh -c \
"mc alias set impress http://localhost:9000 impress password && \
mc alias ls && \
mc mb impress/impress-media-storage && \
mc version enable impress/impress-media-storage"
- name: Install Python
uses: actions/setup-python@v3
with:
python-version: "3.10"
- name: Install development dependencies
run: pip install --user .[dev]
- name: Install gettext (required to compile messages)
run: |
sudo apt-get update
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
-384
View File
@@ -1,384 +0,0 @@
name: People Workflow
on:
push:
branches:
- main
pull_request:
branches:
- '*'
jobs:
lint-git:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' # Makes sense only for pull requests
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: show
run: git log
- name: Enforce absence of print statements in code
run: |
! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- . ':(exclude)**/people.yml' | grep "print("
- name: Check absence of fixup commits
run: |
! git log | grep 'fixup!'
- name: Install gitlint
run: pip install --user requests gitlint
- name: Lint commit messages added to main
run: ~/.local/bin/gitlint --commits origin/${{ github.event.pull_request.base.ref }}..HEAD
check-changelog:
runs-on: ubuntu-latest
if: |
contains(github.event.pull_request.labels.*.name, 'noChangeLog') == false &&
github.event_name == 'pull_request'
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check that the CHANGELOG has been modified in the current branch
run: git whatchanged --name-only --pretty="" origin/${{ github.event.pull_request.base.ref }}..HEAD | grep CHANGELOG
lint-changelog:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Check CHANGELOG max line length
run: |
max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L)
if [ $max_line_length -ge 80 ]; then
echo "ERROR: CHANGELOG has lines longer than 80 characters."
exit 1
fi
install-front:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18.x'
- 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: Install dependencies
if: steps.front-node_modules.outputs.cache-hit != 'true'
run: cd src/frontend/ && yarn install --frozen-lockfile
- name: Cache install frontend
if: steps.front-node_modules.outputs.cache-hit != 'true'
uses: actions/cache@v4
with:
path: 'src/frontend/**/node_modules'
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
build-front:
runs-on: ubuntu-latest
needs: install-front
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Restore the frontend cache
uses: actions/cache@v4
id: front-node_modules
with:
path: 'src/frontend/**/node_modules'
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
- name: Build CI App
run: cd src/frontend/ && yarn ci:build
- name: Cache build frontend
uses: actions/cache@v4
with:
path: src/frontend/apps/desk/out/
key: build-front-${{ github.run_id }}
test-front:
runs-on: ubuntu-latest
needs: install-front
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Restore the frontend cache
uses: actions/cache@v4
id: front-node_modules
with:
path: 'src/frontend/**/node_modules'
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
- name: Test App
run: cd src/frontend/ && yarn app:test
lint-front:
runs-on: ubuntu-latest
needs: install-front
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Restore the frontend cache
uses: actions/cache@v4
id: front-node_modules
with:
path: 'src/frontend/**/node_modules'
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
- name: Check linting
run: cd src/frontend/ && yarn lint
test-e2e:
runs-on: ubuntu-latest
needs: [build-mails, build-front]
timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set services env variables
run: |
make create-env-files
cat env.d/development/common.e2e.dist >> env.d/development/common
- name: Download mails' templates
uses: actions/download-artifact@v4
with:
name: mails-templates
path: src/backend/core/templates/mail
- 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/desk/out/
key: build-front-${{ github.run_id }}
- name: Build and Start Docker Servers
env:
DOCKER_BUILDKIT: 1
COMPOSE_DOCKER_CLI_BUILD: 1
run: |
docker-compose build --pull --build-arg BUILDKIT_INLINE_CACHE=1
make run
- 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
- name: Run e2e tests
run: cd src/frontend/ && yarn e2e:test
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: src/frontend/apps/e2e/report/
retention-days: 7
build-mails:
runs-on: ubuntu-latest
defaults:
run:
working-directory: src/mail
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
- name: Install yarn
run: npm install -g yarn
- name: Install node dependencies
run: yarn install --frozen-lockfile
- name: Build mails
run: yarn build
- name: Persist mails' templates
uses: actions/upload-artifact@v4
with:
name: mails-templates
path: src/backend/core/templates/mail
lint-back:
runs-on: ubuntu-latest
defaults:
run:
working-directory: src/backend
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Python
uses: actions/setup-python@v5
with:
python-version: '3.10'
- name: Install development dependencies
run: pip install --user .[dev]
- name: Check code formatting with ruff
run: ~/.local/bin/ruff format . --diff
- name: Lint code with ruff
run: ~/.local/bin/ruff check .
- name: Lint code with pylint
run: ~/.local/bin/pylint .
test-back:
runs-on: ubuntu-latest
needs: build-mails
defaults:
run:
working-directory: src/backend
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: people
POSTGRES_USER: dinum
POSTGRES_PASSWORD: pass
ports:
- 5432:5432
# needed because the postgres container does not provide a healthcheck
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
env:
DJANGO_CONFIGURATION: Test
DJANGO_SETTINGS_MODULE: people.settings
DJANGO_SECRET_KEY: ThisIsAnExampleKeyForTestPurposeOnly
OIDC_OP_JWKS_ENDPOINT: /endpoint-for-test-purpose-only
DB_HOST: localhost
DB_NAME: people
DB_USER: dinum
DB_PASSWORD: pass
DB_PORT: 5432
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Create writable /data
run: |
sudo mkdir -p /data/media && \
sudo mkdir -p /data/static
- name: Download mails' templates
uses: actions/download-artifact@v4
with:
name: mails-templates
path: src/backend/core/templates/mail
- name: Install Python
uses: actions/setup-python@v5
with:
python-version: '3.10'
- name: Install development dependencies
run: pip install --user .[dev]
- name: Install gettext (required to compile messages)
run: |
sudo apt-get update
sudo apt-get install -y gettext
- 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: "people,secrets"
-
name: Checkout repository
uses: actions/checkout@v2
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
- 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@v5
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: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/people/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
- 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: Download sources from Crowdin to stay synchronized
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 download sources -c /app/crowdin/config.yml
- 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
+1 -6
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
@@ -78,5 +74,4 @@ db.sqlite3
.idea/
.vscode/
*.iml
.devcontainer/
.tool-versions
.devcontainer
+10
View File
@@ -0,0 +1,10 @@
creation_rules:
- path_regex: ./*
key_groups:
- age:
- age15fyxdwmg5mvldtqqus87xspuws2u0cpvwheehrtvkexj4tnsqqysw6re2x # jacques
- age16hnlml8yv4ynwy0seer57g8qww075crd0g7nsundz3pj4wk7m3vqftszg7 # github-repo
- age1plkp8td6zzfcavjusmsfrlk54t9vn8jjxm8zaz7cmnr7kzl2nfnsd54hwg # Anthony Le-Courric
- age12g6f5fse25tgrwweleh4jls3qs52hey2edh759smulwmk5lnzadslu2cp3 # Antoine Lebaud
- age1hnhuzj96ktkhpyygvmz0x9h8mfvssz7ss6emmukags644mdhf4msajk93r # Samuel Paccoud
+149
View File
@@ -6,4 +6,153 @@ 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
## 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
## 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
- 🤡(demo) generate dummy documents on dev users #120
- ✨(frontend) create side modal component #134
- ✨(frontend) Doc grid actions (update / delete) #136
- ✨(frontend) Doc editor header information #137
## Changed
- ♻️(frontend) replace docs panel with docs grid #120
- ♻️(frontend) create a doc from a modal #132
- ♻️(frontend) manage members from the share modal #140
## [1.0.0] - 2024-07-02
## Added
- 🛂(frontend) Manage the document's right (#75)
- ✨(frontend) Update document (#68)
- ✨(frontend) Remove document (#68)
- 🐳(docker) dockerize dev frontend (#63)
- 👔(backend) list users with email filtering (#79)
- ✨(frontend) add user to a document (#52)
- ✨(frontend) invite user to a document (#52)
- 🛂(frontend) manage members (update role / list / remove) (#81)
- ✨(frontend) offline mode (#88)
- 🌐(frontend) translate cgu (#83)
- ✨(service-worker) offline doc management (#94)
- ⚗️(frontend) Add beta tag on logo (#121)
## Changed
- ♻️(frontend) Change site from Impress to Docs (#76)
- ✨(frontend) Generate PDF from a modal (#68)
- 🔧(helm) sticky session by request_uri for signaling server (#78)
- ♻️(frontend) change logo (#84)
- ♻️(frontend) pdf has title doc (#84)
- ⚡️(e2e) unique login between tests (#80)
- ⚡️(CI) improve e2e job (#86)
- ♻️(frontend) improve the error and message info ui (#93)
- ✏️(frontend) change all occurences of pad to doc (#99)
## Fixed
- 🐛(frontend) Fix the break line when generate PDF (#84)
## Delete
- 💚(CI) Remove trigger workflow on push tags on CI (#68)
- 🔥(frontend) Remove coming soon page (#121)
## [0.1.0] - 2024-05-24
## Added
- ✨(frontend) Coming Soon page (#67)
- 🚀 Impress, project to manage your documents easily and collaboratively.
[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
+15 -62
View File
@@ -1,4 +1,4 @@
# Django People
# Django impress
# ---- base image to inherit from ----
FROM python:3.10-slim-bullseye as base
@@ -11,55 +11,6 @@ RUN apt-get update && \
apt-get -y upgrade && \
rm -rf /var/lib/apt/lists/*
### ---- Front-end dependencies image ----
FROM node:20 as frontend-deps
WORKDIR /deps
COPY ./src/frontend/package.json ./package.json
COPY ./src/frontend/yarn.lock ./yarn.lock
COPY ./src/frontend/apps/desk/package.json ./apps/desk/package.json
COPY ./src/frontend/packages/i18n/package.json ./packages/i18n/package.json
COPY ./src/frontend/packages/eslint-config-people/package.json ./packages/eslint-config-people/package.json
RUN yarn --frozen-lockfile
### ---- Front-end builder dev image ----
FROM node:20 as frontend-builder-dev
WORKDIR /builder
COPY --from=frontend-deps /deps/node_modules ./node_modules
COPY ./src/frontend .
WORKDIR ./apps/desk
### ---- Front-end builder image ----
FROM frontend-builder-dev as frontend-builder
RUN yarn build
# ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:1.25 as frontend-production
# Un-privileged user running the application
ARG DOCKER_USER
USER ${DOCKER_USER}
COPY --from=frontend-builder \
/builder/apps/desk/out \
/usr/share/nginx/html
COPY ./src/frontend/apps/desk/conf/default.conf /etc/nginx/conf.d
# Copy entrypoint
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
CMD ["nginx", "-g", "daemon off;"]
# ---- Back-end builder image ----
FROM base as back-builder
@@ -85,7 +36,7 @@ RUN yarn install --frozen-lockfile && \
# ---- static link collector ----
FROM base as link-collector
ARG PEOPLE_STATIC_ROOT=/data/static
ARG IMPRESS_STATIC_ROOT=/data/static
# Install libpangocairo & rdfind
RUN apt-get update && \
@@ -97,7 +48,7 @@ RUN apt-get update && \
# Copy installed python dependencies
COPY --from=back-builder /install /usr/local
# Copy people application (see .dockerignore)
# Copy impress application (see .dockerignore)
COPY ./src/backend /app/
WORKDIR /app
@@ -108,7 +59,7 @@ RUN DJANGO_CONFIGURATION=Build DJANGO_JWT_PRIVATE_SIGNING_KEY=Dummy \
# Replace duplicated file by a symlink to decrease the overall size of the
# final image
RUN rdfind -makesymlinks true -followsymlinks true -makeresultsfile false ${PEOPLE_STATIC_ROOT}
RUN rdfind -makesymlinks true -followsymlinks true -makeresultsfile false ${IMPRESS_STATIC_ROOT}
# ---- Core application image ----
FROM base as core
@@ -124,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/*
@@ -138,7 +91,7 @@ RUN chmod g=u /etc/passwd
# Copy installed python dependencies
COPY --from=back-builder /install /usr/local
# Copy people application (see .dockerignore)
# Copy impress application (see .dockerignore)
COPY ./src/backend /app/
WORKDIR /app
@@ -159,9 +112,9 @@ RUN apt-get update && \
apt-get install -y postgresql-client && \
rm -rf /var/lib/apt/lists/*
# Uninstall people and re-install it in editable mode along with development
# Uninstall impress and re-install it in editable mode along with development
# dependencies
RUN pip uninstall -y people
RUN pip uninstall -y impress
RUN pip install -e .[dev]
# Restore the un-privileged user running the application
@@ -179,21 +132,21 @@ CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
# ---- Production image ----
FROM core as backend-production
ARG PEOPLE_STATIC_ROOT=/data/static
ARG IMPRESS_STATIC_ROOT=/data/static
# Gunicorn
RUN mkdir -p /usr/local/etc/gunicorn
COPY docker/files/usr/local/etc/gunicorn/people.py /usr/local/etc/gunicorn/people.py
COPY docker/files/usr/local/etc/gunicorn/impress.py /usr/local/etc/gunicorn/impress.py
# Un-privileged user running the application
ARG DOCKER_USER
USER ${DOCKER_USER}
# Copy statics
COPY --from=link-collector ${PEOPLE_STATIC_ROOT} ${PEOPLE_STATIC_ROOT}
COPY --from=link-collector ${IMPRESS_STATIC_ROOT} ${IMPRESS_STATIC_ROOT}
# Copy people mails
# Copy impress mails
COPY --from=mail-builder /mail/backend/core/templates/mail /app/core/templates/mail
# The default command runs gunicorn WSGI server in people's main module
CMD ["gunicorn", "-c", "/usr/local/etc/gunicorn/people.py", "people.wsgi:application"]
# The default command runs gunicorn WSGI server in impress's main module
CMD ["gunicorn", "-c", "/usr/local/etc/gunicorn/impress.py", "impress.wsgi:application"]
+35 -49
View File
@@ -45,16 +45,14 @@ COMPOSE_RUN = $(COMPOSE) run --rm
COMPOSE_RUN_APP = $(COMPOSE_RUN) app-dev
COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT) -timeout 60s
WAIT_KC_DB = $(COMPOSE_RUN) dockerize -wait tcp://kc_postgresql:5432 -timeout 60s
# -- 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
PATH_FRONT_DESK = $(PATH_FRONT)/apps/desk
PATH_FRONT_IMPRESS = $(PATH_FRONT)/apps/impress
# ==============================================================================
# RULES
@@ -77,23 +75,24 @@ create-env-files: \
env.d/development/kc_postgresql
.PHONY: create-env-files
bootstrap: ## Prepare Docker images for the project and install frontend dependencies
bootstrap: ## Prepare Docker images for the project
bootstrap: \
data/media \
data/static \
create-env-files \
build \
run \
run-frontend-dev \
migrate \
demo \
back-i18n-compile \
mails-install \
mails-build \
install-front-desk
mails-build
.PHONY: bootstrap
# -- Docker/compose
build: ## build the app-dev container
@$(COMPOSE) build app-dev
@$(COMPOSE) build app-dev --no-cache
@$(COMPOSE) build frontend-dev --no-cache
.PHONY: build
down: ## stop and remove containers, networks, images, and volumes
@@ -105,12 +104,9 @@ logs: ## display app-dev logs (follow mode)
.PHONY: logs
run: ## start the wsgi (production) and development server
@$(COMPOSE) up --force-recreate -d nginx
@$(COMPOSE) up --force-recreate -d app-dev
@$(COMPOSE) up --force-recreate -d celery-dev
@$(COMPOSE) up --force-recreate -d keycloak
@$(COMPOSE) up --force-recreate -d y-provider
@echo "Wait for postgresql to be up..."
@$(WAIT_KC_DB)
@$(WAIT_DB)
.PHONY: run
@@ -166,30 +162,23 @@ test-back-parallel: ## run all back-end tests in parallel
bin/pytest -n auto $${args:-${1}}
.PHONY: test-back-parallel
makemigrations: ## run django makemigrations for the people project.
makemigrations: ## run django makemigrations for the impress project.
@echo "$(BOLD)Running makemigrations$(RESET)"
@$(COMPOSE) up -d postgresql
@$(WAIT_DB)
@$(MANAGE) makemigrations $(ARGS)
@$(MANAGE) makemigrations
.PHONY: makemigrations
migrate: ## run django migrations for the people project.
migrate: ## run django migrations for the impress project.
@echo "$(BOLD)Running migrations$(RESET)"
@$(COMPOSE) up -d postgresql
@$(WAIT_DB)
@$(MANAGE) migrate $(ARGS)
@$(MANAGE) migrate
.PHONY: migrate
showmigrations: ## run django showmigrations for the people project.
@echo "$(BOLD)Running showmigrations$(RESET)"
@$(COMPOSE) up -d postgresql
@$(WAIT_DB)
@$(MANAGE) showmigrations $(ARGS)
.PHONY: showmigrations
superuser: ## Create an admin superuser with password "admin"
@echo "$(BOLD)Creating a Django superuser$(RESET)"
@$(MANAGE) createsuperuser --admin_email admin@example.com --password admin
@$(MANAGE) createsuperuser --email admin@example.com --password admin
.PHONY: superuser
back-i18n-compile: ## compile the gettext files
@@ -197,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
@@ -231,7 +220,7 @@ env.d/development/kc_postgresql:
env.d/development/crowdin:
cp -n env.d/development/crowdin.dist env.d/development/crowdin
crowdin-download: ## Download translated message from Crowdin
crowdin-download: ## Download translated message from crowdin
@$(COMPOSE_RUN_CROWDIN) download -c crowdin/config.yml
.PHONY: crowdin-download
@@ -239,7 +228,7 @@ crowdin-download-sources: ## Download sources from Crowdin
@$(COMPOSE_RUN_CROWDIN) download sources -c crowdin/config.yml
.PHONY: crowdin-download-sources
crowdin-upload: ## Upload source translations to Crowdin
crowdin-upload: ## Upload source translations to crowdin
@$(COMPOSE_RUN_CROWDIN) upload sources -c crowdin/config.yml
.PHONY: crowdin-upload
@@ -286,35 +275,21 @@ 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
.PHONY: clean
help:
@echo "$(BOLD)People Makefile"
@echo "$(BOLD)impress Makefile"
@echo "Please use 'make $(BOLD)target$(RESET)' where $(BOLD)target$(RESET) is one of:"
@grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(firstword $(MAKEFILE_LIST)) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "$(GREEN)%-30s$(RESET) %s\n", $$1, $$2}'
.PHONY: help
# Front
install-front-desk: ## Install the frontend dependencies of app Desk
cd $(PATH_FRONT_DESK) && yarn
.PHONY: install-front-desk
run-front-desk: ## Start app Desk
cd $(PATH_FRONT_DESK) && yarn dev
.PHONY: run-front-desk
run-frontend-dev: ## Install and run the frontend dev
@$(COMPOSE) up --force-recreate -d frontend-dev
.PHONY: run-frontend-dev
frontend-i18n-extract: ## Extract the frontend translation inside a json to be used for crowdin
cd $(PATH_FRONT) && yarn i18n:extract
@@ -331,10 +306,21 @@ frontend-i18n-compile: ## Format the crowin json files used deploy to the apps
.PHONY: frontend-i18n-compile
# -- K8S
start-kind: ## Create the kubernetes cluster
build-k8s-cluster: ## build the kubernetes cluster using kind
./bin/start-kind.sh
.PHONY: start-kind
.PHONY: build-k8s-cluster
tilt-up: ## start tilt - k8s local development
start-tilt: ## start the kubernetes cluster using kind
tilt up -f ./bin/Tiltfile
.PHONY: tilt-up
.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
+16 -28
View File
@@ -1,11 +1,9 @@
# People
# Impress
People is an application to handle users and teams.
Impress prints your markdown to pdf from predefined templates with user and role based access rights.
As of today, this project is **not yet ready for production**. Expect breaking changes.
People is built on top of [Django Rest
Framework](https://www.django-rest-framework.org/).
Impress is built on top of [Django Rest
Framework](https://www.django-rest-framework.org/) and [Next.js](https://nextjs.org/).
## Getting started
@@ -30,20 +28,28 @@ $ docker compose -v
The easiest way to start working on the project is to use GNU Make:
```bash
$ make bootstrap
$ make bootstrap FLUSH_ARGS='--no-input'
```
Then you can access to the project in development mode by going to http://localhost:3000.
You will be prompted to log in, the default credentials are:
```bash
username: impress
password: impress
```
---
This command builds the `app` container, installs dependencies, performs
database migrations and compile translations. It's a good idea to use this
command each time you are pulling code from the project repository to avoid
dependency-related or migration-related issues.
dependency-releated or migration-releated issues.
Your Docker services should now be up and running 🎉
Note that if you need to run them afterward, you can use the eponym Make rule:
Note that if you need to run them afterwards, you can use the eponym Make rule:
```bash
$ make run
$ make run-frontend-dev
```
### Adding content
@@ -69,24 +75,6 @@ You first need to create a superuser account:
$ make superuser
```
You can then login with credentials `admin@example` / `admin`.
### Run frontend
Run the front with:
```bash
$ make run-front-desk
```
Then access at
[http://localhost:3000](http://localhost:3000)
user: people
password: people
## Contributing
This project is intended to be community-driven, so please, do not hesitate to
+25 -15
View File
@@ -1,9 +1,9 @@
load('ext://uibutton', 'cmd_button', 'bool_input', 'location')
load('ext://namespace', 'namespace_create', 'namespace_inject')
namespace_create('desk')
namespace_create('impress')
docker_build(
'localhost:5001/people-backend:latest',
'localhost:5001/impress-backend:latest',
context='..',
dockerfile='../Dockerfile',
only=['./src/backend', './src/mail', './docker'],
@@ -18,28 +18,38 @@ docker_build(
)
docker_build(
'localhost:5001/people-frontend:latest',
'localhost:5001/impress-y-provider:latest',
context='..',
dockerfile='../Dockerfile',
build_args={'ENV': 'dev'},
only=['./src/frontend', './src/mail', './docker'],
target = 'frontend-builder-dev',
dockerfile='../src/frontend/Dockerfile',
only=['./src/frontend/', './docker/', './.dockerignore'],
target = 'y-provider',
live_update=[
sync('../src/frontend', '/builder'),
sync('../src/frontend/servers/y-provider/src', '/home/frontend/servers/y-provider/src'),
]
)
k8s_yaml(local('cd ../src/helm && helmfile -n desk -e dev template .'))
docker_build(
'localhost:5001/impress-frontend:latest',
context='..',
dockerfile='../src/frontend/Dockerfile',
only=['./src/frontend', './docker', './.dockerignore'],
target = 'impress',
live_update=[
sync('../src/frontend', '/home/frontend'),
]
)
k8s_yaml(local('cd ../src/helm && helmfile -n impress -e dev template .'))
migration = '''
set -eu
# get k8s pod name from tilt resource name
POD_NAME="$(tilt get kubernetesdiscovery desk-backend -ojsonpath='{.status.pods[0].name}')"
kubectl -n desk exec "$POD_NAME" -- python manage.py makemigrations
POD_NAME="$(tilt get kubernetesdiscovery impress-backend -ojsonpath='{.status.pods[0].name}')"
kubectl -n impress exec "$POD_NAME" -- python manage.py makemigrations
'''
cmd_button('Make migration',
argv=['sh', '-c', migration],
resource='desk-backend',
resource='impress-backend',
icon_name='developer_board',
text='Run makemigration',
)
@@ -47,12 +57,12 @@ cmd_button('Make migration',
pod_migrate = '''
set -eu
# get k8s pod name from tilt resource name
POD_NAME="$(tilt get kubernetesdiscovery desk-backend -ojsonpath='{.status.pods[0].name}')"
kubectl -n desk exec "$POD_NAME" -- python manage.py migrate --no-input
POD_NAME="$(tilt get kubernetesdiscovery impress-backend -ojsonpath='{.status.pods[0].name}')"
kubectl -n impress exec "$POD_NAME" -- python manage.py migrate --no-input
'''
cmd_button('Migrate db',
argv=['sh', '-c', pod_migrate],
resource='desk-backend',
resource='impress-backend',
icon_name='developer_board',
text='Run database migration',
)
+1 -1
View File
@@ -7,7 +7,7 @@ UNSET_USER=0
TERRAFORM_DIRECTORY="./env.d/terraform"
COMPOSE_FILE="${REPO_DIR}/docker-compose.yml"
COMPOSE_PROJECT="people"
COMPOSE_PROJECT="impress"
# _set_user: set (or unset) default user id used to run docker commands
View File
Regular → Executable
+8 -7
View File
@@ -3,25 +3,25 @@ set -o errexit
CURRENT_DIR=$(pwd)
# 0. Create ca
echo "0. Create ca"
# 0. Create ca
mkcert -install
cd /tmp
mkcert "127.0.0.1.nip.io" "*.127.0.0.1.nip.io"
cd $CURRENT_DIR
# 1. Create registry container unless it already exists
echo "1. Create registry container unless it already exists"
# 1. Create registry container unless it already exists
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
# 2. Create kind cluster with containerd registry config dir enabled
echo "2. Create kind cluster with containerd registry config dir enabled"
# 2. Create kind cluster with containerd registry config dir enabled
# TODO: kind will eventually enable this by default and this patch will
# be unnecessary.
#
@@ -58,8 +58,8 @@ nodes:
image: kindest/node:v1.27.3
EOF
# 3. Add the registry config to the nodes
echo "3. Add the registry config to the nodes"
# 3. Add the registry config to the nodes
#
# This is necessary because localhost resolves to loopback addresses that are
# network-namespace local.
@@ -75,15 +75,15 @@ for node in $(kind get nodes); do
EOF
done
# 4. Connect the registry to the cluster network if not already connected
echo "4. Connect the registry to the cluster network if not already connected"
# 4. Connect the registry to the cluster network if not already connected
# This allows kind to bootstrap the network but ensures they're on the same network
if [ "$(docker inspect -f='{{json .NetworkSettings.Networks.kind}}' "${reg_name}")" = 'null' ]; then
docker network connect "kind" "${reg_name}"
fi
# 5. Document the local registry
echo "5. Document the local registry"
# 5. Document the local registry
# https://github.com/kubernetes/enhancements/tree/master/keps/sig-cluster-lifecycle/generic/1755-communicating-a-local-registry
cat <<EOF | kubectl apply -f -
apiVersion: v1
@@ -97,6 +97,7 @@ data:
help: "https://kind.sigs.k8s.io/docs/user/local-registry/"
EOF
echo "6. Install ingress-nginx"
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/kind/deploy.yaml
kubectl -n ingress-nginx create secret tls mkcert --key /tmp/127.0.0.1.nip.io+1-key.pem --cert /tmp/127.0.0.1.nip.io+1.pem
kubectl -n ingress-nginx patch deployments.apps ingress-nginx-controller --type 'json' -p '[{"op": "add", "path": "/spec/template/spec/containers/0/args/-", "value":"--default-ssl-certificate=ingress-nginx/mkcert"}]'
View File
+1 -1
View File
@@ -7,6 +7,6 @@ _dc_run \
app-dev \
python manage.py spectacular \
--api-version 'v1.0' \
--urlconf 'people.api_urls' \
--urlconf 'impress.api_urls' \
--format openapi-json \
--file /app/core/tests/swagger/swagger.json
+3
View File
@@ -0,0 +1,3 @@
#!/bin/bash
find . -name "*.enc.*" -exec sops updatekeys -y {} \;
+13 -14
View File
@@ -14,17 +14,16 @@ preserve_hierarchy: true
#
# Files configuration
#
files:
[
{
source: "/backend/locale/django.pot",
dest: "/backend.pot",
translation: "/backend/locale/%locale_with_underscore%/LC_MESSAGES/django.po",
},
{
source: "/frontend/packages/i18n/locales/desk/translations-crowdin.json",
dest: "/desk.json",
translation: "/frontend/packages/i18n/locales/desk/%two_letters_code%/translations.json",
skip_untranslated_strings: true,
},
]
files: [
{
source : "/backend/locale/django.pot",
dest: "/backend-impress.pot",
translation : "/backend/locale/%locale_with_underscore%/LC_MESSAGES/django.po"
},
{
source: "/frontend/packages/i18n/locales/impress/translations-crowdin.json",
dest: "/frontend-impress.json",
translation: "/frontend/packages/i18n/locales/impress/%two_letters_code%/translations.json",
skip_untranslated_strings: true,
},
]
+72 -30
View File
@@ -1,5 +1,3 @@
version: '3.8'
services:
postgresql:
image: postgres:16
@@ -16,6 +14,31 @@ services:
ports:
- "1081:1080"
minio:
user: ${DOCKER_USER:-1000}
image: minio/minio
environment:
- MINIO_ROOT_USER=impress
- MINIO_ROOT_PASSWORD=password
ports:
- '9000:9000'
- '9001:9001'
entrypoint: ""
command: minio server --console-address :9001 /data
volumes:
- ./data/media:/data
createbuckets:
image: minio/mc
depends_on:
- minio
entrypoint: >
sh -c "
/usr/bin/mc alias set impress http://minio:9000 impress password && \
/usr/bin/mc mb impress/impress-media-storage && \
/usr/bin/mc version enable impress/impress-media-storage && \
exit 0;"
app-dev:
build:
context: .
@@ -23,7 +46,7 @@ services:
args:
DOCKER_USER: ${DOCKER_USER:-1000}
user: ${DOCKER_USER:-1000}
image: people:backend-development
image: impress:backend-development
environment:
- PYLINTHOME=/app/.pylint.d
- DJANGO_CONFIGURATION=Development
@@ -34,17 +57,18 @@ services:
- "8071:8000"
volumes:
- ./src/backend:/app
- ./data/media:/data/media
- ./data/static:/data/static
depends_on:
- postgresql
- mailcatcher
- redis
- createbuckets
- nginx
celery-dev:
user: ${DOCKER_USER:-1000}
image: people:backend-development
command: ["celery", "-A", "people.celery_app", "worker", "-l", "DEBUG"]
image: impress:backend-development
command: ["celery", "-A", "impress.celery_app", "worker", "-l", "DEBUG"]
environment:
- DJANGO_CONFIGURATION=Development
env_file:
@@ -52,7 +76,6 @@ services:
- env.d/development/postgresql
volumes:
- ./src/backend:/app
- ./data/media:/data/media
- ./data/static:/data/static
depends_on:
- app-dev
@@ -64,22 +87,21 @@ services:
args:
DOCKER_USER: ${DOCKER_USER:-1000}
user: ${DOCKER_USER:-1000}
image: people:backend-production
image: impress:backend-production
environment:
- DJANGO_CONFIGURATION=Demo
env_file:
- env.d/development/common
- env.d/development/postgresql
volumes:
- ./data/media:/data/media
depends_on:
- postgresql
- redis
- minio
celery:
user: ${DOCKER_USER:-1000}
image: people:backend-production
command: ["celery", "-A", "people.celery_app", "worker", "-l", "INFO"]
image: impress:backend-production
command: ["celery", "-A", "impress.celery_app", "worker", "-l", "INFO"]
environment:
- DJANGO_CONFIGURATION=Demo
env_file:
@@ -95,9 +117,16 @@ services:
volumes:
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
depends_on:
- app
- 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
@@ -118,28 +147,41 @@ services:
volumes:
- ".:/app"
terraform-state:
image: hashicorp/terraform:1.6
environment:
- TF_WORKSPACE=${PROJECT:-} # avoid env conflict in local state
y-provider:
user: ${DOCKER_USER:-1000}
working_dir: /app
build:
context: .
dockerfile: ./src/frontend/Dockerfile
target: y-provider
restart: unless-stopped
ports:
- "4444:4444"
volumes:
- ./src/terraform/create_state_bucket:/app
- ./src/frontend/servers/y-provider:/home/frontend/servers/y-provider
- /home/frontend/servers/y-provider/node_modules/
- /home/frontend/servers/y-provider/dist/
terraform:
image: hashicorp/terraform:1.6
user: ${DOCKER_USER:-1000}
working_dir: /app
frontend-dev:
user: "${DOCKER_USER:-1000}"
build:
context: .
dockerfile: ./src/frontend/Dockerfile
target: impress-dev
ports:
- "3000:3000"
volumes:
- ./src/terraform:/app
- ./src/frontend/apps/impress:/home/frontend/apps/impress
- /home/frontend/node_modules/
depends_on:
- y-provider
- celery-dev
kc_postgresql:
image: postgres:14.3
ports:
- "5433:5432"
env_file:
- env.d/development/kc_postgresql
image: postgres:14.3
ports:
- "5433:5432"
env_file:
- env.d/development/kc_postgresql
keycloak:
image: quay.io/keycloak/keycloak:20.0.1
@@ -161,7 +203,7 @@ services:
KC_DB_URL_HOST: kc_postgresql
KC_DB_URL_DATABASE: keycloak
KC_DB_PASSWORD: pass
KC_DB_USERNAME: people
KC_DB_USERNAME: impress
KC_DB_SCHEMA: public
PROXY_ADDRESS_FORWARDING: 'true'
ports:
+29 -14
View File
@@ -1,6 +1,6 @@
{
"id": "ccf4fd40-4286-474d-854a-4714282a8bec",
"realm": "people",
"realm": "impress",
"notBefore": 0,
"defaultSignatureAlgorithm": "RS256",
"revokeRefreshToken": false,
@@ -45,15 +45,15 @@
"failureFactor": 30,
"users": [
{
"username": "people",
"email": "people@people.world",
"username": "impress",
"email": "impress@impress.world",
"firstName": "John",
"lastName": "Doe",
"enabled": true,
"credentials": [
{
"type": "password",
"value": "people"
"value": "impress"
}
],
"realmRoles": ["user"]
@@ -114,7 +114,7 @@
},
{
"id": "1bfe401a-08fc-4d94-80e0-86c4f5195f99",
"name": "default-roles-people",
"name": "default-roles-impress",
"description": "${role_default-roles}",
"composite": true,
"composites": {
@@ -359,7 +359,7 @@
"attributes": {}
}
],
"people": [],
"impress": [],
"account": [
{
"id": "63b1a4e1-a594-4571-99c3-7c5c3efd61ce",
@@ -449,7 +449,7 @@
"groups": [],
"defaultRole": {
"id": "1bfe401a-08fc-4d94-80e0-86c4f5195f99",
"name": "default-roles-people",
"name": "default-roles-impress",
"description": "${role_default-roles}",
"composite": true,
"clientRole": false,
@@ -504,12 +504,12 @@
"clientId": "account",
"name": "${client_account}",
"rootUrl": "${authBaseUrl}",
"baseUrl": "/realms/people/account/",
"baseUrl": "/realms/impress/account/",
"surrogateAuthRequired": false,
"enabled": true,
"alwaysDisplayInConsole": false,
"clientAuthenticatorType": "client-secret",
"redirectUris": ["/realms/people/account/*"],
"redirectUris": ["/realms/impress/account/*"],
"webOrigins": [],
"notBefore": 0,
"bearerOnly": false,
@@ -546,12 +546,12 @@
"clientId": "account-console",
"name": "${client_account-console}",
"rootUrl": "${authBaseUrl}",
"baseUrl": "/realms/people/account/",
"baseUrl": "/realms/impress/account/",
"surrogateAuthRequired": false,
"enabled": true,
"alwaysDisplayInConsole": false,
"clientAuthenticatorType": "client-secret",
"redirectUris": ["/realms/people/account/*"],
"redirectUris": ["/realms/impress/account/*"],
"webOrigins": [],
"notBefore": 0,
"bearerOnly": false,
@@ -676,7 +676,7 @@
},
{
"id": "869481d0-5774-4e64-bc30-fedc7c58958f",
"clientId": "people",
"clientId": "impress",
"name": "",
"description": "",
"rootUrl": "",
@@ -791,12 +791,12 @@
"clientId": "security-admin-console",
"name": "${client_security-admin-console}",
"rootUrl": "${authAdminUrl}",
"baseUrl": "/admin/people/console/",
"baseUrl": "/admin/impress/console/",
"surrogateAuthRequired": false,
"enabled": true,
"alwaysDisplayInConsole": false,
"clientAuthenticatorType": "client-secret",
"redirectUris": ["/admin/people/console/*"],
"redirectUris": ["/admin/impress/console/*"],
"webOrigins": ["+"],
"notBefore": 0,
"bearerOnly": false,
@@ -1339,6 +1339,21 @@
"jsonType.label": "String"
}
},
{
"id": "qb109597-e31e-46d7-7844-62e5fcf32ac8",
"name": "email sub",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-property-mapper",
"consentRequired": false,
"config": {
"userinfo.token.claim": "true",
"user.attribute": "email",
"id.token.claim": "true",
"access.token.claim": "true",
"claim.name": "sub",
"jsonType.label": "String"
}
},
{
"id": "61c135e5-2447-494b-bc70-9612f383be27",
"name": "email verified",
@@ -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;
+2 -2
View File
@@ -13,7 +13,7 @@
#
# To pass environment variables, you can either use the -e option of the docker run command:
#
# docker run --rm -e USER_NAME=foo -e HOME='/home/foo' people:latest python manage.py migrate
# docker run --rm -e USER_NAME=foo -e HOME='/home/foo' impress:latest python manage.py migrate
#
# or define new variables in an environment file to use with docker or docker compose:
#
@@ -21,7 +21,7 @@
# USER_NAME=foo
# HOME=/home/foo
#
# docker run --rm --env-file env.d/production people:latest python manage.py migrate
# docker run --rm --env-file env.d/production impress:latest python manage.py migrate
#
echo "🐳(entrypoint) creating user running in the container..."
@@ -1,6 +1,6 @@
# Gunicorn-django settings
bind = ["0.0.0.0:8000"]
name = "people"
name = "impress"
python_path = "/app"
# Run
-235
View File
@@ -1,235 +0,0 @@
# What is People?
Space Odyssey is a dynamic organization. They use the People application to enhance teamwork and
streamline communication among their co-workers. Let's explore how this application helps them
interact efficiently.
Let's see how we could interact with Django's shell to recreate David's environment in the app.
## Base contacts from the organization records
David Bowman is an exemplary employee at Space Odyssey Corporation. His email is
`david.bowman@spaceodyssey.com` and he is registered in the organization's records via a base
contact as follows:
```python
david_base_contact = Contact.objects.create(
full_name="David Bowman",
short_name="David",
data={
"emails": [
{"type": "Work", "value": "david.bowman@spaceodyssey.com"},
],
"phones": [
{"type": "Work", "value": "(123) 456-7890"},
],
"addresses": [
{
"type": "Work",
"street": "123 Main St",
"city": "Cityville",
"state": "CA",
"zip": "12345",
"country": "USA",
}
],
"links": [
{"type": "Website", "value": "http://www.spaceodyssey.com"},
{"type": "Twitter", "value": "https://www.twitter.com/dbowman"},
],
"organizations": [
{
"name": "Space Odyssey Corporation",
"department": "IT",
"jobTitle": "AI Engineer",
},
],
}
)
```
When David logs-in to the People application for the first time using the corporation's OIDC
Single Sign-On service. A user is created for him on the fly by the system, together with an
identity record representing the OIDC session:
```python
david_user = User.objects.create(
language="en-us",
timezone="America/Los_Angeles",
)
david_identity = Identity.objects.create(
"user": david_user,
"sub": "2a1b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"email" : "david.bowman@spaceodyssey.com",
"is_main": True,
)
```
## Profile contact
The system identifies Dave through the email associated with his OIDC session and prompts him to
confirm the details of the base contact stored in the database.
When David confirms, giving an alternative short name that he prefers, a contact override is
created on top of the organization's base contact. This new contact is marked as David's profile
on the user:
```python
david_contact = Contact.objects.create(
base=david_base_contact,
owner=david_user,
full_name="David Bowman",
short_name="Dave",
data={}
)
david_user.profile_contact = david_contact
david_user.save()
```
If Dave had not had any existing contact in the organization's records, the profile contact would
have been created independently, without any connection to a base contact:
```python
david_contact = Contact.objects.create(
base=None,
owner=david_user,
full_name="David Bowman",
short_name="Dave",
data={}
)
```
Now, Dave feels like sharing his mobile phone number with his colleagues. He can do this
by editing his contact in the application:
```python
contact.data["phones"] = [
{"type": "Mobile", "value": "(123) 456-7890"},
]
contact.save()
```
## Contact override
During a Space conference he attended, Dave met Dr Ryan Stone, a medical engineer who gave him
her professional email address. Ryan is already present in the system but her email is missing.
Dave can add it to his private version of the contact:
```python
ryan_base_contact = Contact.objects.create(
full_name="Ryan Stone",
data={}
)
ryan_contact = Contact.objects.create(
base=ryan_base_contact,
owner=david_user,
full_name="Ryan Stone",
short_name="Dr Ryan",
data={
"emails": [
{"type": "Work", "value": "ryan.stone@hubblestation.com"},
],
}
)
```
## Team Collaboration
Dave wants to form a team with Ryan and other colleagues to work together better on using the organization's digital tools for their projects.
Dave would like to create a team with Ryan and some other colleagues, to enhance collaboration
throughout their projects:
```python
projectx = Team.objects.create(name="Project X")
```
A team can for example be used to create an email alias or to define role based access rights
(RBAC) in a specific application or all applications of the organization's digital Suite.
Having created he team, Dave is automatically assigned the "owner" role. He invites Ryan,
granting an "administrator" role to her so she can invite her own colleagues. Both of them can
then proceed to invite other colleagues as simple members. If Ryan wants, she can upgrade a
colleague to "administrator" but only David can upgrade someone to the "owner" status:
```python
TeamAccess.objects.create(user=david_user, team=projectx, role="owner")
TeamAccess.objects.create(user=ryan_user, team=projectx, role="administrator")
TeamAccess.objects.create(user=julie_user, team=projectx, role="member")
```
| Role | Member | Administrator | Owner |
|-----------------------------------|--------|---------------|-------|
| Can view team | ✔ | ✔ | ✔ |
| Can set roles except for owners | | ✔ | ✔ |
| Can set roles for owners | | | ✔ |
| Can delete team | | | ✔ |
Importantly, the system ensures that there is always at least one owner left to maintain control
of the team.
# Models overview
The following graph represents the application's models and their relationships:
```mermaid
erDiagram
%% Models
Contact {
UUID id PK
Contact base
User owner
string full_name
string short_name
json data
DateTime created_at
DateTime updated_at
}
User {
UUID id PK
Contact profile_contact
string language
string timezone
boolean is_device
boolean is_staff
boolean is_active
DateTime created_at
DateTime updated_at
}
Identity {
UUID id PK
User user
string sub
Email email
boolean is_main
DateTime created_at
DateTime updated_at
}
Team {
UUID id PK
string name
DateTime created_at
DateTime updated_at
}
TeamAccess {
UUID id PK
Team team
User user
string role
DateTime created_at
DateTime updated_at
}
%% Relations
User ||--o{ Contact : "owns"
Contact ||--o{ User : "profile for"
User ||--o{ TeamAccess : ""
Team ||--o{ TeamAccess : ""
Identity ||--o{ User : "connects"
Contact }o--|| Contact : "overrides"
```
+72
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
```
-25
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 People front application itself.
This client is made with [openapi-typescript-codegen](https://github.com/ferdikoomen/openapi-typescript-codegen)
and People'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>
```
+14 -8
View File
@@ -1,28 +1,34 @@
# Django
DJANGO_ALLOWED_HOSTS=*
DJANGO_SECRET_KEY=ThisIsAnExampleKeyForDevPurposeOnly
DJANGO_SETTINGS_MODULE=people.settings
DJANGO_SETTINGS_MODULE=impress.settings
DJANGO_SUPERUSER_PASSWORD=admin
# Python
PYTHONPATH=/app
# People settings
# impress settings
# Mail
DJANGO_EMAIL_HOST="mailcatcher"
DJANGO_EMAIL_PORT=1025
# Backend url
PEOPLE_BASE_URL="http://localhost:8072"
IMPRESS_BASE_URL="http://localhost:8072"
# Media
STORAGES_STATICFILES_BACKEND=django.contrib.staticfiles.storage.StaticFilesStorage
AWS_S3_ENDPOINT_URL=http://minio:9000
AWS_S3_ACCESS_KEY_ID=impress
AWS_S3_SECRET_ACCESS_KEY=password
# OIDC
OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/people/protocol/openid-connect/certs
OIDC_OP_AUTHORIZATION_ENDPOINT=http://localhost:8083/realms/people/protocol/openid-connect/auth
OIDC_OP_TOKEN_ENDPOINT=http://nginx:8083/realms/people/protocol/openid-connect/token
OIDC_OP_USER_ENDPOINT=http://nginx:8083/realms/people/protocol/openid-connect/userinfo
OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/impress/protocol/openid-connect/certs
OIDC_OP_AUTHORIZATION_ENDPOINT=http://localhost:8083/realms/impress/protocol/openid-connect/auth
OIDC_OP_TOKEN_ENDPOINT=http://nginx:8083/realms/impress/protocol/openid-connect/token
OIDC_OP_USER_ENDPOINT=http://nginx:8083/realms/impress/protocol/openid-connect/userinfo
OIDC_RP_CLIENT_ID=people
OIDC_RP_CLIENT_ID=impress
OIDC_RP_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly
OIDC_RP_SIGN_ALGO=RS256
OIDC_RP_SCOPES="openid email"
+3 -3
View File
@@ -1,11 +1,11 @@
# Postgresql db container configuration
POSTGRES_DB=keycloak
POSTGRES_USER=people
POSTGRES_USER=impress
POSTGRES_PASSWORD=pass
# App database configuration
DB_HOST=kc_postgresql
DB_NAME=keycloak
DB_USER=people
DB_USER=impress
DB_PASSWORD=pass
DB_PORT=5433
DB_PORT=5433
+3 -3
View File
@@ -1,11 +1,11 @@
# Postgresql db container configuration
POSTGRES_DB=people
POSTGRES_DB=impress
POSTGRES_USER=dinum
POSTGRES_PASSWORD=pass
# App database configuration
DB_HOST=postgresql
DB_NAME=people
DB_NAME=impress
DB_USER=dinum
DB_PASSWORD=pass
DB_PORT=5432
DB_PORT=5432
+1 -1
View File
@@ -24,7 +24,7 @@ class GitmojiTitle(LineRule):
def validate(self, title, _commit):
"""
Download the list possible gitmojis from the project's GitHub repository and check that
Download the list possible gitmojis from the project's github repository and check that
title contains one of them.
"""
gitmojis = requests.get(
+1 -1
View File
@@ -13,7 +13,7 @@
"enabled": false,
"groupName": "ignored js dependencies",
"matchManagers": ["npm"],
"matchPackageNames": ["node", "node-fetch", "i18next-parser", "eslint"]
"matchPackageNames": ["fetch-mock", "node", "node-fetch", "eslint"]
}
]
}
+1 -1
Submodule secrets updated: d7cfe7bcdc...2643697e5f
+1 -1
View File
@@ -172,7 +172,7 @@ ignore-on-opaque-inference=yes
# for classes with dynamically set attributes). This supports the use of
# qualified names.
ignored-classes=optparse.Values,thread._local,_thread._local,responses,
Team,Contact
Template,Contact
# List of module names for which member attributes should not be checked
# (useful for modules/projects where namespaces are manipulated during runtime
+1 -1
View File
@@ -1,3 +1,3 @@
include LICENSE
include README.md
recursive-include src/backend/people *.html *.png *.gif *.css *.ico *.jpg *.jpeg *.po *.mo *.eot *.svg *.ttf *.woff *.woff2
recursive-include src/backend/impress *.html *.png *.gif *.css *.ico *.jpg *.jpeg *.po *.mo *.eot *.svg *.ttf *.woff *.woff2
+40 -83
View File
@@ -1,6 +1,5 @@
"""Admin classes and registrations for People's core app."""
"""Admin classes and registrations for core app."""
from django import forms
from django.contrib import admin
from django.contrib.auth import admin as auth_admin
from django.utils.translation import gettext_lazy as _
@@ -8,58 +7,11 @@ from django.utils.translation import gettext_lazy as _
from . import models
class IdentityFormSet(forms.BaseInlineFormSet):
"""
Make the "is_main" field readonly when it is True so that declaring another identity
works in the admin.
"""
class TemplateAccessInline(admin.TabularInline):
"""Inline admin class for template accesses."""
def add_fields(self, form, index):
"""Disable the "is_main" field when it is set to True"""
super().add_fields(form, index)
is_main_value = form.instance.is_main if form.instance else False
form.fields["is_main"].disabled = is_main_value
class IdentityInline(admin.TabularInline):
"""Inline admin class for user identities."""
fields = (
"sub",
"email",
"is_main",
"created_at",
"updated_at",
)
formset = IdentityFormSet
model = models.Identity
model = models.TemplateAccess
extra = 0
readonly_fields = ("email", "created_at", "sub", "updated_at")
def has_add_permission(self, request, obj):
"""
Identities are automatically created on successful OIDC logins.
Disable creating identities via the admin.
"""
return False
class TeamAccessInline(admin.TabularInline):
"""Inline admin class for team accesses."""
extra = 0
autocomplete_fields = ["user", "team"]
model = models.TeamAccess
readonly_fields = ("created_at", "updated_at")
class TeamWebhookInline(admin.TabularInline):
"""Inline admin class for team webhooks."""
extra = 0
autocomplete_fields = ["team"]
model = models.TeamWebhook
readonly_fields = ("created_at", "updated_at")
@admin.register(models.User)
@@ -72,11 +24,12 @@ class UserAdmin(auth_admin.UserAdmin):
{
"fields": (
"id",
"admin_email",
"password",
)
},
),
(_("Personal info"), {"fields": ("admin_email", "language", "timezone")}),
(_("Personal info"), {"fields": ("sub", "email", "language", "timezone")}),
(
_("Permissions"),
{
@@ -97,38 +50,56 @@ class UserAdmin(auth_admin.UserAdmin):
None,
{
"classes": ("wide",),
"fields": ("admin_email", "password1", "password2"),
"fields": ("email", "password1", "password2"),
},
),
)
inlines = (IdentityInline, TeamAccessInline)
inlines = (TemplateAccessInline,)
list_display = (
"id",
"sub",
"admin_email",
"created_at",
"updated_at",
"email",
"is_active",
"is_device",
"is_staff",
"is_superuser",
"is_device",
"created_at",
"updated_at",
)
list_filter = ("is_staff", "is_superuser", "is_device", "is_active")
ordering = ("is_active", "-is_superuser", "-is_staff", "-is_device", "-updated_at")
readonly_fields = ("id", "created_at", "updated_at")
search_fields = ("id", "admin_email", "identities__sub", "identities__email")
readonly_fields = ("id", "sub", "email", "created_at", "updated_at")
search_fields = ("id", "sub", "admin_email", "email")
@admin.register(models.Team)
class TeamAdmin(admin.ModelAdmin):
"""Team admin interface declaration."""
@admin.register(models.Template)
class TemplateAdmin(admin.ModelAdmin):
"""Template admin interface declaration."""
inlines = (TeamAccessInline, TeamWebhookInline)
inlines = (TemplateAccessInline,)
class DocumentAccessInline(admin.TabularInline):
"""Inline admin class for template accesses."""
model = models.DocumentAccess
extra = 0
@admin.register(models.Document)
class DocumentAdmin(admin.ModelAdmin):
"""Document admin interface declaration."""
inlines = (DocumentAccessInline,)
list_display = (
"name",
"slug",
"id",
"title",
"link_reach",
"link_role",
"created_at",
"updated_at",
)
search_fields = ("name",)
@admin.register(models.Invitation)
@@ -137,7 +108,7 @@ class InvitationAdmin(admin.ModelAdmin):
fields = (
"email",
"team",
"document",
"role",
"created_at",
"issuer",
@@ -149,25 +120,11 @@ class InvitationAdmin(admin.ModelAdmin):
)
list_display = (
"email",
"team",
"document",
"created_at",
"is_expired",
)
def get_readonly_fields(self, request, obj=None):
if obj:
# all fields read only = disable update
return self.fields
return self.readonly_fields
def change_view(self, request, object_id, form_url="", extra_context=None):
"""Custom edit form. Remove 'save' buttons."""
extra_context = extra_context or {}
extra_context["show_save_and_continue"] = False
extra_context["show_save"] = False
extra_context["show_save_and_add_another"] = False
return super().change_view(request, object_id, extra_context=extra_context)
def save_model(self, request, obj, form, change):
obj.issuer = request.user
obj.save()
+4 -6
View File
@@ -1,4 +1,4 @@
"""People core API endpoints"""
"""Impress core API endpoints"""
from django.conf import settings
from django.core.exceptions import ValidationError
@@ -17,14 +17,12 @@ 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
else:
detail = ""
exc = drf_exceptions.ValidationError(detail=detail)
+25
View File
@@ -0,0 +1,25 @@
"""A JSONField for DRF to handle serialization/deserialization."""
import json
from rest_framework import serializers
class JSONField(serializers.Field):
"""
A custom field for handling JSON data.
"""
def to_representation(self, value):
"""
Convert the JSON string to a Python dictionary for serialization.
"""
return value
def to_internal_value(self, data):
"""
Convert the Python dictionary to a JSON string for deserialization.
"""
if data is None:
return None
return json.dumps(data)
+25 -4
View File
@@ -1,9 +1,13 @@
"""Permission handlers for the People core app."""
"""Permission handlers for the impress core app."""
from django.core import exceptions
from rest_framework import permissions
ACTION_FOR_METHOD_TO_PERMISSION = {
"versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"}
}
class IsAuthenticated(permissions.BasePermission):
"""
@@ -12,7 +16,16 @@ class IsAuthenticated(permissions.BasePermission):
"""
def has_permission(self, request, view):
return bool(request.auth) if request.auth else request.user.is_authenticated
return bool(request.auth) or request.user.is_authenticated
class IsAuthenticatedOrSafe(IsAuthenticated):
"""Allows access to authenticated users (or anonymous users but only on safe methods)."""
def has_permission(self, request, view):
if request.method in permissions.SAFE_METHODS:
return True
return super().has_permission(request, view)
class IsSelf(IsAuthenticated):
@@ -46,10 +59,18 @@ class IsOwnedOrPublic(IsAuthenticated):
return False
class AccessPermission(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)
return abilities.get(request.method.lower(), False)
action = view.action
try:
action = ACTION_FOR_METHOD_TO_PERMISSION[view.action][request.method]
except KeyError:
pass
return abilities.get(action, False)
+245 -131
View File
@@ -1,84 +1,30 @@
"""Client serializers for the People core app."""
"""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 _
from rest_framework import exceptions, serializers
from timezone_field.rest_framework import TimeZoneSerializerField
from core import models
class ContactSerializer(serializers.ModelSerializer):
"""Serialize contacts."""
class Meta:
model = models.Contact
fields = [
"id",
"base",
"data",
"full_name",
"owner",
"short_name",
]
read_only_fields = ["id", "owner"]
def update(self, instance, validated_data):
"""Make "base" field readonly but only for update/patch."""
validated_data.pop("base", None)
return super().update(instance, validated_data)
class DynamicFieldsModelSerializer(serializers.ModelSerializer):
"""
A ModelSerializer that takes an additional `fields` argument that
controls which fields should be displayed.
"""
def __init__(self, *args, **kwargs):
# Don't pass the 'fields' arg up to the superclass
fields = kwargs.pop("fields", None)
# Instantiate the superclass normally
super().__init__(*args, **kwargs)
if fields is not None:
# Drop any fields that are not specified in the `fields` argument.
allowed = set(fields)
existing = set(self.fields)
for field_name in existing - allowed:
self.fields.pop(field_name)
class UserSerializer(DynamicFieldsModelSerializer):
class UserSerializer(serializers.ModelSerializer):
"""Serialize users."""
timezone = TimeZoneSerializerField(use_pytz=False, required=True)
email = serializers.ReadOnlyField()
name = serializers.ReadOnlyField()
class Meta:
model = models.User
fields = [
"id",
"email",
"language",
"name",
"timezone",
"is_device",
"is_staff",
]
read_only_fields = ["id", "name", "email", "is_device", "is_staff"]
fields = ["id", "email"]
read_only_fields = ["id", "email"]
class TeamAccessSerializer(serializers.ModelSerializer):
"""Serialize team accesses."""
class BaseAccessSerializer(serializers.ModelSerializer):
"""Serialize template accesses."""
abilities = serializers.SerializerMethodField(read_only=True)
class Meta:
model = models.TeamAccess
fields = ["id", "user", "role", "abilities"]
read_only_fields = ["id", "abilities"]
def update(self, instance, validated_data):
"""Make "user" field is readonly but only on update."""
validated_data.pop("user", None)
@@ -107,133 +53,301 @@ class TeamAccessSerializer(serializers.ModelSerializer):
message = (
f"You are only allowed to set role to {', '.join(can_set_role_to)}"
if can_set_role_to
else "You are not allowed to set this role for this team."
else "You are not allowed to set this role for this template."
)
raise exceptions.PermissionDenied(message)
# Create
else:
try:
team_id = self.context["team_id"]
resource_id = self.context["resource_id"]
except KeyError as exc:
raise exceptions.ValidationError(
"You must set a team ID in kwargs to create a new team access."
"You must set a resource ID in kwargs to create a new access."
) from exc
if not models.TeamAccess.objects.filter(
team=team_id,
user=user,
if not self.Meta.model.objects.filter( # pylint: disable=no-member
Q(user=user) | Q(team__in=user.teams),
role__in=[models.RoleChoices.OWNER, models.RoleChoices.ADMIN],
).exists():
raise exceptions.PermissionDenied(
"You are not allowed to manage accesses for this team."
"You are not allowed to manage accesses for this resource."
)
if (
role == models.RoleChoices.OWNER
and not models.TeamAccess.objects.filter(
team=team_id,
user=user,
and not self.Meta.model.objects.filter( # pylint: disable=no-member
Q(user=user) | Q(team__in=user.teams),
role=models.RoleChoices.OWNER,
**{self.Meta.resource_field_name: resource_id}, # pylint: disable=no-member
).exists()
):
raise exceptions.PermissionDenied(
"Only owners of a team can assign other users as owners."
"Only owners of a resource can assign other users as owners."
)
attrs["team_id"] = self.context["team_id"]
# pylint: disable=no-member
attrs[f"{self.Meta.resource_field_name}_id"] = self.context["resource_id"]
return attrs
class TeamAccessReadOnlySerializer(TeamAccessSerializer):
"""Serialize team accesses for list and retrieve actions."""
class DocumentAccessSerializer(BaseAccessSerializer):
"""Serialize document accesses."""
user = UserSerializer(read_only=True, fields=["id", "name", "email"])
user_id = serializers.PrimaryKeyRelatedField(
queryset=models.User.objects.all(),
write_only=True,
source="user",
required=False,
allow_null=True,
)
user = UserSerializer(read_only=True)
class Meta:
model = models.TeamAccess
fields = [
"id",
"user",
"role",
"abilities",
]
read_only_fields = [
"id",
"user",
"role",
"abilities",
]
model = models.DocumentAccess
resource_field_name = "document"
fields = ["id", "user", "user_id", "team", "role", "abilities"]
read_only_fields = ["id", "abilities"]
class TeamSerializer(serializers.ModelSerializer):
"""Serialize teams."""
class TemplateAccessSerializer(BaseAccessSerializer):
"""Serialize template accesses."""
class Meta:
model = models.TemplateAccess
resource_field_name = "template"
fields = ["id", "user", "team", "role", "abilities"]
read_only_fields = ["id", "abilities"]
class BaseResourceSerializer(serializers.ModelSerializer):
"""Serialize documents."""
abilities = serializers.SerializerMethodField(read_only=True)
slug = serializers.SerializerMethodField()
accesses = TemplateAccessSerializer(many=True, read_only=True)
class Meta:
model = models.Team
fields = [
"id",
"name",
"accesses",
"abilities",
"slug",
"created_at",
"updated_at",
]
read_only_fields = [
"id",
"accesses",
"abilities",
"slug",
"created_at",
"updated_at",
]
def get_abilities(self, team) -> dict:
def get_abilities(self, document) -> dict:
"""Return abilities of the logged-in user on the instance."""
request = self.context.get("request")
if request:
return team.get_abilities(request.user)
return document.get_abilities(request.user)
return {}
def get_slug(self, instance):
"""Return slug from the team's name."""
return instance.get_slug()
class DocumentSerializer(BaseResourceSerializer):
"""Serialize documents."""
content = serializers.CharField(required=False)
accesses = DocumentAccessSerializer(many=True, read_only=True)
class Meta:
model = models.Document
fields = [
"id",
"content",
"title",
"accesses",
"abilities",
"is_e2ee",
"link_role",
"link_reach",
"created_at",
"updated_at",
]
read_only_fields = [
"id",
"accesses",
"abilities",
"link_role",
"link_reach",
"created_at",
"updated_at",
]
def get_fields(self):
"""Dynamically make `id` read-only on PUT requests but writable on POST requests."""
fields = super().get_fields()
request = self.context.get("request")
if request and request.method == "POST":
fields["id"].read_only = False
return fields
def validate_id(self, value):
"""Ensure the provided ID does not already exist when creating a new document."""
request = self.context.get("request")
# Only check this on POST (creation)
if request and request.method == "POST":
if models.Document.objects.filter(id=value).exists():
raise serializers.ValidationError(
"A document with this ID already exists. You cannot override it."
)
return value
class LinkDocumentSerializer(BaseResourceSerializer):
"""
Serialize link configuration for documents.
We expose it separately from document in order to simplify and secure access control.
"""
class Meta:
model = models.Document
fields = [
"link_role",
"link_reach",
]
# Suppress the warning about not implementing `create` and `update` methods
# since we don't use a model and only rely on the serializer for validation
# pylint: disable=abstract-method
class FileUploadSerializer(serializers.Serializer):
"""Receive file upload requests."""
file = serializers.FileField()
def validate_file(self, file):
"""Add file size and type constraints as defined in settings."""
# Validate file size
if file.size > settings.DOCUMENT_IMAGE_MAX_SIZE:
max_size = settings.DOCUMENT_IMAGE_MAX_SIZE // (1024 * 1024)
raise serializers.ValidationError(
f"File size exceeds the maximum limit of {max_size:d} MB."
)
# 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):
"""Serialize templates."""
class Meta:
model = models.Template
fields = [
"id",
"title",
"accesses",
"abilities",
"css",
"code",
"is_public",
]
read_only_fields = ["id", "accesses", "abilities"]
# pylint: disable=abstract-method
class DocumentGenerationSerializer(serializers.Serializer):
"""Serializer to receive a request to generate a document on a template."""
body = serializers.CharField(label=_("Body"))
body_type = serializers.ChoiceField(
choices=["html", "markdown"],
label=_("Body type"),
required=False,
default="html",
)
format = serializers.ChoiceField(
choices=["pdf", "docx"],
label=_("Format"),
required=False,
default="pdf",
)
class InvitationSerializer(serializers.ModelSerializer):
"""Serialize invitations."""
abilities = serializers.SerializerMethodField(read_only=True)
class Meta:
model = models.Invitation
fields = ["id", "created_at", "email", "team", "role", "issuer", "is_expired"]
read_only_fields = ["id", "created_at", "team", "issuer", "is_expired"]
fields = [
"id",
"abilities",
"created_at",
"email",
"document",
"role",
"issuer",
"is_expired",
]
read_only_fields = [
"id",
"abilities",
"created_at",
"document",
"issuer",
"is_expired",
]
def get_abilities(self, invitation) -> dict:
"""Return abilities of the logged-in user on the instance."""
request = self.context.get("request")
if request:
return invitation.get_abilities(request.user)
return {}
def validate(self, attrs):
"""Validate and restrict invitation to new user based on email."""
request = self.context.get("request")
user = getattr(request, "user", None)
role = attrs.get("role")
try:
team_id = self.context["team_id"]
document_id = self.context["resource_id"]
except KeyError as exc:
raise exceptions.ValidationError(
"You must set a team ID in kwargs to create a new team invitation."
"You must set a document ID in kwargs to create a new document invitation."
) from exc
if not models.TeamAccess.objects.filter(
team=team_id,
user=user,
if not user and user.is_authenticated:
raise exceptions.PermissionDenied(
"Anonymous users are not allowed to create invitations."
)
if not models.DocumentAccess.objects.filter(
Q(user=user) | Q(team__in=user.teams),
document=document_id,
role__in=[models.RoleChoices.OWNER, models.RoleChoices.ADMIN],
).exists():
raise exceptions.PermissionDenied(
"You are not allowed to manage invitation for this team."
"You are not allowed to manage invitations for this document."
)
attrs["team_id"] = team_id
if (
role == models.RoleChoices.OWNER
and not models.DocumentAccess.objects.filter(
Q(user=user) | Q(team__in=user.teams),
document=document_id,
role=models.RoleChoices.OWNER,
).exists()
):
raise exceptions.PermissionDenied(
"Only owners of a document can invite other users as owners."
)
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()
+33
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
+543 -269
View File
@@ -1,25 +1,53 @@
"""API endpoints"""
from django.contrib.postgres.search import TrigramSimilarity
from django.db.models import Func, Max, OuterRef, Prefetch, Q, Subquery, Value
from django.db.models.functions import Coalesce
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 Http404
from botocore.exceptions import ClientError
from rest_framework import (
decorators,
exceptions,
filters,
mixins,
pagination,
response,
throttling,
status,
viewsets,
)
from rest_framework import (
response as drf_response,
)
from core import models
from core.utils import email_invitation
from . import permissions, serializers
from . import permissions, serializers, utils
SIMILARITY_THRESHOLD = 0.04
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):
@@ -97,140 +125,37 @@ class SerializerPerActionMixin:
class Pagination(pagination.PageNumberPagination):
"""Pagination to display no more than 100 objects per page sorted by creation date."""
ordering = "-created_on"
max_page_size = 100
page_size_query_param = "page_size"
class BurstRateThrottle(throttling.UserRateThrottle):
"""
Throttle rate for minutes. See DRF section in settings for default value.
"""
scope = "burst"
class SustainedRateThrottle(throttling.UserRateThrottle):
"""
Throttle rate for hours. See DRF section in settings for default value.
"""
scope = "sustained"
# pylint: disable=too-many-ancestors
class ContactViewSet(
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
"""Contact ViewSet"""
permission_classes = [permissions.IsOwnedOrPublic]
queryset = models.Contact.objects.all()
serializer_class = serializers.ContactSerializer
throttle_classes = [BurstRateThrottle, SustainedRateThrottle]
def list(self, request, *args, **kwargs):
"""Limit listed users by a query with throttle protection."""
user = self.request.user
queryset = self.filter_queryset(self.get_queryset())
# Exclude contacts that:
queryset = queryset.filter(
# - belong to another user (keep public and owned contacts)
Q(owner__isnull=True) | Q(owner=user),
# - are profile contacts for a user
user__isnull=True,
# - are overriden base contacts
overriding_contacts__isnull=True,
)
# Search by case-insensitive and accent-insensitive trigram similarity
if query := self.request.GET.get("q", ""):
query = Func(Value(query), function="unaccent")
similarity = TrigramSimilarity(
Func("full_name", function="unaccent"),
query,
) + TrigramSimilarity(Func("short_name", function="unaccent"), query)
queryset = (
queryset.annotate(similarity=similarity)
.filter(
similarity__gte=0.05
) # Value determined by testing (test_api_contacts.py)
.order_by("-similarity")
)
serializer = self.get_serializer(queryset, many=True)
return response.Response(serializer.data)
def perform_create(self, serializer):
"""Set the current user as owner of the newly created contact."""
user = self.request.user
serializer.validated_data["owner"] = user
return super().perform_create(serializer)
class UserViewSet(
mixins.UpdateModelMixin, viewsets.GenericViewSet, mixins.ListModelMixin
):
"""
User viewset for all interactions with user infos and teams.
GET /api/users/&q=query
Return a list of users whose email matches the query. Similarity is
calculated using trigram similarity, allowing for partial,
case-insensitive matches and accented queries.
"""
"""User ViewSet"""
permission_classes = [permissions.IsSelf]
queryset = models.User.objects.all().order_by("-created_at")
queryset = models.User.objects.all()
serializer_class = serializers.UserSerializer
throttle_classes = [BurstRateThrottle, SustainedRateThrottle]
pagination_class = Pagination
def get_queryset(self):
"""Limit listed users by a query. Pagination and throttle protection apply."""
"""
Limit listed users by querying the email field with a trigram similarity
search if a query is provided.
Limit listed users by excluding users already in the document if a document_id
is provided.
"""
queryset = self.queryset
if self.action == "list":
# Exclude inactive contacts
queryset = queryset.filter(
is_active=True,
).prefetch_related(
Prefetch(
"identities",
queryset=models.Identity.objects.filter(is_main=True),
to_attr="_identities_main",
)
)
# Exclude all users already in the given document
if document_id := self.request.GET.get("document_id", ""):
queryset = queryset.exclude(documentaccess__document_id=document_id)
# Exclude all users already in the given team
if team_id := self.request.GET.get("team_id", ""):
queryset = queryset.exclude(teams__id=team_id)
# Search by case-insensitive and accent-insensitive trigram similarity
# Filter users by email similarity
if query := self.request.GET.get("q", ""):
similarity = Max(
TrigramSimilarity(
Coalesce(
Func("identities__email", function="unaccent"), Value("")
),
Func(Value(query), function="unaccent"),
)
+ TrigramSimilarity(
Coalesce(
Func("identities__name", function="unaccent"), Value("")
),
Func(Value(query), function="unaccent"),
)
)
queryset = (
queryset.annotate(similarity=similarity)
.filter(similarity__gte=SIMILARITY_THRESHOLD)
.order_by("-similarity")
)
queryset = queryset.filter(email__trigram_word_similar=query)
return queryset
@@ -239,100 +164,59 @@ class UserViewSet(
methods=["get"],
url_name="me",
url_path="me",
permission_classes=[permissions.IsAuthenticated],
)
def get_me(self, request):
"""
Return information on currently logged user
"""
user = request.user
return response.Response(
self.serializer_class(user, context={"request": request}).data
context = {"request": request}
return drf_response.Response(
self.serializer_class(request.user, context=context).data
)
class TeamViewSet(
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
"""Team ViewSet"""
class ResourceViewsetMixin:
"""Mixin with methods common to all resource viewsets that are managed with accesses."""
permission_classes = [permissions.AccessPermission]
serializer_class = serializers.TeamSerializer
filter_backends = [filters.OrderingFilter]
ordering_fields = ["created_at"]
ordering_fields = ["created_at", "updated_at", "title"]
ordering = ["-created_at"]
queryset = models.Team.objects.all()
def get_queryset(self):
"""Custom queryset to get user related teams."""
user_role_query = models.TeamAccess.objects.filter(
user=self.request.user, team=OuterRef("pk")
).values("role")[:1]
return models.Team.objects.filter(accesses__user=self.request.user).annotate(
user_role=Subquery(user_role_query)
"""Custom queryset to get user related resources."""
queryset = super().get_queryset()
user = self.request.user
if not user.is_authenticated:
return queryset
user_roles_query = (
self.access_model_class.objects.filter(
Q(user=user) | Q(team__in=user.teams),
**{self.resource_field_name: OuterRef("pk")},
)
.values(self.resource_field_name)
.annotate(roles_array=ArrayAgg("role"))
.values("roles_array")
)
return queryset.annotate(user_roles=Subquery(user_roles_query)).distinct()
def perform_create(self, serializer):
"""Set the current user as owner of the newly created team."""
team = serializer.save()
models.TeamAccess.objects.create(
team=team,
"""Set the current user as owner of the newly created object."""
obj = serializer.save()
self.access_model_class.objects.create(
user=self.request.user,
role=models.RoleChoices.OWNER,
**{self.resource_field_name: obj},
)
class TeamAccessViewSet(
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
"""
API ViewSet for all interactions with team accesses.
GET /api/v1.0/teams/<team_id>/accesses/:<team_access_id>
Return list of all team accesses related to the logged-in user or one
team access if an id is provided.
POST /api/v1.0/teams/<team_id>/accesses/ with expected data:
- user: str
- role: str [owner|admin|member]
Return newly created team access
PUT /api/v1.0/teams/<team_id>/accesses/<team_access_id>/ with expected data:
- role: str [owner|admin|member]
Return updated team access
PATCH /api/v1.0/teams/<team_id>/accesses/<team_access_id>/ with expected data:
- role: str [owner|admin|member]
Return partially updated team access
DELETE /api/v1.0/teams/<team_id>/accesses/<team_access_id>/
Delete targeted team access
"""
lookup_field = "pk"
pagination_class = Pagination
permission_classes = [permissions.AccessPermission]
queryset = (
models.TeamAccess.objects.all().select_related("user").order_by("-created_at")
)
list_serializer_class = serializers.TeamAccessReadOnlySerializer
detail_serializer_class = serializers.TeamAccessSerializer
filter_backends = [filters.OrderingFilter]
ordering = ["role"]
ordering_fields = ["role", "email", "name"]
class ResourceAccessViewsetMixin:
"""Mixin with methods common to all access viewsets."""
def get_permissions(self):
"""User only needs to be authenticated to list team accesses"""
"""User only needs to be authenticated to list resource accesses"""
if self.action == "list":
permission_classes = [permissions.IsAuthenticated]
else:
@@ -343,48 +227,40 @@ class TeamAccessViewSet(
def get_serializer_context(self):
"""Extra context provided to the serializer class."""
context = super().get_serializer_context()
context["team_id"] = self.kwargs["team_id"]
context["resource_id"] = self.kwargs["resource_id"]
return context
def get_serializer_class(self):
if self.action in {"list", "retrieve"}:
return self.list_serializer_class
return self.detail_serializer_class
def get_queryset(self):
"""Return the queryset according to the action."""
queryset = super().get_queryset()
queryset = queryset.filter(team=self.kwargs["team_id"])
queryset = queryset.filter(
**{self.resource_field_name: self.kwargs["resource_id"]}
)
if self.action in {"list", "retrieve"}:
# Determine which role the logged-in user has in the team
user_role_query = models.TeamAccess.objects.filter(
user=self.request.user, team=self.kwargs["team_id"]
).values("role")[:1]
user_main_identity_query = models.Identity.objects.filter(
user=OuterRef("user_id"), is_main=True
if self.action == "list":
user = self.request.user
teams = user.teams
user_roles_query = (
queryset.filter(
Q(user=user) | Q(team__in=teams),
**{self.resource_field_name: self.kwargs["resource_id"]},
)
.values(self.resource_field_name)
.annotate(roles_array=ArrayAgg("role"))
.values("roles_array")
)
# Limit to resource access instances related to a resource THAT also has
# a resource access
# instance for the logged-in user (we don't want to list only the resource
# access instances pointing to the logged-in user)
queryset = (
# The logged-in user should be part of a team to see its accesses
queryset.filter(
team__accesses__user=self.request.user,
)
.prefetch_related(
Prefetch(
"user__identities",
queryset=models.Identity.objects.filter(is_main=True),
to_attr="_identities_main",
)
)
# Abilities are computed based on logged-in user's role and
# the user role on each team access
.annotate(
user_role=Subquery(user_role_query),
email=Subquery(user_main_identity_query.values("email")[:1]),
name=Subquery(user_main_identity_query.values("name")[:1]),
Q(**{f"{self.resource_field_name}__accesses__user": user})
| Q(**{f"{self.resource_field_name}__accesses__team__in": teams}),
**{self.resource_field_name: self.kwargs["resource_id"]},
)
.annotate(user_roles=Subquery(user_roles_query))
.distinct()
)
return queryset
@@ -392,13 +268,16 @@ class TeamAccessViewSet(
def destroy(self, request, *args, **kwargs):
"""Forbid deleting the last owner access"""
instance = self.get_object()
team = instance.team
resource = getattr(instance, self.resource_field_name)
# Check if the access being deleted is the last owner access for the team
if instance.role == "owner" and team.accesses.filter(role="owner").count() == 1:
return response.Response(
{"detail": "Cannot delete the last owner access for the team."},
status=400,
# Check if the access being deleted is the last owner access for the resource
if (
instance.role == "owner"
and resource.accesses.filter(role="owner").count() == 1
):
return drf_response.Response(
{"detail": "Cannot delete the last owner access for the resource."},
status=status.HTTP_403_FORBIDDEN,
)
return super().destroy(request, *args, **kwargs)
@@ -412,87 +291,482 @@ class TeamAccessViewSet(
"role" in self.request.data
and self.request.data["role"] != models.RoleChoices.OWNER
):
team = instance.team
# Check if the access being updated is the last owner access for the team
resource = getattr(instance, self.resource_field_name)
# Check if the access being updated is the last owner access for the resource
if (
instance.role == models.RoleChoices.OWNER
and team.accesses.filter(role=models.RoleChoices.OWNER).count() == 1
and resource.accesses.filter(role=models.RoleChoices.OWNER).count() == 1
):
message = "Cannot change the role to a non-owner role for the last owner access."
raise exceptions.ValidationError({"role": message})
raise exceptions.PermissionDenied({"detail": message})
serializer.save()
class DocumentViewSet(
ResourceViewsetMixin,
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
"""Document ViewSet"""
permission_classes = [
permissions.AccessPermission,
]
serializer_class = serializers.DocumentSerializer
access_model_class = models.DocumentAccess
resource_field_name = "document"
queryset = models.Document.objects.all()
ordering = ["-updated_at"]
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()
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):
"""
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=user) | Q(team__in=user.teams),
)
)
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"],
url_path="versions/(?P<version_id>[0-9a-f-]{36})",
)
# pylint: disable=unused-argument
def versions_detail(self, request, pk, version_id, *args, **kwargs):
"""Custom action to retrieve a specific version of a document"""
document = self.get_object()
try:
response = document.get_content_response(version_id=version_id)
except (FileNotFoundError, ClientError) as err:
raise Http404 from err
# 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=user) | Q(team__in=user.teams),
)
)
if response["LastModified"] < from_datetime:
raise Http404
if request.method == "DELETE":
response = document.delete_version(version_id)
return drf_response.Response(
status=response["ResponseMetadata"]["HTTPStatusCode"]
)
return drf_response.Response(
{
"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,
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
"""
API ViewSet for all interactions with document accesses.
GET /api/v1.0/documents/<resource_id>/accesses/:<document_access_id>
Return list of all document accesses related to the logged-in user or one
document access if an id is provided.
POST /api/v1.0/documents/<resource_id>/accesses/ with expected data:
- user: str
- role: str [administrator|editor|reader]
Return newly created document access
PUT /api/v1.0/documents/<resource_id>/accesses/<document_access_id>/ with expected data:
- role: str [owner|admin|editor|reader]
Return updated document access
PATCH /api/v1.0/documents/<resource_id>/accesses/<document_access_id>/ with expected data:
- role: str [owner|admin|editor|reader]
Return partially updated document access
DELETE /api/v1.0/documents/<resource_id>/accesses/<document_access_id>/
Delete targeted document access
"""
lookup_field = "pk"
pagination_class = Pagination
permission_classes = [permissions.IsAuthenticated, permissions.AccessPermission]
queryset = models.DocumentAccess.objects.select_related("user").all()
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.RetrieveModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
"""Template ViewSet"""
permission_classes = [
permissions.IsAuthenticatedOrSafe,
permissions.AccessPermission,
]
serializer_class = serializers.TemplateSerializer
access_model_class = models.TemplateAccess
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"],
url_path="generate-document",
permission_classes=[permissions.AccessPermission],
)
# pylint: disable=unused-argument
def generate_document(self, request, pk=None):
"""
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)
if not serializer.is_valid():
return drf_response.Response(
serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
body = serializer.validated_data["body"]
body_type = serializer.validated_data["body_type"]
export_format = serializer.validated_data["format"]
template = self.get_object()
return template.generate_document(body, body_type, export_format)
class TemplateAccessViewSet(
ResourceAccessViewsetMixin,
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
"""
API ViewSet for all interactions with template accesses.
GET /api/v1.0/templates/<template_id>/accesses/:<template_access_id>
Return list of all template accesses related to the logged-in user or one
template access if an id is provided.
POST /api/v1.0/templates/<template_id>/accesses/ with expected data:
- user: str
- role: str [administrator|editor|reader]
Return newly created template access
PUT /api/v1.0/templates/<template_id>/accesses/<template_access_id>/ with expected data:
- role: str [owner|admin|editor|reader]
Return updated template access
PATCH /api/v1.0/templates/<template_id>/accesses/<template_access_id>/ with expected data:
- role: str [owner|admin|editor|reader]
Return partially updated template access
DELETE /api/v1.0/templates/<template_id>/accesses/<template_access_id>/
Delete targeted template access
"""
lookup_field = "pk"
pagination_class = Pagination
permission_classes = [permissions.IsAuthenticated, permissions.AccessPermission]
queryset = models.TemplateAccess.objects.select_related("user").all()
resource_field_name = "template"
serializer_class = serializers.TemplateAccessSerializer
class InvitationViewset(
mixins.CreateModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.DestroyModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
"""API ViewSet for user invitations to team.
"""API ViewSet for user invitations to document.
GET /api/v1.0/teams/<team_id>/invitations/:<invitation_id>/
Return list of invitations related to that team or or one
team access if an id is provided.
GET /api/v1.0/documents/<document_id>/invitations/:<invitation_id>/
Return list of invitations related to that document or one
document access if an id is provided.
POST /api/v1.0/teams/<team_id>/invitations/ with expected data:
POST /api/v1.0/documents/<document_id>/invitations/ with expected data:
- email: str
- role: str [owner|admin|member]
- issuer : User, automatically added from user making query, if allowed
- team : Team, automatically added from requested URI
Return newly created invitation
- 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/teams/<team_id>/invitations/<invitation_id>/
DELETE /api/v1.0/documents/<document_id>/invitations/<invitation_id>/
Delete targeted invitation
"""
lookup_field = "id"
pagination_class = Pagination
permission_classes = [permissions.AccessPermission]
permission_classes = [permissions.IsAuthenticated, permissions.AccessPermission]
queryset = (
models.Invitation.objects.all().select_related("team").order_by("-created_at")
models.Invitation.objects.all()
.select_related("document")
.order_by("-created_at")
)
serializer_class = serializers.InvitationSerializer
def get_permissions(self):
"""User only needs to be authenticated to list invitations"""
if self.action == "list":
permission_classes = [permissions.IsAuthenticated]
else:
return super().get_permissions()
return [permission() for permission in permission_classes]
def get_serializer_context(self):
"""Extra context provided to the serializer class."""
context = super().get_serializer_context()
context["team_id"] = self.kwargs["team_id"]
context["resource_id"] = self.kwargs["resource_id"]
return context
def get_queryset(self):
"""Return the queryset according to the action."""
queryset = super().get_queryset()
queryset = queryset.filter(team=self.kwargs["team_id"])
queryset = queryset.filter(document=self.kwargs["resource_id"])
if self.action == "list":
# Determine which role the logged-in user has in the team
user_role_query = models.TeamAccess.objects.filter(
user=self.request.user, team=self.kwargs["team_id"]
).values("role")[:1]
user = self.request.user
teams = user.teams
# Determine which role the logged-in user has in the document
user_roles_query = (
models.DocumentAccess.objects.filter(
Q(user=user) | Q(team__in=teams),
document=self.kwargs["resource_id"],
)
.values("document")
.annotate(roles_array=ArrayAgg("role"))
.values("roles_array")
)
queryset = (
# The logged-in user should be part of a team to see its accesses
# The logged-in user should be part of a document to see its accesses
queryset.filter(
team__accesses__user=self.request.user,
Q(document__accesses__user=user)
| Q(document__accesses__team__in=teams),
)
# Abilities are computed based on logged-in user's role and
# the user role on each team access
.annotate(user_role=Subquery(user_role_query))
# the user role on each document access
.annotate(user_roles=Subquery(user_roles_query))
.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)
+3 -3
View File
@@ -1,11 +1,11 @@
"""People Core application"""
"""Impress Core application"""
# from django.apps import AppConfig
# from django.utils.translation import gettext_lazy as _
# class CoreConfig(AppConfig):
# """Configuration class for the People core app."""
# """Configuration class for the impress core app."""
# name = "core"
# app_label = "core"
# verbose_name = _("People core application")
# verbose_name = _("impress core application")
+16 -38
View File
@@ -1,8 +1,6 @@
"""Authentication Backends for the People core app."""
"""Authentication Backends for the Impress core app."""
from django.conf import settings
from django.core.exceptions import SuspiciousOperation
from django.db import models
from django.utils.translation import gettext_lazy as _
import requests
@@ -10,7 +8,7 @@ from mozilla_django_oidc.auth import (
OIDCAuthenticationBackend as MozillaOIDCAuthenticationBackend,
)
from core.models import Identity
from core.models import User
class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
@@ -66,57 +64,37 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
"""
user_info = self.get_userinfo(access_token, id_token, payload)
# Compute user name from OIDC name fields as defined in settings
names_list = [
user_info[field]
for field in settings.USER_OIDC_FIELDS_TO_NAME
if user_info.get(field)
]
user_info["name"] = " ".join(names_list) or None
sub = user_info.get("sub")
if sub is None:
raise SuspiciousOperation(
_("User info contained no recognizable user identification")
)
user = (
self.UserModel.objects.filter(identities__sub=sub)
.annotate(
identity_email=models.F("identities__email"),
identity_name=models.F("identities__name"),
)
.distinct()
.first()
)
if user:
email = user_info.get("email")
name = user_info.get("name")
if (
email
and email != user.identity_email
or name
and name != user.identity_name
):
Identity.objects.filter(sub=sub).update(email=email, name=name)
elif self.get_settings("OIDC_CREATE_USER", True):
user = self.create_user(user_info)
try:
user = User.objects.get(sub=sub)
except User.DoesNotExist:
if self.get_settings("OIDC_CREATE_USER", True):
user = self.create_user(user_info)
else:
user = None
return user
def create_user(self, claims):
"""Return a newly created User instance."""
sub = claims.get("sub")
if sub is None:
raise SuspiciousOperation(
_("Claims contained no recognizable user identification")
)
user = self.UserModel.objects.create(password="!") # noqa: S106
Identity.objects.create(
user=user, sub=sub, email=claims.get("email"), name=claims.get("name")
user = User.objects.create(
sub=sub,
email=claims.get("email"),
password="!", # noqa: S106
)
return user
-9
View File
@@ -3,7 +3,6 @@ Core application enums declaration
"""
from django.conf import global_settings, settings
from django.db import models
from django.utils.translation import gettext_lazy as _
# Django sets `LANGUAGES` by default with all supported languages. We can use it for
@@ -15,11 +14,3 @@ ALL_LANGUAGES = getattr(
"ALL_LANGUAGES",
[(language, _(name)) for language, name in global_settings.LANGUAGES],
)
class WebhookStatusChoices(models.TextChoices):
"""Defines the possible statuses in which a webhook can be."""
FAILURE = "failure", _("Failure")
PENDING = "pending", _("Pending")
SUCCESS = "success", _("Success")
+84 -144
View File
@@ -14,177 +14,117 @@ from core import models
fake = Faker()
class BaseContactFactory(factory.django.DjangoModelFactory):
"""A factory to create contacts for a user"""
class Meta:
model = models.Contact
full_name = factory.Faker("name")
short_name = factory.LazyAttributeSequence(
lambda o, n: o.full_name.split()[0] if o.full_name else f"user{n!s}"
)
data = factory.Dict(
{
"emails": factory.LazyAttribute(
lambda x: [
{
"type": fake.random_element(["Home", "Work", "Other"]),
"value": fake.email(),
}
for _ in range(fake.random_int(1, 3))
]
),
"phones": factory.LazyAttribute(
lambda x: [
{
"type": fake.random_element(
[
"Mobile",
"Home",
"Work",
"Main",
"Work Fax",
"Home Fax",
"Pager",
"Other",
]
),
"value": fake.phone_number(),
}
for _ in range(fake.random_int(1, 3))
]
),
"addresses": factory.LazyAttribute(
lambda x: [
{
"type": fake.random_element(["Home", "Work", "Other"]),
"street": fake.street_address(),
"city": fake.city(),
"state": fake.state(),
"zip": fake.zipcode(),
"country": fake.country(),
}
for _ in range(fake.random_int(1, 3))
]
),
"links": factory.LazyAttribute(
lambda x: [
{
"type": fake.random_element(
[
"Profile",
"Blog",
"Website",
"Twitter",
"Facebook",
"Instagram",
"LinkedIn",
"Other",
]
),
"value": fake.url(),
}
for _ in range(fake.random_int(1, 3))
]
),
"customFields": factory.LazyAttribute(
lambda x: {
f"custom_field_{i:d}": fake.word()
for i in range(fake.random_int(1, 3))
},
),
"organizations": factory.LazyAttribute(
lambda x: [
{
"name": fake.company(),
"department": fake.word(),
"jobTitle": fake.job(),
}
for _ in range(fake.random_int(1, 3))
]
),
}
)
class ContactFactory(BaseContactFactory):
"""A factory to create contacts for a user"""
class Meta:
model = models.Contact
base = factory.SubFactory("core.factories.ContactFactory", base=None, owner=None)
owner = factory.SubFactory("core.factories.UserFactory", profile_contact=None)
class UserFactory(factory.django.DjangoModelFactory):
"""A factory to create random users for testing purposes."""
"""A factory to random users for testing purposes."""
class Meta:
model = models.User
django_get_or_create = ("admin_email",)
admin_email = factory.Faker("email")
sub = factory.Sequence(lambda n: f"user{n!s}")
email = factory.Faker("email")
language = factory.fuzzy.FuzzyChoice([lang[0] for lang in settings.LANGUAGES])
password = make_password("password")
class IdentityFactory(factory.django.DjangoModelFactory):
"""A factory to create identities for a user"""
class DocumentFactory(factory.django.DjangoModelFactory):
"""A factory to create documents"""
class Meta:
model = models.Identity
django_get_or_create = ("sub",)
user = factory.SubFactory(UserFactory)
sub = factory.Sequence(lambda n: f"user{n!s}")
email = factory.Faker("email")
name = factory.Faker("name")
class TeamFactory(factory.django.DjangoModelFactory):
"""A factory to create teams"""
class Meta:
model = models.Team
django_get_or_create = ("name",)
model = models.Document
django_get_or_create = ("title",)
skip_postgeneration_save = True
name = factory.Sequence(lambda n: f"team{n}")
title = factory.Sequence(lambda n: f"document{n}")
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):
"""Add users to team from a given list of users with or without roles."""
if not create or not extracted:
return
for user_entry in extracted:
if isinstance(user_entry, models.User):
TeamAccessFactory(team=self, user=user_entry)
else:
TeamAccessFactory(team=self, user=user_entry[0], role=user_entry[1])
"""Add users to document from a given list of users with or without roles."""
if create and extracted:
for item in extracted:
if isinstance(item, models.User):
UserDocumentAccessFactory(document=self, user=item)
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 TeamAccessFactory(factory.django.DjangoModelFactory):
"""Create fake team user accesses for testing."""
class UserDocumentAccessFactory(factory.django.DjangoModelFactory):
"""Create fake document user accesses for testing."""
class Meta:
model = models.TeamAccess
model = models.DocumentAccess
team = factory.SubFactory(TeamFactory)
document = factory.SubFactory(DocumentFactory)
user = factory.SubFactory(UserFactory)
role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices])
class TeamWebhookFactory(factory.django.DjangoModelFactory):
"""Create fake team webhooks for testing."""
class TeamDocumentAccessFactory(factory.django.DjangoModelFactory):
"""Create fake document team accesses for testing."""
class Meta:
model = models.TeamWebhook
model = models.DocumentAccess
team = factory.SubFactory(TeamFactory)
url = factory.Sequence(lambda n: f"https://example.com/Groups/{n!s}")
document = factory.SubFactory(DocumentFactory)
team = factory.Sequence(lambda n: f"team{n}")
role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices])
class TemplateFactory(factory.django.DjangoModelFactory):
"""A factory to create templates"""
class Meta:
model = models.Template
django_get_or_create = ("title",)
skip_postgeneration_save = True
title = factory.Sequence(lambda n: f"template{n}")
is_public = factory.Faker("boolean")
@factory.post_generation
def users(self, create, extracted, **kwargs):
"""Add users to template from a given list of users with or without roles."""
if create and extracted:
for item in extracted:
if isinstance(item, models.User):
UserTemplateAccessFactory(template=self, user=item)
else:
UserTemplateAccessFactory(template=self, user=item[0], role=item[1])
class UserTemplateAccessFactory(factory.django.DjangoModelFactory):
"""Create fake template user accesses for testing."""
class Meta:
model = models.TemplateAccess
template = factory.SubFactory(TemplateFactory)
user = factory.SubFactory(UserFactory)
role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices])
class TeamTemplateAccessFactory(factory.django.DjangoModelFactory):
"""Create fake template team accesses for testing."""
class Meta:
model = models.TemplateAccess
template = factory.SubFactory(TemplateFactory)
team = factory.Sequence(lambda n: f"team{n}")
role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices])
class InvitationFactory(factory.django.DjangoModelFactory):
@@ -193,7 +133,7 @@ class InvitationFactory(factory.django.DjangoModelFactory):
class Meta:
model = models.Invitation
team = factory.SubFactory(TeamFactory)
email = factory.Faker("email")
document = factory.SubFactory(DocumentFactory)
role = factory.fuzzy.FuzzyChoice([role[0] for role in models.RoleChoices.choices])
issuer = factory.SubFactory(UserFactory)
@@ -1,130 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"title": "Contact Information",
"properties": {
"emails": {
"type": "array",
"title": "Emails",
"items": {
"type": "object",
"properties": {
"type": {
"type": "string",
"title": "Type",
"enum": ["Work", "Home", "Other"]
},
"value": {
"type": "string",
"title": "Email Address",
"format": "email"
}
},
"required": ["type", "value"]
}
},
"phones": {
"type": "array",
"title": "Phones",
"items": {
"type": "object",
"properties": {
"type": {
"type": "string",
"title": "Type",
"enum": ["Mobile", "Home", "Work", "Main", "Work Fax", "Home Fax", "Pager", "Other"]
},
"value": {
"type": "string",
"title": "Phone Number"
}
},
"required": ["type", "value"]
}
},
"addresses": {
"type": "array",
"title": "Addresses",
"items": {
"type": "object",
"properties": {
"type": {
"type": "string",
"title": "Type",
"enum": ["Home", "Work", "Other"]
},
"street": {
"type": "string",
"title": "Street"
},
"city": {
"type": "string",
"title": "City"
},
"state": {
"type": "string",
"title": "State"
},
"zip": {
"type": "string",
"title": "ZIP Code"
},
"country": {
"type": "string",
"title": "Country"
}
}
}
},
"links": {
"type": "array",
"title": "Links",
"items": {
"type": "object",
"properties": {
"type": {
"type": "string",
"title": "Type",
"enum": ["Profile", "Blog", "Website", "Twitter", "Facebook", "Instagram", "LinkedIn", "Other"]
},
"value": {
"type": "string",
"title": "URL",
"format": "uri"
}
},
"required": ["type", "value"]
}
},
"customFields": {
"type": "object",
"title": "Custom Fields",
"additionalProperties": {
"type": "string"
}
},
"organizations": {
"type": "array",
"title": "Organizations",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string",
"title": "Organization Name"
},
"department": {
"type": "string",
"title": "Department"
},
"jobTitle": {
"type": "string",
"title": "Job Title"
}
},
"required": ["name"]
}
}
},
"additionalProperties": false
}
+80 -102
View File
@@ -1,4 +1,4 @@
# Generated by Django 5.0.3 on 2024-03-25 22:58
# Generated by Django 5.0.3 on 2024-05-28 20:29
import django.contrib.auth.models
import django.core.validators
@@ -18,22 +18,39 @@ class Migration(migrations.Migration):
]
operations = [
migrations.RunSQL('CREATE EXTENSION IF NOT EXISTS pg_trgm;', 'DROP EXTENSION IF EXISTS pg_trgm;'),
migrations.RunSQL('CREATE EXTENSION IF NOT EXISTS unaccent;', 'DROP EXTENSION IF EXISTS unaccent;'),
migrations.CreateModel(
name='Team',
name='Document',
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 at')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated at')),
('name', models.CharField(max_length=100)),
('slug', models.SlugField(editable=False, max_length=100, unique=True)),
('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')),
('title', models.CharField(max_length=255, verbose_name='title')),
('is_public', models.BooleanField(default=False, help_text='Whether this document is public for anyone to use.', verbose_name='public')),
],
options={
'verbose_name': 'Team',
'verbose_name_plural': 'Teams',
'db_table': 'people_team',
'ordering': ('name',),
'verbose_name': 'Document',
'verbose_name_plural': 'Documents',
'db_table': 'impress_document',
'ordering': ('title',),
},
),
migrations.CreateModel(
name='Template',
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')),
('title', models.CharField(max_length=255, verbose_name='title')),
('description', models.TextField(blank=True, verbose_name='description')),
('code', models.TextField(blank=True, verbose_name='code')),
('css', models.TextField(blank=True, verbose_name='css')),
('is_public', models.BooleanField(default=False, help_text='Whether this template is public for anyone to use.', verbose_name='public')),
],
options={
'verbose_name': 'Template',
'verbose_name_plural': 'Templates',
'db_table': 'impress_template',
'ordering': ('title',),
},
),
migrations.CreateModel(
@@ -43,8 +60,10 @@ class Migration(migrations.Migration):
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('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 at')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated at')),
('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')),
('sub', models.CharField(blank=True, help_text='Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only.', max_length=255, null=True, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters.', regex='^[\\w.@+-]+\\Z')], verbose_name='sub')),
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='identity email address')),
('admin_email', models.EmailField(blank=True, max_length=254, null=True, unique=True, verbose_name='admin email address')),
('language', 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')),
('timezone', timezone_field.fields.TimeZoneField(choices_display='WITH_GMT_OFFSET', default='UTC', help_text='The timezone in which the user wants to see times.', use_pytz=False)),
@@ -57,132 +76,91 @@ class Migration(migrations.Migration):
options={
'verbose_name': 'user',
'verbose_name_plural': 'users',
'db_table': 'people_user',
'db_table': 'impress_user',
},
managers=[
('objects', django.contrib.auth.models.UserManager()),
],
),
migrations.CreateModel(
name='Contact',
name='DocumentAccess',
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 at')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated at')),
('full_name', models.CharField(blank=True, max_length=150, null=True, verbose_name='full name')),
('short_name', models.CharField(blank=True, max_length=30, null=True, verbose_name='short name')),
('data', models.JSONField(blank=True, help_text='A JSON object containing the contact information', verbose_name='contact information')),
('base', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='overriding_contacts', to='core.contact')),
('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='contacts', to=settings.AUTH_USER_MODEL)),
('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')),
('team', models.CharField(blank=True, max_length=100)),
('role', models.CharField(choices=[('reader', 'Reader'), ('editor', 'Editor'), ('administrator', 'Administrator'), ('owner', 'Owner')], default='reader', max_length=20)),
('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='accesses', to='core.document')),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'contact',
'verbose_name_plural': 'contacts',
'db_table': 'people_contact',
'ordering': ('full_name', 'short_name'),
},
),
migrations.AddField(
model_name='user',
name='profile_contact',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='user', to='core.contact'),
),
migrations.CreateModel(
name='Identity',
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 at')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated at')),
('sub', models.CharField(help_text='Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only.', max_length=255, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters.', regex='^[\\w.@+-]+\\Z')], verbose_name='sub')),
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='email address')),
('name', models.CharField(blank=True, max_length=100, null=True, verbose_name='name')),
('is_main', models.BooleanField(default=False, help_text='Designates whether the email is the main one.', verbose_name='main')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='identities', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'identity',
'verbose_name_plural': 'identities',
'db_table': 'people_identity',
'ordering': ('-is_main', 'email'),
'verbose_name': 'Document/user relation',
'verbose_name_plural': 'Document/user relations',
'db_table': 'impress_document_access',
'ordering': ('-created_at',),
},
),
migrations.CreateModel(
name='Invitation',
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 at')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated at')),
('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')),
('email', models.EmailField(max_length=254, verbose_name='email address')),
('role', models.CharField(choices=[('member', 'Member'), ('administrator', 'Administrator'), ('owner', 'Owner')], default='member', max_length=20)),
('role', models.CharField(choices=[('reader', 'Reader'), ('editor', 'Editor'), ('administrator', 'Administrator'), ('owner', 'Owner')], default='reader', max_length=20)),
('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to='core.document')),
('issuer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to=settings.AUTH_USER_MODEL)),
('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to='core.team')),
],
options={
'verbose_name': 'Team invitation',
'verbose_name_plural': 'Team invitations',
'db_table': 'people_invitation',
'verbose_name': 'Document invitation',
'verbose_name_plural': 'Document invitations',
'db_table': 'impress_invitation',
},
),
migrations.CreateModel(
name='TeamAccess',
name='TemplateAccess',
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 at')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated at')),
('role', models.CharField(choices=[('member', 'Member'), ('administrator', 'Administrator'), ('owner', 'Owner')], default='member', max_length=20)),
('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='accesses', to='core.team')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='accesses', to=settings.AUTH_USER_MODEL)),
('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')),
('team', models.CharField(blank=True, max_length=100)),
('role', models.CharField(choices=[('reader', 'Reader'), ('editor', 'Editor'), ('administrator', 'Administrator'), ('owner', 'Owner')], default='reader', max_length=20)),
('template', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='accesses', to='core.template')),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Team/user relation',
'verbose_name_plural': 'Team/user relations',
'db_table': 'people_team_access',
},
),
migrations.AddField(
model_name='team',
name='users',
field=models.ManyToManyField(related_name='teams', through='core.TeamAccess', to=settings.AUTH_USER_MODEL),
),
migrations.CreateModel(
name='TeamWebhook',
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 at')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated at')),
('url', models.URLField(verbose_name='url')),
('secret', models.CharField(blank=True, max_length=255, null=True, verbose_name='secret')),
('status', models.CharField(choices=[('failure', 'Failure'), ('pending', 'Pending'), ('success', 'Success')], default='pending', max_length=10)),
('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='webhooks', to='core.team')),
],
options={
'verbose_name': 'Team webhook',
'verbose_name_plural': 'Team webhooks',
'db_table': 'people_team_webhook',
'verbose_name': 'Template/user relation',
'verbose_name_plural': 'Template/user relations',
'db_table': 'impress_template_access',
'ordering': ('-created_at',),
},
),
migrations.AddConstraint(
model_name='contact',
constraint=models.CheckConstraint(check=models.Q(('base__isnull', False), ('owner__isnull', True), _negated=True), name='base_owner_constraint', violation_error_message='A contact overriding a base contact must be owned.'),
model_name='documentaccess',
constraint=models.UniqueConstraint(condition=models.Q(('user__isnull', False)), fields=('user', 'document'), name='unique_document_user', violation_error_message='This user is already in this document.'),
),
migrations.AddConstraint(
model_name='contact',
constraint=models.CheckConstraint(check=models.Q(('base', models.F('id')), _negated=True), name='base_not_self', violation_error_message='A contact cannot be based on itself.'),
),
migrations.AlterUniqueTogether(
name='contact',
unique_together={('owner', 'base')},
model_name='documentaccess',
constraint=models.UniqueConstraint(condition=models.Q(('team__gt', '')), fields=('team', 'document'), name='unique_document_team', violation_error_message='This team is already in this document.'),
),
migrations.AddConstraint(
model_name='identity',
constraint=models.UniqueConstraint(fields=('user', 'email'), name='unique_user_email', violation_error_message='This email address is already declared for this user.'),
model_name='documentaccess',
constraint=models.CheckConstraint(check=models.Q(models.Q(('team', ''), ('user__isnull', False)), models.Q(('team__gt', ''), ('user__isnull', True)), _connector='OR'), name='check_document_access_either_user_or_team', violation_error_message='Either user or team must be set, not both.'),
),
migrations.AddConstraint(
model_name='invitation',
constraint=models.UniqueConstraint(fields=('email', 'team'), name='email_and_team_unique_together'),
constraint=models.UniqueConstraint(fields=('email', 'document'), name='email_and_document_unique_together'),
),
migrations.AddConstraint(
model_name='teamaccess',
constraint=models.UniqueConstraint(fields=('user', 'team'), name='unique_team_user', violation_error_message='This user is already in this team.'),
model_name='templateaccess',
constraint=models.UniqueConstraint(condition=models.Q(('user__isnull', False)), fields=('user', 'template'), name='unique_template_user', violation_error_message='This user is already in this template.'),
),
migrations.AddConstraint(
model_name='templateaccess',
constraint=models.UniqueConstraint(condition=models.Q(('team__gt', '')), fields=('team', 'template'), name='unique_template_team', violation_error_message='This team is already in this template.'),
),
migrations.AddConstraint(
model_name='templateaccess',
constraint=models.CheckConstraint(check=models.Q(models.Q(('team', ''), ('user__isnull', False)), models.Q(('team__gt', ''), ('user__isnull', True)), _connector='OR'), name='check_template_access_either_user_or_team', violation_error_message='Either user or team must be set, not both.'),
),
]
@@ -0,0 +1,14 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('core', '0001_initial'),
]
operations = [
migrations.RunSQL(
"CREATE EXTENSION IF NOT EXISTS pg_trgm;",
reverse_sql="DROP EXTENSION IF EXISTS pg_trgm;",
),
]
@@ -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.')],
},
),
]
@@ -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
),
]
@@ -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'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.1 on 2024-09-12 13:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0005_remove_document_is_public_alter_document_link_reach_and_more'),
]
operations = [
migrations.AddField(
model_name='document',
name='is_e2ee',
field=models.BooleanField(default=False),
),
]
+635 -439
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.
@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html>
<head>
<title>Generate Document</title>
</head>
<body>
<h2>Generate Document</h2>
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_p }}
<button type="submit">Generate PDF</button>
</form>
</body>
</html>
@@ -6,7 +6,7 @@ import pytest
from core import models
from core.authentication.backends import OIDCAuthenticationBackend
from core.factories import IdentityFactory
from core.factories import UserFactory
pytestmark = pytest.mark.django_db
@@ -19,16 +19,10 @@ def test_authentication_getter_existing_user_no_email(
"""
klass = OIDCAuthenticationBackend()
# Create a user and its identity
identity = IdentityFactory(name=None)
# Create multiple identities for a user
for _ in range(5):
IdentityFactory(user=identity.user)
db_user = UserFactory()
def get_userinfo_mocked(*args):
return {"sub": identity.sub}
return {"sub": db_user.sub}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
@@ -37,95 +31,7 @@ def test_authentication_getter_existing_user_no_email(
access_token="test-token", id_token=None, payload=None
)
identity.refresh_from_db()
assert user == identity.user
def test_authentication_getter_existing_user_with_email(
django_assert_num_queries, monkeypatch
):
"""
When the user's info contains an email and targets an existing user,
"""
klass = OIDCAuthenticationBackend()
identity = IdentityFactory(name="John Doe")
# Create multiple identities for a user
for _ in range(5):
IdentityFactory(user=identity.user)
assert models.User.objects.count() == 1
def get_userinfo_mocked(*args):
return {
"sub": identity.sub,
"email": identity.email,
"first_name": "John",
"last_name": "Doe",
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
# Only 1 query because email and names have not changed
with django_assert_num_queries(1):
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert models.User.objects.get() == user
@pytest.mark.parametrize(
"first_name, last_name, email",
[
("Jack", "Doe", "john.doe@example.com"),
("John", "Duy", "john.doe@example.com"),
("John", "Doe", "jack.duy@example.com"),
("Jack", "Duy", "jack.duy@example.com"),
],
)
def test_authentication_getter_existing_user_change_fields(
first_name, last_name, email, django_assert_num_queries, monkeypatch
):
"""
It should update the email or name fields on the identity when they change.
The email on the user should not be changed.
"""
klass = OIDCAuthenticationBackend()
identity = IdentityFactory(name="John Doe", email="john.doe@example.com")
user_email = identity.user.admin_email
# Create multiple identities for a user
for _ in range(5):
IdentityFactory(user=identity.user)
assert models.User.objects.count() == 1
def get_userinfo_mocked(*args):
return {
"sub": identity.sub,
"email": email,
"first_name": first_name,
"last_name": last_name,
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
# One and only one additional update query when a field has changed
with django_assert_num_queries(2):
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
identity.refresh_from_db()
assert identity.email == email
assert identity.name == f"{first_name:s} {last_name:s}"
assert models.User.objects.count() == 1
assert user == identity.user
assert user.admin_email == user_email
assert user == db_user
def test_authentication_getter_new_user_no_email(monkeypatch):
@@ -144,11 +50,8 @@ def test_authentication_getter_new_user_no_email(monkeypatch):
access_token="test-token", id_token=None, payload=None
)
identity = user.identities.get()
assert identity.sub == "123"
assert identity.email is None
assert user.admin_email is None
assert user.sub == "123"
assert user.email is None
assert user.password == "!"
assert models.User.objects.count() == 1
@@ -161,7 +64,7 @@ def test_authentication_getter_new_user_with_email(monkeypatch):
"""
klass = OIDCAuthenticationBackend()
email = "people@example.com"
email = "impress@example.com"
def get_userinfo_mocked(*args):
return {"sub": "123", "email": email, "first_name": "John", "last_name": "Doe"}
@@ -172,12 +75,9 @@ def test_authentication_getter_new_user_with_email(monkeypatch):
access_token="test-token", id_token=None, payload=None
)
identity = user.identities.get()
assert identity.sub == "123"
assert identity.email == email
assert identity.name == "John Doe"
assert user.admin_email is None
assert user.sub == "123"
assert user.email == email
assert user.password == "!"
assert models.User.objects.count() == 1
@@ -38,8 +38,7 @@ def test_view_logout_anonymous():
def test_view_logout(mocked_oidc_logout_url):
"""Authenticated users should be redirected to OIDC provider for logout."""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -60,8 +59,7 @@ def test_view_logout(mocked_oidc_logout_url):
def test_view_logout_no_oidc_provider(mocked_oidc_logout_url):
"""Authenticated users should be logged out when no OIDC provider is available."""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -96,8 +94,7 @@ def test_view_logout_callback_anonymous():
def test_view_logout_persist_state(initial_oidc_states):
"""State value should be persisted in session's data."""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
@@ -128,8 +125,7 @@ def test_view_logout_construct_oidc_logout_url(
):
"""Should construct the logout URL to initiate the logout flow with the OIDC provider."""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
@@ -159,8 +155,7 @@ def test_view_logout_construct_oidc_logout_url_none_id_token():
"""If no ID token is available in the session,
the user should be redirected to the final URL."""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
@@ -180,8 +175,7 @@ def test_view_logout_construct_oidc_logout_url_none_id_token():
def test_view_logout_callback_wrong_state(initial_state):
"""Should raise an error if OIDC state doesn't match session data."""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
@@ -207,8 +201,7 @@ def test_view_logout_callback_wrong_state(initial_state):
def test_view_logout_callback():
"""If state matches, callback should clear OIDC state and redirects."""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
request = RequestFactory().get("/logout-callback/", data={"state": "mocked_state"})
request.user = user
+18
View File
@@ -0,0 +1,18 @@
"""Fixtures for tests in the impress core application"""
from unittest import mock
import pytest
USER = "user"
TEAM = "team"
VIA = [USER, TEAM]
@pytest.fixture
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
@@ -0,0 +1,792 @@
"""
Test document accesses API endpoints for users in impress's core app.
"""
import random
from uuid import uuid4
import pytest
from rest_framework.test import APIClient
from core import factories, models
from core.api import serializers
from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
def test_api_document_accesses_list_anonymous():
"""Anonymous users should not be allowed to list document accesses."""
document = factories.DocumentFactory()
factories.UserDocumentAccessFactory.create_batch(2, document=document)
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/accesses/")
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_document_accesses_list_authenticated_unrelated():
"""
Authenticated users should not be allowed to list document accesses for a document
to which they are not related.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
factories.UserDocumentAccessFactory.create_batch(3, document=document)
# Accesses for other documents to which the user is related should not be listed either
other_access = factories.UserDocumentAccessFactory(user=user)
factories.UserDocumentAccessFactory(document=other_access.document)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/accesses/",
)
assert response.status_code == 200
assert response.json() == {
"count": 0,
"next": None,
"previous": None,
"results": [],
}
@pytest.mark.parametrize("via", VIA)
def test_api_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.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
user_access = None
if via == USER:
user_access = models.DocumentAccess.objects.create(
document=document,
user=user,
role=random.choice(models.RoleChoices.choices)[0],
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
user_access = models.DocumentAccess.objects.create(
document=document,
team="lasuite",
role=random.choice(models.RoleChoices.choices)[0],
)
access1 = factories.TeamDocumentAccessFactory(document=document)
access2 = factories.UserDocumentAccessFactory(document=document)
# Accesses for other documents to which the user is related should not be listed either
other_access = factories.UserDocumentAccessFactory(user=user)
factories.UserDocumentAccessFactory(document=other_access.document)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/accesses/",
)
access2_user = serializers.UserSerializer(instance=access2.user).data
base_user = serializers.UserSerializer(instance=user).data
assert response.status_code == 200
content = response.json()
assert len(content["results"]) == 3
assert sorted(content["results"], key=lambda x: x["id"]) == sorted(
[
{
"id": str(user_access.id),
"user": base_user if via == "user" else None,
"team": "lasuite" if via == "team" else "",
"role": user_access.role,
"abilities": user_access.get_abilities(user),
},
{
"id": str(access1.id),
"user": None,
"team": access1.team,
"role": access1.role,
"abilities": access1.get_abilities(user),
},
{
"id": str(access2.id),
"user": access2_user,
"team": "",
"role": access2.role,
"abilities": access2.get_abilities(user),
},
],
key=lambda x: x["id"],
)
def test_api_document_accesses_retrieve_anonymous():
"""
Anonymous users should not be allowed to retrieve a document access.
"""
access = factories.UserDocumentAccessFactory()
response = APIClient().get(
f"/api/v1.0/documents/{access.document_id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_document_accesses_retrieve_authenticated_unrelated():
"""
Authenticated users should not be allowed to retrieve a document access for
a document to which they are not related.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
access = factories.UserDocumentAccessFactory(document=document)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
# Accesses related to another document should be excluded even if the user is related to it
for access in [
factories.UserDocumentAccessFactory(),
factories.UserDocumentAccessFactory(user=user),
]:
response = client.get(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 404
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_teams):
"""
A user who is related to a document should be allowed to retrieve the
associated document user accesses.
"""
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")
access = factories.UserDocumentAccessFactory(document=document)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
)
access_user = serializers.UserSerializer(instance=access.user).data
assert response.status_code == 200
assert response.json() == {
"id": str(access.id),
"user": access_user,
"team": "",
"role": access.role,
"abilities": 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()
old_values = serializers.DocumentAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
}
api_client = APIClient()
for field, value in new_values.items():
response = api_client.put(
f"/api/v1.0/documents/{access.document_id!s}/accesses/{access.id!s}/",
{**old_values, field: value},
format="json",
)
assert response.status_code == 401
access.refresh_from_db()
updated_values = serializers.DocumentAccessSerializer(instance=access).data
assert updated_values == old_values
def test_api_document_accesses_update_authenticated_unrelated():
"""
Authenticated users should not be allowed to update a document access for a document to which
they are not related.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
access = factories.UserDocumentAccessFactory()
old_values = serializers.DocumentAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
}
for field, value in new_values.items():
response = client.put(
f"/api/v1.0/documents/{access.document_id!s}/accesses/{access.id!s}/",
{**old_values, field: value},
format="json",
)
assert response.status_code == 403
access.refresh_from_db()
updated_values = serializers.DocumentAccessSerializer(instance=access).data
assert updated_values == old_values
@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_teams
):
"""Readers or editors of a document should not be allowed to update its 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
)
access = factories.UserDocumentAccessFactory(document=document)
old_values = serializers.DocumentAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
}
for field, value in new_values.items():
response = client.put(
f"/api/v1.0/documents/{access.document_id!s}/accesses/{access.id!s}/",
{**old_values, field: value},
format="json",
)
assert response.status_code == 403
access.refresh_from_db()
updated_values = serializers.DocumentAccessSerializer(instance=access).data
assert updated_values == old_values
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_update_administrator_except_owner(via, mock_user_teams):
"""
A user who is a direct administrator in a document should be allowed to update a user
access for this document, as long as they don't try to set the role to owner.
"""
user = factories.UserFactory()
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"
)
access = factories.UserDocumentAccessFactory(
document=document,
role=random.choice(["administrator", "editor", "reader"]),
)
old_values = serializers.DocumentAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(["administrator", "editor", "reader"]),
}
for field, value in new_values.items():
new_data = {**old_values, field: value}
response = client.put(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
)
if (
new_data["role"] == old_values["role"]
): # we are not really updating the role
assert response.status_code == 403
else:
assert response.status_code == 200
access.refresh_from_db()
updated_values = serializers.DocumentAccessSerializer(instance=access).data
if field == "role":
assert updated_values == {**old_values, "role": new_values["role"]}
else:
assert updated_values == old_values
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_update_administrator_from_owner(via, mock_user_teams):
"""
A user who is an administrator in a document, should not be allowed to update
the user access of an "owner" for this document.
"""
user = factories.UserFactory()
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()
access = factories.UserDocumentAccessFactory(
document=document, user=other_user, role="owner"
)
old_values = serializers.DocumentAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
}
for field, value in new_values.items():
response = client.put(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
data={**old_values, field: value},
format="json",
)
assert response.status_code == 403
access.refresh_from_db()
updated_values = serializers.DocumentAccessSerializer(instance=access).data
assert updated_values == old_values
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_update_administrator_to_owner(via, mock_user_teams):
"""
A user who is an administrator in a document, should not be allowed to update
the user access of another user to grant document ownership.
"""
user = factories.UserFactory()
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()
access = factories.UserDocumentAccessFactory(
document=document,
user=other_user,
role=random.choice(["administrator", "editor", "reader"]),
)
old_values = serializers.DocumentAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": "owner",
}
for field, value in new_values.items():
new_data = {**old_values, field: value}
response = client.put(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
)
# We are not allowed or not really updating the role
if field == "role" or new_data["role"] == old_values["role"]:
assert response.status_code == 403
else:
assert response.status_code == 200
access.refresh_from_db()
updated_values = serializers.DocumentAccessSerializer(instance=access).data
assert updated_values == old_values
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_update_owner(via, mock_user_teams):
"""
A user who is an owner in a document should be allowed to update
a user access for this document whatever the role.
"""
user = factories.UserFactory()
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"
)
factories.UserFactory()
access = factories.UserDocumentAccessFactory(
document=document,
)
old_values = serializers.DocumentAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
}
for field, value in new_values.items():
new_data = {**old_values, field: value}
response = client.put(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
)
if (
new_data["role"] == old_values["role"]
): # we are not really updating the role
assert response.status_code == 403
else:
assert response.status_code == 200
access.refresh_from_db()
updated_values = serializers.DocumentAccessSerializer(instance=access).data
if field == "role":
assert updated_values == {**old_values, "role": new_values["role"]}
else:
assert updated_values == old_values
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_update_owner_self(via, mock_user_teams):
"""
A user who is owner of a document should be allowed to update
their own user access provided there are other owners in the document.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
access = None
if via == USER:
access = factories.UserDocumentAccessFactory(
document=document, user=user, role="owner"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
access = factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="owner"
)
old_values = serializers.DocumentAccessSerializer(instance=access).data
new_role = random.choice(["administrator", "editor", "reader"])
response = client.put(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
data={**old_values, "role": new_role},
format="json",
)
assert response.status_code == 403
access.refresh_from_db()
assert access.role == "owner"
# Add another owner and it should now work
factories.UserDocumentAccessFactory(document=document, role="owner")
response = client.put(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
data={
**old_values,
"role": new_role,
"user_id": old_values.get("user", {}).get("id")
if old_values.get("user") is not None
else None,
},
format="json",
)
assert response.status_code == 200
access.refresh_from_db()
assert access.role == new_role
# Delete
def test_api_document_accesses_delete_anonymous():
"""Anonymous users should not be allowed to destroy a document access."""
access = factories.UserDocumentAccessFactory()
response = APIClient().delete(
f"/api/v1.0/documents/{access.document_id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 401
assert models.DocumentAccess.objects.count() == 1
def test_api_document_accesses_delete_authenticated():
"""
Authenticated users should not be allowed to delete a document access for a
document to which they are not related.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
access = factories.UserDocumentAccessFactory()
response = client.delete(
f"/api/v1.0/documents/{access.document_id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 403
assert models.DocumentAccess.objects.count() == 1
@pytest.mark.parametrize("role", ["reader", "editor"])
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_delete_reader_or_editor(via, role, mock_user_teams):
"""
Authenticated users should not be allowed to delete a document access for a
document in which they are a simple reader or editor.
"""
user = factories.UserFactory()
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
)
access = factories.UserDocumentAccessFactory(document=document)
assert models.DocumentAccess.objects.count() == 2
assert models.DocumentAccess.objects.filter(user=access.user).exists()
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 403
assert models.DocumentAccess.objects.count() == 2
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_delete_administrators_except_owners(
via, mock_user_teams
):
"""
Users who are administrators in a document should be allowed to delete an access
from the document provided it is not ownership.
"""
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"
)
access = factories.UserDocumentAccessFactory(
document=document, role=random.choice(["reader", "editor", "administrator"])
)
assert models.DocumentAccess.objects.count() == 2
assert models.DocumentAccess.objects.filter(user=access.user).exists()
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 204
assert models.DocumentAccess.objects.count() == 1
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_delete_administrator_on_owners(via, mock_user_teams):
"""
Users who are administrators in a document should not be allowed to delete an ownership
access from the document.
"""
user = factories.UserFactory()
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"
)
access = factories.UserDocumentAccessFactory(document=document, role="owner")
assert models.DocumentAccess.objects.count() == 2
assert models.DocumentAccess.objects.filter(user=access.user).exists()
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 403
assert models.DocumentAccess.objects.count() == 2
@pytest.mark.parametrize("via", VIA)
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.
"""
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"
)
access = factories.UserDocumentAccessFactory(document=document)
assert models.DocumentAccess.objects.count() == 2
assert models.DocumentAccess.objects.filter(user=access.user).exists()
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 204
assert models.DocumentAccess.objects.count() == 1
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_delete_owners_last_owner(via, mock_user_teams):
"""
It should not be possible to delete the last owner access from a document
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
access = None
if via == USER:
access = factories.UserDocumentAccessFactory(
document=document, user=user, role="owner"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
access = factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="owner"
)
assert models.DocumentAccess.objects.count() == 1
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 403
assert models.DocumentAccess.objects.count() == 1
@@ -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
@@ -0,0 +1,658 @@
"""
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
from core import factories, models
from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
def test_api_document_invitations__create__anonymous():
"""Anonymous users should not be able to create invitations."""
document = factories.DocumentFactory()
invitation_values = {
"email": "guest@example.com",
"role": random.choice(models.RoleChoices.choices)[0],
}
response = APIClient().post(
f"/api/v1.0/documents/{document.id}/invitations/",
invitation_values,
format="json",
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_document_invitations__create__authenticated_outsider():
"""Users outside of document should not be permitted to invite to document."""
user = factories.UserFactory()
document = factories.DocumentFactory()
invitation_values = {
"email": "guest@example.com",
"role": random.choice(models.RoleChoices.choices)[0],
}
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id}/invitations/",
invitation_values,
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
@pytest.mark.parametrize(
"inviting,invited,is_allowed",
(
["reader", "reader", False],
["reader", "editor", False],
["reader", "administrator", False],
["reader", "owner", False],
["editor", "reader", False],
["editor", "editor", False],
["editor", "administrator", False],
["editor", "owner", False],
["administrator", "reader", True],
["administrator", "editor", True],
["administrator", "administrator", True],
["administrator", "owner", False],
["owner", "reader", True],
["owner", "editor", True],
["owner", "administrator", True],
["owner", "owner", True],
),
)
@pytest.mark.parametrize("via", VIA)
def test_api_document_invitations__create__privileged_members(
via, inviting, invited, is_allowed, mock_user_teams
):
"""
Only owners and administrators should be able to invite new users.
Only owners can invite owners.
"""
user = factories.UserFactory()
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=inviting)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=inviting
)
invitation_values = {
"email": "guest@example.com",
"role": invited,
}
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",
)
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()
document = factories.DocumentFactory(users=[(user, "owner")])
other_document = factories.DocumentFactory(users=[(user, "owner")])
invitation_values = {
"document": str(other_document.id),
"issuer": str(factories.UserFactory().id),
"email": "guest@example.com",
"role": random.choice(models.RoleChoices.choices)[0],
}
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id}/invitations/",
invitation_values,
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
# document and issuer automatically set
assert response.json()["document"] == str(document.id)
assert response.json()["issuer"] == str(user.id)
def test_api_document_invitations__create__cannot_duplicate_invitation():
"""An email should not be invited multiple times to the same document."""
existing_invitation = factories.InvitationFactory()
document = existing_invitation.document
# Grant privileged role on the Document to the user
user = factories.UserFactory()
models.DocumentAccess.objects.create(
document=document, user=user, role="administrator"
)
# Create a new invitation to the same document with the exact same email address
invitation_values = {
"email": existing_invitation.email,
"role": random.choice(["administrator", "editor", "reader"]),
}
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id}/invitations/",
invitation_values,
format="json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json() == [
"Document invitation with this Email address and Document already exists."
]
def test_api_document_invitations__create__cannot_invite_existing_users():
"""
It should not be possible to invite already existing users.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(users=[(user, "owner")])
existing_user = factories.UserFactory()
# Build an invitation to the email of an exising identity in the db
invitation_values = {
"email": existing_user.email,
"role": random.choice(models.RoleChoices.choices)[0],
}
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id}/invitations/",
invitation_values,
format="json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json() == ["This email is already associated to a registered user."]
def test_api_document_invitations__list__anonymous_user():
"""Anonymous users should not be able to list invitations."""
document = factories.DocumentFactory()
response = APIClient().get(f"/api/v1.0/documents/{document.id}/invitations/")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@pytest.mark.parametrize("via", VIA)
def test_api_document_invitations__list__authenticated(
via, mock_user_teams, django_assert_num_queries
):
"""
Authenticated users should be able to list invitations for documents to which they are
related, whatever the role and including invitations issued by other users.
"""
user = factories.UserFactory()
other_user = factories.UserFactory()
document = factories.DocumentFactory()
role = random.choice(models.RoleChoices.choices)[0]
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
)
invitation = factories.InvitationFactory(
document=document, role="administrator", issuer=user
)
other_invitations = factories.InvitationFactory.create_batch(
2, document=document, role="reader", issuer=other_user
)
# invitations from other documents should not be listed
other_document = factories.DocumentFactory()
factories.InvitationFactory.create_batch(2, document=other_document, role="reader")
client = APIClient()
client.force_login(user)
with django_assert_num_queries(3):
response = client.get(
f"/api/v1.0/documents/{document.id}/invitations/",
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["count"] == 3
assert sorted(response.json()["results"], key=lambda x: x["created_at"]) == sorted(
[
{
"id": str(i.id),
"created_at": i.created_at.isoformat().replace("+00:00", "Z"),
"email": str(i.email),
"document": str(document.id),
"role": i.role,
"issuer": str(i.issuer.id),
"is_expired": False,
"abilities": {
"destroy": role in ["administrator", "owner"],
"update": role in ["administrator", "owner"],
"partial_update": role in ["administrator", "owner"],
"retrieve": True,
},
}
for i in [invitation, *other_invitations]
],
key=lambda x: x["created_at"],
)
def test_api_document_invitations__list__expired_invitations_still_listed(settings):
"""
Expired invitations are still listed.
"""
user = factories.UserFactory()
other_user = factories.UserFactory()
document = factories.DocumentFactory(
users=[(user, "administrator"), (other_user, "owner")]
)
# override settings to accelerate validation expiration
settings.INVITATION_VALIDITY_DURATION = 1 # second
expired_invitation = factories.InvitationFactory(
document=document,
role="reader",
issuer=user,
)
time.sleep(1)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/documents/{document.id}/invitations/",
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["count"] == 1
assert sorted(response.json()["results"], key=lambda x: x["created_at"]) == sorted(
[
{
"id": str(expired_invitation.id),
"created_at": expired_invitation.created_at.isoformat().replace(
"+00:00", "Z"
),
"email": str(expired_invitation.email),
"document": str(document.id),
"role": expired_invitation.role,
"issuer": str(expired_invitation.issuer.id),
"is_expired": True,
"abilities": {
"destroy": True,
"update": True,
"partial_update": True,
"retrieve": True,
},
},
],
key=lambda x: x["created_at"],
)
def test_api_document_invitations__retrieve__anonymous_user():
"""
Anonymous users should not be able to retrieve invitations.
"""
invitation = factories.InvitationFactory()
response = APIClient().get(
f"/api/v1.0/documents/{invitation.document.id}/invitations/{invitation.id}/",
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_api_document_invitations__retrieve__unrelated_user():
"""
Authenticated unrelated users should not be able to retrieve invitations.
"""
user = factories.UserFactory()
invitation = factories.InvitationFactory()
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/documents/{invitation.document.id!s}/invitations/{invitation.id!s}/",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
@pytest.mark.parametrize("via", VIA)
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.
"""
user = factories.UserFactory()
invitation = factories.InvitationFactory()
role = random.choice(models.RoleChoices.choices)[0]
if via == USER:
factories.UserDocumentAccessFactory(
document=invitation.document, user=user, role=role
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=invitation.document, team="lasuite", role=role
)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/documents/{invitation.document.id}/invitations/{invitation.id}/",
)
assert response.status_code == status.HTTP_200_OK
assert response.json() == {
"id": str(invitation.id),
"created_at": invitation.created_at.isoformat().replace("+00:00", "Z"),
"email": invitation.email,
"document": str(invitation.document.id),
"role": str(invitation.role),
"issuer": str(invitation.issuer.id),
"is_expired": False,
"abilities": {
"destroy": role in ["administrator", "owner"],
"update": role in ["administrator", "owner"],
"partial_update": role in ["administrator", "owner"],
"retrieve": True,
},
}
@pytest.mark.parametrize("via", VIA)
def test_api_document_invitations__put_authenticated(via, mock_user_teams):
"""
Authenticated user can put invitations.
"""
user = factories.UserFactory()
invitation = factories.InvitationFactory()
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"
)
client = APIClient()
client.force_login(user)
url = f"/api/v1.0/documents/{invitation.document.id}/invitations/{invitation.id}/"
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_403_FORBIDDEN
assert (
response.json()["detail"]
== "You do not have permission to perform this action."
)
def test_api_document_invitations__delete__anonymous():
"""Anonymous user should not be able to delete invitations."""
invitation = factories.InvitationFactory()
response = APIClient().delete(
f"/api/v1.0/documents/{invitation.document.id}/invitations/{invitation.id}/",
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_api_document_invitations__delete__authenticated_outsider():
"""Members unrelated to a document should not be allowed to cancel invitations."""
user = factories.UserFactory()
document = factories.DocumentFactory()
invitation = factories.InvitationFactory(document=document)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/documents/{document.id}/invitations/{invitation.id}/",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
@pytest.mark.parametrize("via", VIA)
@pytest.mark.parametrize("role", ["owner", "administrator"])
def test_api_document_invitations__delete__privileged_members(
role, via, mock_user_teams
):
"""Privileged member should 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_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
)
invitation = factories.InvitationFactory(document=document)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/documents/{document.id}/invitations/{invitation.id}/",
)
assert response.status_code == status.HTTP_204_NO_CONTENT
@pytest.mark.parametrize("role", ["reader", "editor"])
@pytest.mark.parametrize("via", VIA)
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_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
)
invitation = factories.InvitationFactory(document=document)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/documents/{document.id}/invitations/{invitation.id}/",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert (
response.json()["detail"]
== "You do not have permission to perform this action."
)
@@ -0,0 +1,444 @@
"""
Test document versions API endpoints for users in impress's core app.
"""
import random
import time
import pytest
from rest_framework.test import APIClient
from core import factories, models
from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
@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 document
whatever the reach and role.
"""
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 == 403
assert response.json() == {"detail": "Authentication required."}
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
def test_api_document_versions_list_authenticated_unrelated(reach):
"""
Authenticated users should not be allowed to list document versions for a document
to which they are not related.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
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
factories.UserDocumentAccessFactory(user=user)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/versions/",
)
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_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.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
if via == USER:
models.DocumentAccess.objects.create(
document=document,
user=user,
role=random.choice(models.RoleChoices.choices)[0],
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
models.DocumentAccess.objects.create(
document=document,
team="lasuite",
role=random.choice(models.RoleChoices.choices)[0],
)
# Other versions of documents to which the user has access should not be listed
factories.UserDocumentAccessFactory(user=user)
# A version created before the user got access should be hidden
response = client.get(
f"/api/v1.0/documents/{document.id!s}/versions/",
)
assert response.status_code == 200
content = response.json()
assert len(content["results"]) == 0
assert content["count"] == 0
# Add a new version to the document
document.content = "new content"
document.save()
response = client.get(
f"/api/v1.0/documents/{document.id!s}/versions/",
)
assert response.status_code == 200
content = response.json()
assert len(content["results"]) == 1
assert content["count"] == 1
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
def test_api_document_versions_retrieve_anonymous(reach):
"""
Anonymous users should not be allowed to find specific versions for a document with
restricted or authenticated link reach.
"""
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}/"
response = APIClient().get(url)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
def test_api_document_versions_retrieve_authenticated_unrelated(reach):
"""
Authenticated users should not be allowed to retrieve specific versions for a
document to which they are not related.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach)
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 == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
@pytest.mark.parametrize("via", VIA)
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.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
version_id = document.get_versions_slice()["versions"][0]["version_id"]
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(document=document, team="lasuite")
# Versions created before the document was shared should not be seen by the user
response = client.get(
f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/",
)
assert response.status_code == 404
# Create a new version should make it available to the user
time.sleep(1) # minio stores datetimes with the precision of a second
document.content = "new content"
document.save()
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 == 200
assert response.json()["content"] == "new content"
def test_api_document_versions_create_anonymous():
"""Anonymous users should not be allowed to create document versions."""
document = factories.DocumentFactory()
response = APIClient().post(
f"/api/v1.0/documents/{document.id!s}/versions/",
{"foo": "bar"},
format="json",
)
assert response.status_code == 405
assert response.json() == {"detail": 'Method "POST" not allowed.'}
def test_api_document_versions_create_authenticated_unrelated():
"""
Authenticated users should not be allowed to create document versions for a document to
which they are not related.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
response = client.post(
f"/api/v1.0/documents/{document.id!s}/versions/",
{"foo": "bar"},
format="json",
)
assert response.status_code == 405
@pytest.mark.parametrize("via", VIA)
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.
"""
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")
response = client.post(
f"/api/v1.0/documents/{document.id!s}/versions/",
{"foo": "bar"},
format="json",
)
assert response.status_code == 405
def test_api_document_versions_update_anonymous():
"""Anonymous users should not be allowed to update a document version."""
access = factories.UserDocumentAccessFactory()
version_id = access.document.get_versions_slice()["versions"][0]["version_id"]
response = APIClient().put(
f"/api/v1.0/documents/{access.document_id!s}/versions/{version_id:s}/",
{"foo": "bar"},
format="json",
)
assert response.status_code == 405
def test_api_document_versions_update_authenticated_unrelated():
"""
Authenticated users should not be allowed to update a document version for a document to which
they are not related.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
access = factories.UserDocumentAccessFactory()
version_id = access.document.get_versions_slice()["versions"][0]["version_id"]
response = client.put(
f"/api/v1.0/documents/{access.document_id!s}/versions/{version_id:s}/",
{"foo": "bar"},
format="json",
)
assert response.status_code == 405
@pytest.mark.parametrize("via", VIA)
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.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
version_id = document.get_versions_slice()["versions"][0]["version_id"]
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(document=document, team="lasuite")
response = client.put(
f"/api/v1.0/documents/{document.id!s}/versions/{version_id!s}/",
{"foo": "bar"},
format="json",
)
assert response.status_code == 405
# Delete
def test_api_document_versions_delete_anonymous():
"""Anonymous users should not be allowed to destroy a document version."""
access = factories.UserDocumentAccessFactory()
response = APIClient().delete(
f"/api/v1.0/documents/{access.document_id!s}/versions/{access.id!s}/",
)
assert response.status_code == 401
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
def test_api_document_versions_delete_authenticated(reach):
"""
Authenticated users should not be allowed to delete a document version for a
public document to which they are not related.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach)
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 == 403
@pytest.mark.parametrize("role", ["reader", "editor"])
@pytest.mark.parametrize("via", VIA)
def test_api_document_versions_delete_reader_or_editor(via, role, mock_user_teams):
"""
Authenticated users should not be allowed to delete a document version for a
document in which they are a simple reader or editor.
"""
user = factories.UserFactory()
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
)
# Create a new version should make it available to the user
time.sleep(1) # minio stores datetimes with the precision of a second
document.content = "new content"
document.save()
versions = document.get_versions_slice()["versions"]
assert len(versions) == 2
version_id = versions[1]["version_id"]
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/",
)
assert response.status_code == 403
version_id = versions[0]["version_id"]
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/",
)
assert response.status_code == 403
versions = document.get_versions_slice()["versions"]
assert len(versions) == 2
@pytest.mark.parametrize("via", VIA)
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.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
role = random.choice(["administrator", "owner"])
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
)
# Create a new version should make it available to the user
time.sleep(1) # minio stores datetimes with the precision of a second
document.content = "new content"
document.save()
versions = document.get_versions_slice()["versions"]
assert len(versions) == 2
version_id = versions[1]["version_id"]
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/",
)
# 404 because the version was created before the user was given access to the document
assert response.status_code == 404
version_id = versions[0]["version_id"]
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/",
)
assert response.status_code == 204
versions = document.get_versions_slice()["versions"]
assert len(versions) == 1
@@ -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"
]
}
@@ -0,0 +1,113 @@
"""
Tests for Documents API endpoint in impress's core app: create
"""
from uuid import uuid4
import pytest
from rest_framework.test import APIClient
from core import factories
from core.models import Document
pytestmark = pytest.mark.django_db
def test_api_documents_create_anonymous():
"""Anonymous users should not be allowed to create documents."""
response = APIClient().post(
"/api/v1.0/documents/",
{
"title": "my document",
},
)
assert response.status_code == 401
assert not Document.objects.exists()
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.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/documents/",
{
"title": "my document",
},
format="json",
)
assert response.status_code == 201
document = Document.objects.get()
assert document.title == "my document"
assert document.accesses.filter(role="owner", user=user).exists()
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)
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/",
{
"id": str(forced_id),
"title": "my document",
},
format="json",
)
assert response.status_code == 201
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."]
}
@@ -0,0 +1,104 @@
"""
Tests for Documents API endpoint in impress's core app: delete
"""
import pytest
from rest_framework.test import APIClient
from core import factories, models
from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
def test_api_documents_delete_anonymous():
"""Anonymous users should not be allowed to destroy a document."""
document = factories.DocumentFactory()
response = APIClient().delete(
f"/api/v1.0/documents/{document.id!s}/",
)
assert response.status_code == 401
assert models.Document.objects.count() == 1
@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.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
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
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_teams):
"""
Authenticated users should not be allowed to delete a document for which they are
only a reader, editor or administrator.
"""
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
)
response = client.delete(
f"/api/v1.0/documents/{document.id}/",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
assert models.Document.objects.count() == 1
@pytest.mark.parametrize("via", VIA)
def test_api_documents_delete_authenticated_owner(via, mock_user_teams):
"""
Authenticated users should be able to delete a document they own.
"""
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"
)
response = client.delete(
f"/api/v1.0/documents/{document.id}/",
)
assert response.status_code == 204
assert models.Document.objects.exists() is False
@@ -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]
@@ -0,0 +1,274 @@
"""
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.test import APIClient
from core import factories, models
fake = Faker()
pytestmark = pytest.mark.django_db
@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 == 200
results = response.json()["results"]
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 or documents that have a link reach other
than restricted.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
documents = [
access.document
for access in factories.UserDocumentAccessFactory.create_batch(2, user=user)
]
# 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 == 200
results = response.json()["results"]
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_teams):
"""
Authenticated users should be able to list documents they are a
owner/administrator/member of via a team.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
mock_user_teams.return_value = ["team1", "team2", "unknown"]
documents_team1 = [
access.document
for access in factories.TeamDocumentAccessFactory.create_batch(2, team="team1")
]
documents_team2 = [
access.document
for access in factories.TeamDocumentAccessFactory.create_batch(3, team="team2")
]
expected_ids = {str(document.id) for document in documents_team1 + documents_team2}
response = client.get("/api/v1.0/documents/")
assert response.status_code == 200
results = response.json()["results"]
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
@mock.patch.object(PageNumberPagination, "get_page_size", return_value=2)
def test_api_documents_list_pagination(
_mock_page_size,
):
"""Pagination should work as expected."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document_ids = [
str(access.document_id)
for access in factories.UserDocumentAccessFactory.create_batch(3, user=user)
]
# Get page 1
response = client.get(
"/api/v1.0/documents/",
)
assert response.status_code == 200
content = response.json()
assert content["count"] == 3
assert content["next"] == "http://testserver/api/v1.0/documents/?page=2"
assert content["previous"] is None
assert len(content["results"]) == 2
for item in content["results"]:
document_ids.remove(item["id"])
# Get page 2
response = client.get(
"/api/v1.0/documents/?page=2",
)
assert response.status_code == 200
content = response.json()
assert content["count"] == 3
assert content["next"] is None
assert content["previous"] == "http://testserver/api/v1.0/documents/"
assert len(content["results"]) == 1
document_ids.remove(content["results"][0]["id"])
assert document_ids == []
def test_api_documents_list_authenticated_distinct():
"""A document with several related users should only be listed once."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
other_user = factories.UserFactory()
document = factories.DocumentFactory(users=[user, other_user])
response = client.get(
"/api/v1.0/documents/",
)
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_list_ordering_default():
"""Documents should be ordered by descending "updated_at" by default"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(5, users=[user])
response = client.get("/api/v1.0/documents/")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 5
# 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_list_ordering_by_fields():
"""It should be possible to order by several fields"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(5, users=[user])
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(f"/api/v1.0/documents/{querystring:s}")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 5
# 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])
@@ -0,0 +1,589 @@
"""
Tests for Documents API endpoint in impress's core app: retrieve
"""
import pytest
from rest_framework.test import APIClient
from core import factories, models
from core.api import serializers
pytestmark = pytest.mark.django_db
def test_api_documents_retrieve_anonymous_public():
"""Anonymous users should be allowed to retrieve public documents."""
document = factories.DocumentFactory(link_reach="public")
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/")
assert response.status_code == 200
assert response.json() == {
"id": str(document.id),
"abilities": {
"attachment_upload": document.link_role == "editor",
"destroy": False,
"link_configuration": False,
"manage_accesses": False,
"partial_update": document.link_role == "editor",
"retrieve": True,
"update": document.link_role == "editor",
"versions_destroy": False,
"versions_list": False,
"versions_retrieve": False,
},
"accesses": [],
"is_e2ee": False,
"link_reach": "public",
"link_role": document.link_role,
"title": document.title,
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}
@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(link_reach=reach)
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/")
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
@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.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/",
)
assert response.status_code == 200
assert response.json() == {
"id": str(document.id),
"abilities": {
"attachment_upload": document.link_role == "editor",
"link_configuration": False,
"destroy": False,
"manage_accesses": False,
"partial_update": document.link_role == "editor",
"retrieve": True,
"update": document.link_role == "editor",
"versions_destroy": False,
"versions_list": False,
"versions_retrieve": False,
},
"accesses": [],
"is_e2ee": False,
"link_reach": reach,
"link_role": document.link_role,
"title": document.title,
"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
)
@pytest.mark.parametrize("reach", ["public", "authenticated"])
def test_api_documents_retrieve_authenticated_trace_twice(reach):
"""
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()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="restricted")
response = client.get(
f"/api/v1.0/documents/{document.id!s}/",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
def test_api_documents_retrieve_authenticated_related_direct():
"""
Authenticated users should be allowed to retrieve a document to which they
are directly related whatever the role.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
access1 = factories.UserDocumentAccessFactory(document=document, user=user)
access2 = factories.UserDocumentAccessFactory(document=document)
access1_user = serializers.UserSerializer(instance=user).data
access2_user = serializers.UserSerializer(instance=access2.user).data
response = client.get(
f"/api/v1.0/documents/{document.id!s}/",
)
assert response.status_code == 200
content = response.json()
assert sorted(content.pop("accesses"), key=lambda x: x["id"]) == sorted(
[
{
"id": str(access1.id),
"user": access1_user,
"team": "",
"role": access1.role,
"abilities": access1.get_abilities(user),
},
{
"id": str(access2.id),
"user": access2_user,
"team": "",
"role": access2.role,
"abilities": access2.get_abilities(user),
},
],
key=lambda x: x["id"],
)
assert response.json() == {
"id": str(document.id),
"title": document.title,
"content": document.content,
"abilities": document.get_abilities(user),
"is_e2ee": False,
"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_teams):
"""
Authenticated users should not be able to retrieve a restricted document related to
teams in which the user is not.
"""
mock_user_teams.return_value = []
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="restricted")
factories.TeamDocumentAccessFactory(
document=document, team="readers", role="reader"
)
factories.TeamDocumentAccessFactory(
document=document, team="editors", role="editor"
)
factories.TeamDocumentAccessFactory(
document=document, team="administrators", role="administrator"
)
factories.TeamDocumentAccessFactory(document=document, team="owners", role="owner")
factories.TeamDocumentAccessFactory(document=document)
factories.TeamDocumentAccessFactory()
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
@pytest.mark.parametrize(
"teams",
[
["readers"],
["unknown", "readers"],
["editors"],
["unknown", "editors"],
],
)
def test_api_documents_retrieve_authenticated_related_team_members(
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_teams.return_value = teams
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="restricted")
access_reader = factories.TeamDocumentAccessFactory(
document=document, team="readers", role="reader"
)
access_editor = factories.TeamDocumentAccessFactory(
document=document, team="editors", role="editor"
)
access_administrator = factories.TeamDocumentAccessFactory(
document=document, team="administrators", role="administrator"
)
access_owner = factories.TeamDocumentAccessFactory(
document=document, team="owners", role="owner"
)
other_access = factories.TeamDocumentAccessFactory(document=document)
factories.TeamDocumentAccessFactory()
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
# pylint: disable=R0801
assert response.status_code == 200
content = response.json()
expected_abilities = {
"destroy": False,
"retrieve": True,
"set_role_to": [],
"update": False,
"partial_update": False,
}
assert sorted(content.pop("accesses"), key=lambda x: x["id"]) == sorted(
[
{
"id": str(access_reader.id),
"user": None,
"team": "readers",
"role": access_reader.role,
"abilities": expected_abilities,
},
{
"id": str(access_editor.id),
"user": None,
"team": "editors",
"role": access_editor.role,
"abilities": expected_abilities,
},
{
"id": str(access_administrator.id),
"user": None,
"team": "administrators",
"role": access_administrator.role,
"abilities": expected_abilities,
},
{
"id": str(access_owner.id),
"user": None,
"team": "owners",
"role": access_owner.role,
"abilities": expected_abilities,
},
{
"id": str(other_access.id),
"user": None,
"team": other_access.team,
"role": other_access.role,
"abilities": expected_abilities,
},
],
key=lambda x: x["id"],
)
assert response.json() == {
"id": str(document.id),
"title": document.title,
"content": document.content,
"abilities": document.get_abilities(user),
"is_e2ee": 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"),
}
@pytest.mark.parametrize(
"teams",
[
["administrators"],
["editors", "administrators"],
["unknown", "administrators"],
],
)
def test_api_documents_retrieve_authenticated_related_team_administrators(
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_teams.return_value = teams
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="restricted")
access_reader = factories.TeamDocumentAccessFactory(
document=document, team="readers", role="reader"
)
access_editor = factories.TeamDocumentAccessFactory(
document=document, team="editors", role="editor"
)
access_administrator = factories.TeamDocumentAccessFactory(
document=document, team="administrators", role="administrator"
)
access_owner = factories.TeamDocumentAccessFactory(
document=document, team="owners", role="owner"
)
other_access = factories.TeamDocumentAccessFactory(document=document)
factories.TeamDocumentAccessFactory()
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
# pylint: disable=R0801
assert response.status_code == 200
content = response.json()
assert sorted(content.pop("accesses"), key=lambda x: x["id"]) == sorted(
[
{
"id": str(access_reader.id),
"user": None,
"team": "readers",
"role": "reader",
"abilities": {
"destroy": True,
"retrieve": True,
"set_role_to": ["administrator", "editor"],
"update": True,
"partial_update": True,
},
},
{
"id": str(access_editor.id),
"user": None,
"team": "editors",
"role": "editor",
"abilities": {
"destroy": True,
"retrieve": True,
"set_role_to": ["administrator", "reader"],
"update": True,
"partial_update": True,
},
},
{
"id": str(access_administrator.id),
"user": None,
"team": "administrators",
"role": "administrator",
"abilities": {
"destroy": True,
"retrieve": True,
"set_role_to": ["editor", "reader"],
"update": True,
"partial_update": True,
},
},
{
"id": str(access_owner.id),
"user": None,
"team": "owners",
"role": "owner",
"abilities": {
"destroy": False,
"retrieve": True,
"set_role_to": [],
"update": False,
"partial_update": False,
},
},
{
"id": str(other_access.id),
"user": None,
"team": other_access.team,
"role": other_access.role,
"abilities": other_access.get_abilities(user),
},
],
key=lambda x: x["id"],
)
assert response.json() == {
"id": str(document.id),
"title": document.title,
"content": document.content,
"abilities": document.get_abilities(user),
"is_e2ee": 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"),
}
@pytest.mark.parametrize(
"teams",
[
["owners"],
["owners", "administrators"],
["members", "administrators", "owners"],
["unknown", "owners"],
],
)
def test_api_documents_retrieve_authenticated_related_team_owners(
teams, mock_user_teams
):
"""
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_teams.return_value = teams
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="restricted")
access_reader = factories.TeamDocumentAccessFactory(
document=document, team="readers", role="reader"
)
access_editor = factories.TeamDocumentAccessFactory(
document=document, team="editors", role="editor"
)
access_administrator = factories.TeamDocumentAccessFactory(
document=document, team="administrators", role="administrator"
)
access_owner = factories.TeamDocumentAccessFactory(
document=document, team="owners", role="owner"
)
other_access = factories.TeamDocumentAccessFactory(document=document)
factories.TeamDocumentAccessFactory()
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
# pylint: disable=R0801
assert response.status_code == 200
content = response.json()
assert sorted(content.pop("accesses"), key=lambda x: x["id"]) == sorted(
[
{
"id": str(access_reader.id),
"user": None,
"team": "readers",
"role": "reader",
"abilities": {
"destroy": True,
"retrieve": True,
"set_role_to": ["owner", "administrator", "editor"],
"update": True,
"partial_update": True,
},
},
{
"id": str(access_editor.id),
"user": None,
"team": "editors",
"role": "editor",
"abilities": {
"destroy": True,
"retrieve": True,
"set_role_to": ["owner", "administrator", "reader"],
"update": True,
"partial_update": True,
},
},
{
"id": str(access_administrator.id),
"user": None,
"team": "administrators",
"role": "administrator",
"abilities": {
"destroy": True,
"retrieve": True,
"set_role_to": ["owner", "editor", "reader"],
"update": True,
"partial_update": True,
},
},
{
"id": str(access_owner.id),
"user": None,
"team": "owners",
"role": "owner",
"abilities": {
# editable only if there is another owner role than the user's team...
"destroy": other_access.role == "owner",
"retrieve": True,
"set_role_to": ["administrator", "editor", "reader"]
if other_access.role == "owner"
else [],
"update": other_access.role == "owner",
"partial_update": other_access.role == "owner",
},
},
{
"id": str(other_access.id),
"user": None,
"team": other_access.team,
"role": other_access.role,
"abilities": other_access.get_abilities(user),
},
],
key=lambda x: x["id"],
)
assert response.json() == {
"id": str(document.id),
"title": document.title,
"content": document.content,
"abilities": document.get_abilities(user),
"is_e2ee": 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"),
}
@@ -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"
@@ -0,0 +1,306 @@
"""
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
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(
"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(
instance=factories.DocumentFactory()
).data
response = APIClient().put(
f"/api/v1.0/documents/{document.id!s}/",
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.DocumentSerializer(instance=document).data
assert document_values == old_document_values
@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 if the link configuration does not allow it.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
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 == 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(
"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
):
"""
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()
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"
)
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 == 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("role", ["editor", "administrator", "owner"])
@pytest.mark.parametrize("via", VIA)
def test_api_documents_update_authenticated_editor_administrator_or_owner(
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()
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.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_owners(via, mock_user_teams):
"""Administrators of a document should be allowed to update it."""
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"
)
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_administrator_or_owner_of_another(via, mock_user_teams):
"""
Being administrator or owner of a document should not grant authorization to update
another document.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(
document=document, user=user, role=random.choice(["administrator", "owner"])
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document,
team="lasuite",
role=random.choice(["administrator", "owner"]),
)
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/{other_document.id!s}/",
new_document_values,
format="json",
)
assert response.status_code == 403
other_document.refresh_from_db()
other_document_values = serializers.DocumentSerializer(instance=other_document).data
assert other_document_values == old_document_values
@@ -24,7 +24,7 @@ def test_openapi_client_schema():
"--api-version",
"v1.0",
"--urlconf",
"people.api_urls",
"core.urls",
"--format",
"openapi-json",
"--file",
@@ -1,236 +0,0 @@
"""
Test for team accesses API endpoints in People's core app : create
"""
import json
import random
import re
import pytest
import responses
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
def test_api_team_accesses_create_anonymous():
"""Anonymous users should not be allowed to create team accesses."""
user = factories.UserFactory()
team = factories.TeamFactory()
response = APIClient().post(
f"/api/v1.0/teams/{team.id!s}/accesses/",
{
"user": str(user.id),
"team": str(team.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.TeamAccess.objects.exists() is False
def test_api_team_accesses_create_authenticated_unrelated():
"""
Authenticated users should not be allowed to create team accesses for a team to
which they are not related.
"""
identity = factories.IdentityFactory()
user = identity.user
other_user = factories.UserFactory()
team = factories.TeamFactory()
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/teams/{team.id!s}/accesses/",
{
"user": str(other_user.id),
},
format="json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You are not allowed to manage accesses for this team."
}
assert not models.TeamAccess.objects.filter(user=other_user).exists()
def test_api_team_accesses_create_authenticated_member():
"""Members of a team should not be allowed to create team accesses."""
identity = factories.IdentityFactory()
user = identity.user
team = factories.TeamFactory(users=[(user, "member")])
other_user = factories.UserFactory()
client = APIClient()
client.force_login(user)
for role in [role[0] for role in models.RoleChoices.choices]:
response = client.post(
f"/api/v1.0/teams/{team.id!s}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You are not allowed to manage accesses for this team."
}
assert not models.TeamAccess.objects.filter(user=other_user).exists()
def test_api_team_accesses_create_authenticated_administrator():
"""
Administrators of a team should be able to create team accesses except for the "owner" role.
"""
identity = factories.IdentityFactory()
user = identity.user
team = factories.TeamFactory(users=[(user, "administrator")])
other_user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# It should not be allowed to create an owner access
response = client.post(
f"/api/v1.0/teams/{team.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 team 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/teams/{team.id!s}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == 201
assert models.TeamAccess.objects.filter(user=other_user).count() == 1
new_team_access = models.TeamAccess.objects.filter(user=other_user).get()
assert response.json() == {
"abilities": new_team_access.get_abilities(user),
"id": str(new_team_access.id),
"role": role,
"user": str(other_user.id),
}
def test_api_team_accesses_create_authenticated_owner():
"""
Owners of a team should be able to create team accesses whatever the role.
"""
identity = factories.IdentityFactory()
user = identity.user
team = factories.TeamFactory(users=[(user, "owner")])
other_user = factories.UserFactory()
role = random.choice([role[0] for role in models.RoleChoices.choices])
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/teams/{team.id!s}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == 201
assert models.TeamAccess.objects.filter(user=other_user).count() == 1
new_team_access = models.TeamAccess.objects.filter(user=other_user).get()
assert response.json() == {
"abilities": new_team_access.get_abilities(user),
"id": str(new_team_access.id),
"role": role,
"user": str(other_user.id),
}
def test_api_team_accesses_create_webhook():
"""
When the team has a webhook, creating a team access should fire a call.
"""
user, other_user = factories.UserFactory.create_batch(2)
team = factories.TeamFactory(users=[(user, "owner")])
webhook = factories.TeamWebhookFactory(team=team)
role = random.choice([role[0] for role in models.RoleChoices.choices])
client = APIClient()
client.force_login(user)
with responses.RequestsMock() as rsps:
# Ensure successful response by scim provider using "responses":
rsp = rsps.add(
rsps.PATCH,
re.compile(r".*/Groups/.*"),
body="{}",
status=200,
content_type="application/json",
)
response = client.post(
f"/api/v1.0/teams/{team.id!s}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == 201
assert rsp.call_count == 1
assert rsps.calls[0].request.url == webhook.url
# Payload sent to scim provider
payload = json.loads(rsps.calls[0].request.body)
assert payload == {
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [
{
"op": "add",
"path": "members",
"value": [
{
"value": str(other_user.id),
"email": None,
"type": "User",
}
],
}
],
}
@@ -1,227 +0,0 @@
"""
Test for team accesses API endpoints in People's core app : delete
"""
import json
import random
import re
import pytest
import responses
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
def test_api_team_accesses_delete_anonymous():
"""Anonymous users should not be allowed to destroy a team access."""
access = factories.TeamAccessFactory()
response = APIClient().delete(
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 401
assert models.TeamAccess.objects.count() == 1
def test_api_team_accesses_delete_authenticated():
"""
Authenticated users should not be allowed to delete a team access for a
team to which they are not related.
"""
identity = factories.IdentityFactory()
user = identity.user
access = factories.TeamAccessFactory()
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 403
assert models.TeamAccess.objects.count() == 1
def test_api_team_accesses_delete_member():
"""
Authenticated users should not be allowed to delete a team access for a
team in which they are a simple member.
"""
identity = factories.IdentityFactory()
user = identity.user
team = factories.TeamFactory(users=[(user, "member")])
access = factories.TeamAccessFactory(team=team)
assert models.TeamAccess.objects.count() == 2
assert models.TeamAccess.objects.filter(user=access.user).exists()
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 403
assert models.TeamAccess.objects.count() == 2
def test_api_team_accesses_delete_administrators():
"""
Users who are administrators in a team should be allowed to delete an access
from the team provided it is not ownership.
"""
identity = factories.IdentityFactory()
user = identity.user
team = factories.TeamFactory(users=[(user, "administrator")])
access = factories.TeamAccessFactory(
team=team, role=random.choice(["member", "administrator"])
)
assert models.TeamAccess.objects.count() == 2
assert models.TeamAccess.objects.filter(user=access.user).exists()
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 204
assert models.TeamAccess.objects.count() == 1
def test_api_team_accesses_delete_owners_except_owners():
"""
Users should be able to delete the team access of another user
for a team of which they are owner provided it is not an owner access.
"""
identity = factories.IdentityFactory()
user = identity.user
team = factories.TeamFactory(users=[(user, "owner")])
access = factories.TeamAccessFactory(
team=team, role=random.choice(["member", "administrator"])
)
assert models.TeamAccess.objects.count() == 2
assert models.TeamAccess.objects.filter(user=access.user).exists()
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 204
assert models.TeamAccess.objects.count() == 1
def test_api_team_accesses_delete_owners_for_owners():
"""
Users should not be allowed to delete the team access of another owner
even for a team in which they are direct owner.
"""
identity = factories.IdentityFactory()
user = identity.user
team = factories.TeamFactory(users=[(user, "owner")])
access = factories.TeamAccessFactory(team=team, role="owner")
assert models.TeamAccess.objects.count() == 2
assert models.TeamAccess.objects.filter(user=access.user).exists()
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 403
assert models.TeamAccess.objects.count() == 2
def test_api_team_accesses_delete_owners_last_owner():
"""
It should not be possible to delete the last owner access from a team
"""
user = factories.UserFactory()
team = factories.TeamFactory()
access = factories.TeamAccessFactory(team=team, user=user, role="owner")
assert models.TeamAccess.objects.count() == 1
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 403
assert models.TeamAccess.objects.count() == 1
def test_api_team_accesses_delete_webhook():
"""
When the team has a webhook, deleting a team access should fire a call.
"""
user = factories.UserFactory()
team = factories.TeamFactory(users=[(user, "administrator")])
webhook = factories.TeamWebhookFactory(team=team)
access = factories.TeamAccessFactory(
team=team, role=random.choice(["member", "administrator"])
)
assert models.TeamAccess.objects.count() == 2
assert models.TeamAccess.objects.filter(user=access.user).exists()
client = APIClient()
client.force_login(user)
with responses.RequestsMock() as rsps:
# Ensure successful response by scim provider using "responses":
rsp = rsps.add(
rsps.PATCH,
re.compile(r".*/Groups/.*"),
body="{}",
status=200,
content_type="application/json",
)
response = client.delete(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 204
assert rsp.call_count == 1
assert rsps.calls[0].request.url == webhook.url
# Payload sent to scim provider
payload = json.loads(rsps.calls[0].request.body)
assert payload == {
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [
{
"op": "remove",
"path": "members",
"value": [
{
"value": str(access.user.id),
"email": None,
"type": "User",
}
],
}
],
}
assert models.TeamAccess.objects.count() == 1
assert models.TeamAccess.objects.filter(user=access.user).exists() is False
@@ -1,289 +0,0 @@
"""
Test for team accesses API endpoints in People's core app : list
"""
import pytest
from rest_framework.status import HTTP_200_OK
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
def test_api_team_accesses_list_anonymous():
"""Anonymous users should not be allowed to list team accesses."""
team = factories.TeamFactory()
factories.TeamAccessFactory.create_batch(2, team=team)
response = APIClient().get(f"/api/v1.0/teams/{team.id!s}/accesses/")
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_team_accesses_list_authenticated_unrelated():
"""
Authenticated users should not be allowed to list team accesses for a team
to which they are not related.
"""
identity = factories.IdentityFactory()
user = identity.user
team = factories.TeamFactory()
factories.TeamAccessFactory.create_batch(3, team=team)
# Accesses for other teams to which the user is related should not be listed either
other_access = factories.TeamAccessFactory(user=user)
factories.TeamAccessFactory(team=other_access.team)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/teams/{team.id!s}/accesses/",
)
assert response.status_code == 200
assert response.json() == {
"count": 0,
"next": None,
"previous": None,
"results": [],
}
def test_api_team_accesses_list_authenticated_related():
"""
Authenticated users should be able to list team accesses for a team
to which they are related, with a given role.
"""
identity = factories.IdentityFactory(is_main=True)
user = identity.user
team = factories.TeamFactory()
owner = factories.IdentityFactory(is_main=True)
access1 = factories.TeamAccessFactory.create(
team=team, user=owner.user, role="owner"
)
administrator = factories.IdentityFactory(is_main=True)
access2 = factories.TeamAccessFactory.create(
team=team, user=administrator.user, role="administrator"
)
# Ensure this user's role is different from other team members to test abilities' computation
user_access = models.TeamAccess.objects.create(team=team, user=user, role="member")
# Grant other team accesses to the user, they should not be listed either
other_access = factories.TeamAccessFactory(user=user)
factories.TeamAccessFactory(team=other_access.team)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/teams/{team.id!s}/accesses/",
)
assert response.status_code == 200
assert response.json()["count"] == 3
assert sorted(response.json()["results"], key=lambda x: x["id"]) == sorted(
[
{
"id": str(user_access.id),
"user": {
"id": str(user_access.user.id),
"email": str(identity.email),
"name": str(identity.name),
},
"role": str(user_access.role),
"abilities": user_access.get_abilities(user),
},
{
"id": str(access1.id),
"user": {
"id": str(access1.user.id),
"email": str(owner.email),
"name": str(owner.name),
},
"role": str(access1.role),
"abilities": access1.get_abilities(user),
},
{
"id": str(access2.id),
"user": {
"id": str(access2.user.id),
"email": str(administrator.email),
"name": str(administrator.name),
},
"role": str(access2.role),
"abilities": access2.get_abilities(user),
},
],
key=lambda x: x["id"],
)
def test_api_team_accesses_list_authenticated_main_identity():
"""
Name and email should be returned from main identity only
"""
user = factories.UserFactory()
identity = factories.IdentityFactory(user=user, is_main=True)
factories.IdentityFactory(user=user) # additional non-main identity
team = factories.TeamFactory()
models.TeamAccess.objects.create(team=team, user=user) # random role
# other team members should appear, with correct identity
other_user = factories.UserFactory()
other_main_identity = factories.IdentityFactory(is_main=True, user=other_user)
factories.IdentityFactory(user=other_user)
factories.TeamAccessFactory.create(team=team, user=other_user)
# Accesses for other teams to which the user is related should not be listed either
other_access = factories.TeamAccessFactory(user=user)
factories.TeamAccessFactory(team=other_access.team)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/teams/{team.id!s}/accesses/",
)
assert response.status_code == 200
assert response.json()["count"] == 2
users_info = [
(access["user"]["email"], access["user"]["name"])
for access in response.json()["results"]
]
# user information should be returned from main identity
assert sorted(users_info) == sorted(
[
(str(identity.email), str(identity.name)),
(str(other_main_identity.email), str(other_main_identity.name)),
]
)
def test_api_team_accesses_list_authenticated_constant_numqueries(
django_assert_num_queries,
):
"""
The number of queries should not depend on the amount of fetched accesses.
"""
user = factories.UserFactory()
factories.IdentityFactory(user=user, is_main=True)
team = factories.TeamFactory()
models.TeamAccess.objects.create(team=team, user=user) # random role
client = APIClient()
client.force_login(user)
# Only 4 queries are needed to efficiently fetch team accesses,
# related users and identities :
# - query retrieving logged-in user for user_role annotation
# - count from pagination
# - query prefetching users' main identity
# - distinct from viewset
with django_assert_num_queries(4):
response = client.get(
f"/api/v1.0/teams/{team.id!s}/accesses/",
)
# create 20 new team members
for _ in range(20):
extra_user = factories.IdentityFactory(is_main=True).user
factories.TeamAccessFactory(team=team, user=extra_user)
# num queries should still be 4
with django_assert_num_queries(4):
response = client.get(
f"/api/v1.0/teams/{team.id!s}/accesses/",
)
assert response.status_code == 200
assert response.json()["count"] == 21
def test_api_team_accesses_list_authenticated_ordering():
"""Team accesses can be ordered by "role"."""
user = factories.UserFactory()
factories.IdentityFactory(user=user, is_main=True)
team = factories.TeamFactory()
models.TeamAccess.objects.create(team=team, user=user)
# create 20 new team members
for _ in range(20):
extra_user = factories.IdentityFactory(is_main=True).user
factories.TeamAccessFactory(team=team, user=extra_user)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/teams/{team.id!s}/accesses/?ordering=role",
)
assert response.status_code == HTTP_200_OK
assert response.json()["count"] == 21
results = [team_access["role"] for team_access in response.json()["results"]]
assert sorted(results) == results
response = client.get(
f"/api/v1.0/teams/{team.id!s}/accesses/?ordering=-role",
)
assert response.status_code == HTTP_200_OK
assert response.json()["count"] == 21
results = [team_access["role"] for team_access in response.json()["results"]]
assert sorted(results, reverse=True) == results
@pytest.mark.parametrize("ordering_fields", ["name", "email"])
def test_api_team_accesses_list_authenticated_ordering_user(ordering_fields):
"""Team accesses can be ordered by user's fields "email" or "name"."""
user = factories.UserFactory()
factories.IdentityFactory(user=user, is_main=True)
team = factories.TeamFactory()
models.TeamAccess.objects.create(team=team, user=user)
# create 20 new team members
for _ in range(20):
extra_user = factories.IdentityFactory(is_main=True).user
factories.TeamAccessFactory(team=team, user=extra_user)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/teams/{team.id!s}/accesses/?ordering={ordering_fields}",
)
assert response.status_code == HTTP_200_OK
assert response.json()["count"] == 21
def normalize(x):
"""Mimic Django order_by, which is case-insensitive and space-insensitive"""
return x.casefold().replace(" ", "")
results = [
team_access["user"][ordering_fields]
for team_access in response.json()["results"]
]
assert sorted(results, key=normalize) == results
response = client.get(
f"/api/v1.0/teams/{team.id!s}/accesses/?ordering=-{ordering_fields}",
)
assert response.status_code == HTTP_200_OK
assert response.json()["count"] == 21
results = [
team_access["user"][ordering_fields]
for team_access in response.json()["results"]
]
assert sorted(results, reverse=True, key=normalize) == results
@@ -1,87 +0,0 @@
"""
Test for team accesses API endpoints in People's core app : retrieve
"""
import pytest
from rest_framework.test import APIClient
from core import factories
pytestmark = pytest.mark.django_db
def test_api_team_accesses_retrieve_anonymous():
"""
Anonymous users should not be allowed to retrieve a team access.
"""
access = factories.TeamAccessFactory()
response = APIClient().get(
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_team_accesses_retrieve_authenticated_unrelated():
"""
Authenticated users should not be allowed to retrieve a team access for
a team to which they are not related.
"""
identity = factories.IdentityFactory()
user = identity.user
access = factories.TeamAccessFactory(team=factories.TeamFactory())
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 404
assert response.json() == {"detail": "No TeamAccess matches the given query."}
# Accesses related to another team should be excluded even if the user is related to it
for other_access in [
factories.TeamAccessFactory(),
factories.TeamAccessFactory(user=user),
]:
response = client.get(
f"/api/v1.0/teams/{access.team.id!s}/accesses/{other_access.id!s}/",
)
assert response.status_code == 404
assert response.json() == {"detail": "No TeamAccess matches the given query."}
def test_api_team_accesses_retrieve_authenticated_related():
"""
A user who is related to a team should be allowed to retrieve the
associated team user accesses.
"""
identity = factories.IdentityFactory(is_main=True)
user = identity.user
team = factories.TeamFactory()
access = factories.TeamAccessFactory(team=team, user=user)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 200
assert response.json() == {
"id": str(access.id),
"user": {
"id": str(access.user.id),
"email": str(identity.email),
"name": str(identity.name),
},
"role": str(access.role),
"abilities": access.get_abilities(user),
}
@@ -1,341 +0,0 @@
"""
Test for team accesses API endpoints in People's core app : update
"""
import random
from uuid import uuid4
import pytest
from rest_framework.test import APIClient
from core import factories, models
from core.api import serializers
pytestmark = pytest.mark.django_db
def test_api_team_accesses_update_anonymous():
"""Anonymous users should not be allowed to update a team access."""
access = factories.TeamAccessFactory()
old_values = serializers.TeamAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
}
for field, value in new_values.items():
response = APIClient().put(
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
{**old_values, field: value},
format="json",
)
assert response.status_code == 401
access.refresh_from_db()
updated_values = serializers.TeamAccessSerializer(instance=access).data
assert updated_values == old_values
def test_api_team_accesses_update_authenticated_unrelated():
"""
Authenticated users should not be allowed to update a team access for a team to which
they are not related.
"""
identity = factories.IdentityFactory()
user = identity.user
access = factories.TeamAccessFactory()
old_values = serializers.TeamAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
}
client = APIClient()
client.force_login(user)
for field, value in new_values.items():
response = client.put(
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
{**old_values, field: value},
format="json",
)
assert response.status_code == 403
access.refresh_from_db()
updated_values = serializers.TeamAccessSerializer(instance=access).data
assert updated_values == old_values
def test_api_team_accesses_update_authenticated_member():
"""Members of a team should not be allowed to update its accesses."""
identity = factories.IdentityFactory()
user = identity.user
team = factories.TeamFactory(users=[(user, "member")])
access = factories.TeamAccessFactory(team=team)
old_values = serializers.TeamAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
}
client = APIClient()
client.force_login(user)
for field, value in new_values.items():
response = client.put(
f"/api/v1.0/teams/{access.team.id!s}/accesses/{access.id!s}/",
{**old_values, field: value},
format="json",
)
assert response.status_code == 403
access.refresh_from_db()
updated_values = serializers.TeamAccessSerializer(instance=access).data
assert updated_values == old_values
def test_api_team_accesses_update_administrator_except_owner():
"""
A user who is an administrator in a team should be allowed to update a user
access for this team, as long as they don't try to set the role to owner.
"""
identity = factories.IdentityFactory()
user = identity.user
team = factories.TeamFactory(users=[(user, "administrator")])
access = factories.TeamAccessFactory(
team=team,
role=random.choice(["administrator", "member"]),
)
old_values = serializers.TeamAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(["administrator", "member"]),
}
client = APIClient()
client.force_login(user)
for field, value in new_values.items():
new_data = {**old_values, field: value}
response = client.put(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
)
if (
new_data["role"] == old_values["role"]
): # we are not really updating the role
assert response.status_code == 403
else:
assert response.status_code == 200
access.refresh_from_db()
updated_values = serializers.TeamAccessSerializer(instance=access).data
if field == "role":
assert updated_values == {**old_values, "role": new_values["role"]}
else:
assert updated_values == old_values
def test_api_team_accesses_update_administrator_from_owner():
"""
A user who is an administrator in a team, should not be allowed to update
the user access of an "owner" for this team.
"""
identity = factories.IdentityFactory()
user = identity.user
team = factories.TeamFactory(users=[(user, "administrator")])
other_user = factories.UserFactory()
access = factories.TeamAccessFactory(team=team, user=other_user, role="owner")
old_values = serializers.TeamAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
}
client = APIClient()
client.force_login(user)
for field, value in new_values.items():
response = client.put(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
data={**old_values, field: value},
format="json",
)
assert response.status_code == 403
access.refresh_from_db()
updated_values = serializers.TeamAccessSerializer(instance=access).data
assert updated_values == old_values
def test_api_team_accesses_update_administrator_to_owner():
"""
A user who is an administrator in a team, should not be allowed to update
the user access of another user to grant team ownership.
"""
identity = factories.IdentityFactory()
user = identity.user
team = factories.TeamFactory(users=[(user, "administrator")])
other_user = factories.UserFactory()
access = factories.TeamAccessFactory(
team=team,
user=other_user,
role=random.choice(["administrator", "member"]),
)
old_values = serializers.TeamAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": "owner",
}
client = APIClient()
client.force_login(user)
for field, value in new_values.items():
new_data = {**old_values, field: value}
response = client.put(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
)
# We are not allowed or not really updating the role
if field == "role" or new_data["role"] == old_values["role"]:
assert response.status_code == 403
else:
assert response.status_code == 200
access.refresh_from_db()
updated_values = serializers.TeamAccessSerializer(instance=access).data
assert updated_values == old_values
def test_api_team_accesses_update_owner_except_owner():
"""
A user who is an owner in a team should be allowed to update
a user access for this team except for existing "owner" accesses.
"""
identity = factories.IdentityFactory()
user = identity.user
team = factories.TeamFactory(users=[(user, "owner")])
factories.UserFactory()
access = factories.TeamAccessFactory(
team=team,
role=random.choice(["administrator", "member"]),
)
old_values = serializers.TeamAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
}
client = APIClient()
client.force_login(user)
for field, value in new_values.items():
new_data = {**old_values, field: value}
response = client.put(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
)
if (
new_data["role"] == old_values["role"]
): # we are not really updating the role
assert response.status_code == 403
else:
assert response.status_code == 200
access.refresh_from_db()
updated_values = serializers.TeamAccessSerializer(instance=access).data
if field == "role":
assert updated_values == {**old_values, "role": new_values["role"]}
else:
assert updated_values == old_values
def test_api_team_accesses_update_owner_for_owners():
"""
A user who is "owner" of a team should not be allowed to update
an existing owner access for this team.
"""
identity = factories.IdentityFactory()
user = identity.user
team = factories.TeamFactory(users=[(user, "owner")])
access = factories.TeamAccessFactory(team=team, role="owner")
old_values = serializers.TeamAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
}
client = APIClient()
client.force_login(user)
for field, value in new_values.items():
response = client.put(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
data={**old_values, field: value},
content_type="application/json",
)
assert response.status_code == 403
access.refresh_from_db()
updated_values = serializers.TeamAccessSerializer(instance=access).data
assert updated_values == old_values
def test_api_team_accesses_update_owner_self():
"""
A user who is owner of a team should be allowed to update
their own user access provided there are other owners in the team.
"""
identity = factories.IdentityFactory()
user = identity.user
team = factories.TeamFactory()
access = factories.TeamAccessFactory(team=team, user=user, role="owner")
old_values = serializers.TeamAccessSerializer(instance=access).data
new_role = random.choice(["administrator", "member"])
client = APIClient()
client.force_login(user)
response = client.put(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
data={**old_values, "role": new_role},
format="json",
)
assert response.status_code == 403
access.refresh_from_db()
assert access.role == "owner"
# Add another owner and it should now work
factories.TeamAccessFactory(team=team, role="owner")
response = client.put(
f"/api/v1.0/teams/{team.id!s}/accesses/{access.id!s}/",
data={**old_values, "role": new_role},
format="json",
)
assert response.status_code == 200
access.refresh_from_db()
assert access.role == new_role
@@ -1,126 +0,0 @@
"""
Tests for Teams API endpoint in People's core app: create
"""
import pytest
from rest_framework.status import (
HTTP_201_CREATED,
HTTP_400_BAD_REQUEST,
HTTP_401_UNAUTHORIZED,
)
from rest_framework.test import APIClient
from core.factories import IdentityFactory, TeamFactory
from core.models import Team
pytestmark = pytest.mark.django_db
def test_api_teams_create_anonymous():
"""Anonymous users should not be allowed to create teams."""
response = APIClient().post(
"/api/v1.0/teams/",
{
"name": "my team",
},
)
assert response.status_code == HTTP_401_UNAUTHORIZED
assert not Team.objects.exists()
def test_api_teams_create_authenticated():
"""
Authenticated users should be able to create teams and should automatically be declared
as the owner of the newly created team.
"""
identity = IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(identity.user)
response = client.post(
"/api/v1.0/teams/",
{
"name": "my team",
},
format="json",
)
assert response.status_code == HTTP_201_CREATED
team = Team.objects.get()
assert team.name == "my team"
assert team.accesses.filter(role="owner", user=user).exists()
def test_api_teams_create_authenticated_slugify_name():
"""
Creating teams should automatically generate a slug.
"""
identity = IdentityFactory()
client = APIClient()
client.force_login(identity.user)
response = client.post(
"/api/v1.0/teams/",
{"name": "my team"},
)
assert response.status_code == HTTP_201_CREATED
team = Team.objects.get()
assert team.name == "my team"
assert team.slug == "my-team"
@pytest.mark.parametrize(
"param",
[
("my team", "my-team"),
("my team", "my-team"),
("MY TEAM TOO", "my-team-too"),
("mon équipe", "mon-equipe"),
("front devs & UX", "front-devs-ux"),
],
)
def test_api_teams_create_authenticated_expected_slug(param):
"""
Creating teams should automatically create unaccented, no unicode, lower-case slug.
"""
identity = IdentityFactory()
client = APIClient()
client.force_login(identity.user)
response = client.post(
"/api/v1.0/teams/",
{
"name": param[0],
},
)
assert response.status_code == HTTP_201_CREATED
team = Team.objects.get()
assert team.name == param[0]
assert team.slug == param[1]
def test_api_teams_create_authenticated_unique_slugs():
"""
Creating teams should raise an error if already existing slug.
"""
TeamFactory(name="existing team")
identity = IdentityFactory()
client = APIClient()
client.force_login(identity.user)
response = client.post(
"/api/v1.0/teams/",
{
"name": "èxisting team",
},
)
assert response.status_code == HTTP_400_BAD_REQUEST
assert response.json()["slug"] == ["Team with this Slug already exists."]
@@ -1,118 +0,0 @@
"""
Tests for Teams API endpoint in People's core app: delete
"""
import pytest
from rest_framework.status import (
HTTP_204_NO_CONTENT,
HTTP_401_UNAUTHORIZED,
HTTP_403_FORBIDDEN,
HTTP_404_NOT_FOUND,
)
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
def test_api_teams_delete_anonymous():
"""Anonymous users should not be allowed to destroy a team."""
team = factories.TeamFactory()
response = APIClient().delete(
f"/api/v1.0/teams/{team.id!s}/",
)
assert response.status_code == HTTP_401_UNAUTHORIZED
assert models.Team.objects.count() == 1
def test_api_teams_delete_authenticated_unrelated():
"""
Authenticated users should not be allowed to delete a team to which they are not
related.
"""
identity = factories.IdentityFactory()
client = APIClient()
client.force_login(identity.user)
team = factories.TeamFactory()
response = client.delete(
f"/api/v1.0/teams/{team.id!s}/",
)
assert response.status_code == HTTP_404_NOT_FOUND
assert response.json() == {"detail": "No Team matches the given query."}
assert models.Team.objects.count() == 1
def test_api_teams_delete_authenticated_member():
"""
Authenticated users should not be allowed to delete a team for which they are
only a member.
"""
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
team = factories.TeamFactory(users=[(user, "member")])
response = client.delete(
f"/api/v1.0/teams/{team.id}/",
)
assert response.status_code == HTTP_403_FORBIDDEN
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
assert models.Team.objects.count() == 1
def test_api_teams_delete_authenticated_administrator():
"""
Authenticated users should not be allowed to delete a team for which they are
administrator.
"""
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
team = factories.TeamFactory(users=[(user, "administrator")])
response = client.delete(
f"/api/v1.0/teams/{team.id}/",
)
assert response.status_code == HTTP_403_FORBIDDEN
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
assert models.Team.objects.count() == 1
def test_api_teams_delete_authenticated_owner():
"""
Authenticated users should be able to delete a team for which they are directly
owner.
"""
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
team = factories.TeamFactory(users=[(user, "owner")])
response = client.delete(
f"/api/v1.0/teams/{team.id}/",
)
assert response.status_code == HTTP_204_NO_CONTENT
assert models.Team.objects.exists() is False
@@ -1,183 +0,0 @@
"""
Tests for Teams API endpoint in People's core app: list
"""
from unittest import mock
import pytest
from rest_framework.pagination import PageNumberPagination
from rest_framework.status import HTTP_200_OK, HTTP_401_UNAUTHORIZED
from rest_framework.test import APIClient
from core import factories
pytestmark = pytest.mark.django_db
def test_api_teams_list_anonymous():
"""Anonymous users should not be allowed to list teams."""
factories.TeamFactory.create_batch(2)
response = APIClient().get("/api/v1.0/teams/")
assert response.status_code == HTTP_401_UNAUTHORIZED
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_teams_list_authenticated():
"""
Authenticated users should be able to list teams
they are an owner/administrator/member of.
"""
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
expected_ids = {
str(access.team.id)
for access in factories.TeamAccessFactory.create_batch(5, user=user)
}
factories.TeamFactory.create_batch(2) # Other teams
response = client.get(
"/api/v1.0/teams/",
)
assert response.status_code == HTTP_200_OK
results = response.json()["results"]
assert len(results) == 5
results_id = {result["id"] for result in results}
assert expected_ids == results_id
@mock.patch.object(PageNumberPagination, "get_page_size", return_value=2)
def test_api_teams_list_pagination(
_mock_page_size,
):
"""Pagination should work as expected."""
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
team_ids = [
str(access.team.id)
for access in factories.TeamAccessFactory.create_batch(3, user=user)
]
# Get page 1
response = client.get(
"/api/v1.0/teams/",
)
assert response.status_code == HTTP_200_OK
content = response.json()
assert content["count"] == 3
assert content["next"] == "http://testserver/api/v1.0/teams/?page=2"
assert content["previous"] is None
assert len(content["results"]) == 2
for item in content["results"]:
team_ids.remove(item["id"])
# Get page 2
response = client.get(
"/api/v1.0/teams/?page=2",
)
assert response.status_code == HTTP_200_OK
content = response.json()
assert content["count"] == 3
assert content["next"] is None
assert content["previous"] == "http://testserver/api/v1.0/teams/"
assert len(content["results"]) == 1
team_ids.remove(content["results"][0]["id"])
assert team_ids == []
def test_api_teams_list_authenticated_distinct():
"""A team with several related users should only be listed once."""
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
other_user = factories.UserFactory()
team = factories.TeamFactory(users=[user, other_user])
response = client.get(
"/api/v1.0/teams/",
)
assert response.status_code == HTTP_200_OK
content = response.json()
assert len(content["results"]) == 1
assert content["results"][0]["id"] == str(team.id)
def test_api_teams_order():
"""
Test that the endpoint GET teams is sorted in 'created_at' descending order by default.
"""
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
team_ids = [
str(team.id) for team in factories.TeamFactory.create_batch(5, users=[user])
]
response = client.get(
"/api/v1.0/teams/",
)
assert response.status_code == 200
response_data = response.json()
response_team_ids = [team["id"] for team in response_data["results"]]
team_ids.reverse()
assert (
response_team_ids == team_ids
), "created_at values are not sorted from newest to oldest"
def test_api_teams_order_param():
"""
Test that the 'created_at' field is sorted in ascending order
when the 'ordering' query parameter is set.
"""
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
team_ids = [
str(team.id) for team in factories.TeamFactory.create_batch(5, users=[user])
]
response = client.get(
"/api/v1.0/teams/?ordering=created_at",
)
assert response.status_code == 200
response_data = response.json()
response_team_ids = [team["id"] for team in response_data["results"]]
assert (
response_team_ids == team_ids
), "created_at values are not sorted from oldest to newest"
@@ -1,77 +0,0 @@
"""
Tests for Teams API endpoint in People's core app: retrieve
"""
import pytest
from rest_framework import status
from rest_framework.test import APIClient
from core import factories
pytestmark = pytest.mark.django_db
def test_api_teams_retrieve_anonymous():
"""Anonymous users should not be allowed to retrieve a team."""
team = factories.TeamFactory()
response = APIClient().get(f"/api/v1.0/teams/{team.id}/")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_teams_retrieve_authenticated_unrelated():
"""
Authenticated users should not be allowed to retrieve a team to which they are
not related.
"""
identity = factories.IdentityFactory()
client = APIClient()
client.force_login(identity.user)
team = factories.TeamFactory()
response = client.get(
f"/api/v1.0/teams/{team.id!s}/",
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json() == {"detail": "No Team matches the given query."}
def test_api_teams_retrieve_authenticated_related():
"""
Authenticated users should be allowed to retrieve a team to which they
are related whatever the role.
"""
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
team = factories.TeamFactory()
access1 = factories.TeamAccessFactory(team=team, user=user)
access2 = factories.TeamAccessFactory(team=team)
response = client.get(
f"/api/v1.0/teams/{team.id!s}/",
)
assert response.status_code == status.HTTP_200_OK
assert sorted(response.json().pop("accesses")) == sorted(
[
str(access1.id),
str(access2.id),
]
)
assert response.json() == {
"id": str(team.id),
"name": team.name,
"slug": team.slug,
"abilities": team.get_abilities(user),
"created_at": team.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": team.updated_at.isoformat().replace("+00:00", "Z"),
}
@@ -1,224 +0,0 @@
"""
Tests for Teams API endpoint in People's core app: update
"""
import random
import pytest
from rest_framework.status import (
HTTP_200_OK,
HTTP_400_BAD_REQUEST,
HTTP_401_UNAUTHORIZED,
HTTP_403_FORBIDDEN,
HTTP_404_NOT_FOUND,
)
from rest_framework.test import APIClient
from core import factories
from core.api import serializers
pytestmark = pytest.mark.django_db
def test_api_teams_update_anonymous():
"""Anonymous users should not be allowed to update a team."""
team = factories.TeamFactory()
old_team_values = serializers.TeamSerializer(instance=team).data
new_team_values = serializers.TeamSerializer(instance=factories.TeamFactory()).data
response = APIClient().put(
f"/api/v1.0/teams/{team.id!s}/",
new_team_values,
format="json",
)
assert response.status_code == HTTP_401_UNAUTHORIZED
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
team.refresh_from_db()
team_values = serializers.TeamSerializer(instance=team).data
assert team_values == old_team_values
def test_api_teams_update_authenticated_unrelated():
"""
Authenticated users should not be allowed to update a team to which they are not related.
"""
identity = factories.IdentityFactory()
client = APIClient()
client.force_login(identity.user)
team = factories.TeamFactory()
old_team_values = serializers.TeamSerializer(instance=team).data
new_team_values = serializers.TeamSerializer(instance=factories.TeamFactory()).data
response = client.put(
f"/api/v1.0/teams/{team.id!s}/",
new_team_values,
format="json",
)
assert response.status_code == HTTP_404_NOT_FOUND
assert response.json() == {"detail": "No Team matches the given query."}
team.refresh_from_db()
team_values = serializers.TeamSerializer(instance=team).data
assert team_values == old_team_values
def test_api_teams_update_authenticated_members():
"""
Users who are members of a team but not administrators should
not be allowed to update it.
"""
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
team = factories.TeamFactory(users=[(user, "member")])
old_team_values = serializers.TeamSerializer(instance=team).data
new_team_values = serializers.TeamSerializer(instance=factories.TeamFactory()).data
response = client.put(
f"/api/v1.0/teams/{team.id!s}/",
new_team_values,
format="json",
)
assert response.status_code == HTTP_403_FORBIDDEN
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
team.refresh_from_db()
team_values = serializers.TeamSerializer(instance=team).data
assert team_values == old_team_values
def test_api_teams_update_authenticated_administrators():
"""Administrators of a team should be allowed to update it."""
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
team = factories.TeamFactory(users=[(user, "administrator")])
initial_values = serializers.TeamSerializer(instance=team).data
# generate new random values
new_values = serializers.TeamSerializer(instance=factories.TeamFactory.build()).data
response = client.put(
f"/api/v1.0/teams/{team.id!s}/",
new_values,
format="json",
)
assert response.status_code == HTTP_200_OK
team.refresh_from_db()
final_values = serializers.TeamSerializer(instance=team).data
for key, value in final_values.items():
if key in ["id", "accesses", "created_at"]:
assert value == initial_values[key]
elif key == "updated_at":
assert value > initial_values[key]
else:
# name, slug and abilities successfully modified
assert value == new_values[key]
def test_api_teams_update_authenticated_owners():
"""Administrators of a team should be allowed to update it,
apart from read-only fields."""
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
team = factories.TeamFactory(users=[(user, "owner")])
old_team_values = serializers.TeamSerializer(instance=team).data
new_team_values = serializers.TeamSerializer(
instance=factories.TeamFactory.build()
).data
response = client.put(
f"/api/v1.0/teams/{team.id!s}/",
new_team_values,
format="json",
)
assert response.status_code == HTTP_200_OK
team.refresh_from_db()
team_values = serializers.TeamSerializer(instance=team).data
for key, value in team_values.items():
if key in ["id", "accesses", "created_at"]:
assert value == old_team_values[key]
elif key == "updated_at":
assert value > old_team_values[key]
else:
# name, slug and abilities successfully modified
assert value == new_team_values[key]
def test_api_teams_update_administrator_or_owner_of_another():
"""
Being administrator or owner of a team should not grant authorization to update
another team.
"""
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
factories.TeamFactory(users=[(user, random.choice(["administrator", "owner"]))])
team = factories.TeamFactory(name="Old name")
old_team_values = serializers.TeamSerializer(instance=team).data
new_team_values = serializers.TeamSerializer(instance=factories.TeamFactory()).data
response = client.put(
f"/api/v1.0/teams/{team.id!s}/",
new_team_values,
format="json",
)
assert response.status_code == HTTP_404_NOT_FOUND
assert response.json() == {"detail": "No Team matches the given query."}
team.refresh_from_db()
team_values = serializers.TeamSerializer(instance=team).data
assert team_values == old_team_values
def test_api_teams_update_existing_slug_should_return_error():
"""
Updating a team's name to an existing slug should return a bad request,
instead of creating a duplicate.
"""
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
factories.TeamFactory(name="Existing team", users=[(user, "administrator")])
my_team = factories.TeamFactory(name="New team", users=[(user, "administrator")])
updated_values = serializers.TeamSerializer(instance=my_team).data
# Update my team's name for existing team. Creates a duplicate slug
updated_values["name"] = "existing team"
response = client.put(
f"/api/v1.0/teams/{my_team.id!s}/",
updated_values,
format="json",
)
assert response.status_code == HTTP_400_BAD_REQUEST
assert response.json()["slug"] == ["Team with this Slug already exists."]
# Both teams names and slugs should be unchanged
assert my_team.name == "New team"
assert my_team.slug == "new-team"
@@ -0,0 +1,48 @@
"""
Tests for Templates API endpoint in impress's core app: create
"""
import pytest
from rest_framework.test import APIClient
from core import factories
from core.models import Template
pytestmark = pytest.mark.django_db
def test_api_templates_create_anonymous():
"""Anonymous users should not be allowed to create templates."""
response = APIClient().post(
"/api/v1.0/templates/",
{
"title": "my template",
},
)
assert response.status_code == 401
assert not Template.objects.exists()
def test_api_templates_create_authenticated():
"""
Authenticated users should be able to create templates and should automatically be declared
as the owner of the newly created template.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/templates/",
{
"title": "my template",
},
format="json",
)
assert response.status_code == 201
template = Template.objects.get()
assert template.title == "my template"
assert template.accesses.filter(role="owner", user=user).exists()
@@ -0,0 +1,107 @@
"""
Tests for Templates API endpoint in impress's core app: delete
"""
import random
import pytest
from rest_framework.test import APIClient
from core import factories, models
from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
def test_api_templates_delete_anonymous():
"""Anonymous users should not be allowed to destroy a template."""
template = factories.TemplateFactory()
response = APIClient().delete(
f"/api/v1.0/templates/{template.id!s}/",
)
assert response.status_code == 401
assert models.Template.objects.count() == 1
def test_api_templates_delete_authenticated_unrelated():
"""
Authenticated users should not be allowed to delete a template to which they are not
related.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
is_public = random.choice([True, False])
template = factories.TemplateFactory(is_public=is_public)
response = client.delete(
f"/api/v1.0/templates/{template.id!s}/",
)
assert response.status_code == 403 if is_public else 404
assert models.Template.objects.count() == 1
@pytest.mark.parametrize("role", ["reader", "editor", "administrator"])
@pytest.mark.parametrize("via", VIA)
def test_api_templates_delete_authenticated_member_or_administrator(
via, role, mock_user_teams
):
"""
Authenticated users should not be allowed to delete a template for which they are
only a member or administrator.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role=role
)
response = client.delete(
f"/api/v1.0/templates/{template.id}/",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
assert models.Template.objects.count() == 1
@pytest.mark.parametrize("via", VIA)
def test_api_templates_delete_authenticated_owner(via, mock_user_teams):
"""
Authenticated users should be able to delete a template they own.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role="owner")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="owner"
)
response = client.delete(
f"/api/v1.0/templates/{template.id}/",
)
assert response.status_code == 204
assert models.Template.objects.exists() is False
@@ -0,0 +1,208 @@
"""
Test users API endpoints in the impress core app.
"""
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
def test_api_templates_generate_document_anonymous_public():
"""Anonymous users can generate pdf document with public templates."""
template = factories.TemplateFactory(is_public=True)
data = {
"body": "# Test markdown body",
}
response = APIClient().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/pdf"
def test_api_templates_generate_document_anonymous_not_public():
"""
Anonymous users should not be allowed to generate pdf document with templates
that are not marked as public.
"""
template = factories.TemplateFactory(is_public=False)
data = {
"body": "# Test markdown body",
}
response = APIClient().post(
f"/api/v1.0/templates/{template.id!s}/generate-document/",
data,
format="json",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_templates_generate_document_authenticated_public():
"""Authenticated users can generate pdf document with public templates."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory(is_public=True)
data = {"body": "# Test markdown body"}
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/pdf"
def test_api_templates_generate_document_authenticated_not_public():
"""
Authenticated users should not be allowed to generate pdf document with templates
that are not marked as public.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory(is_public=False)
data = {"body": "# Test markdown body"}
response = client.post(
f"/api/v1.0/templates/{template.id!s}/generate-document/",
data,
format="json",
)
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_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_teams.return_value = ["lasuite", "unknown"]
access = factories.TeamTemplateAccessFactory(team="lasuite")
data = {"body": "# Test markdown body"}
response = client.post(
f"/api/v1.0/templates/{access.template_id!s}/generate-document/",
data,
format="json",
)
assert response.status_code == 200
assert response.headers["content-type"] == "application/pdf"
def test_api_templates_generate_document_type_html():
"""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"}
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/pdf"
def test_api_templates_generate_document_type_markdown():
"""Generate pdf document with the body type markdown."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory(is_public=True)
data = {"body": "# Test markdown body", "body_type": "markdown"}
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/pdf"
def test_api_templates_generate_document_type_unknown():
"""Generate pdf document with the body type unknown."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory(is_public=True)
data = {"body": "# Test markdown body", "body_type": "unknown"}
response = client.post(
f"/api/v1.0/templates/{template.id!s}/generate-document/",
data,
format="json",
)
assert response.status_code == 400
assert response.json() == {
"body_type": [
'"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"
)
@@ -0,0 +1,220 @@
"""
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.test import APIClient
from core import factories
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)
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 == 200
results = response.json()["results"]
assert len(results) == 2
results_id = {result["id"] for result in results}
assert expected_ids == results_id
def test_api_templates_list_authenticated_direct():
"""
Authenticated users should be able to list templates they are a direct
owner/administrator/member of or that are public.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
related_templates = [
access.template
for access in factories.UserTemplateAccessFactory.create_batch(5, user=user)
]
public_templates = factories.TemplateFactory.create_batch(2, is_public=True)
factories.TemplateFactory.create_batch(2, is_public=False)
expected_ids = {
str(template.id) for template in related_templates + public_templates
}
response = client.get(
"/api/v1.0/templates/",
)
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_teams):
"""
Authenticated users should be able to list templates they are a
owner/administrator/member of via a team or that are public.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
mock_user_teams.return_value = ["team1", "team2", "unknown"]
templates_team1 = [
access.template
for access in factories.TeamTemplateAccessFactory.create_batch(2, team="team1")
]
templates_team2 = [
access.template
for access in factories.TeamTemplateAccessFactory.create_batch(3, team="team2")
]
public_templates = factories.TemplateFactory.create_batch(2, is_public=True)
factories.TemplateFactory.create_batch(2, is_public=False)
expected_ids = {
str(template.id)
for template in templates_team1 + templates_team2 + public_templates
}
response = client.get("/api/v1.0/templates/")
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
@mock.patch.object(PageNumberPagination, "get_page_size", return_value=2)
def test_api_templates_list_pagination(
_mock_page_size,
):
"""Pagination should work as expected."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template_ids = [
str(access.template_id)
for access in factories.UserTemplateAccessFactory.create_batch(3, user=user)
]
# Get page 1
response = client.get(
"/api/v1.0/templates/",
)
assert response.status_code == 200
content = response.json()
assert content["count"] == 3
assert content["next"] == "http://testserver/api/v1.0/templates/?page=2"
assert content["previous"] is None
assert len(content["results"]) == 2
for item in content["results"]:
template_ids.remove(item["id"])
# Get page 2
response = client.get(
"/api/v1.0/templates/?page=2",
)
assert response.status_code == 200
content = response.json()
assert content["count"] == 3
assert content["next"] is None
assert content["previous"] == "http://testserver/api/v1.0/templates/"
assert len(content["results"]) == 1
template_ids.remove(content["results"][0]["id"])
assert template_ids == []
def test_api_templates_list_authenticated_distinct():
"""A template with several related users should only be listed once."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
other_user = factories.UserFactory()
template = factories.TemplateFactory(users=[user, other_user], is_public=True)
response = client.get(
"/api/v1.0/templates/",
)
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_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(access.template.id)
for access in factories.UserTemplateAccessFactory.create_batch(5, user=user)
]
response = client.get(
"/api/v1.0/templates/",
)
assert response.status_code == 200
response_data = response.json()
response_template_ids = [template["id"] for template in response_data["results"]]
template_ids.reverse()
assert (
response_template_ids == template_ids
), "created_at values are not sorted from newest to oldest"
def test_api_templates_list_order_param():
"""
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(access.template.id)
for access in factories.UserTemplateAccessFactory.create_batch(5, user=user)
]
response = client.get(
"/api/v1.0/templates/?ordering=created_at",
)
assert response.status_code == 200
response_data = response.json()
response_template_ids = [template["id"] for template in response_data["results"]]
assert (
response_template_ids == templates_ids
), "created_at values are not sorted from oldest to newest"
@@ -0,0 +1,522 @@
"""
Tests for Templates API endpoint in impress's core app: retrieve
"""
import pytest
from rest_framework.test import APIClient
from core import factories
pytestmark = pytest.mark.django_db
def test_api_templates_retrieve_anonymous_public():
"""Anonymous users should be allowed to retrieve public templates."""
template = factories.TemplateFactory(is_public=True)
response = APIClient().get(f"/api/v1.0/templates/{template.id!s}/")
assert response.status_code == 200
assert response.json() == {
"id": str(template.id),
"abilities": {
"destroy": False,
"generate_document": True,
"manage_accesses": False,
"partial_update": False,
"retrieve": True,
"update": False,
},
"accesses": [],
"title": template.title,
"is_public": True,
"code": template.code,
"css": template.css,
}
def test_api_templates_retrieve_anonymous_not_public():
"""Anonymous users should not be able to retrieve a template that is not public."""
template = factories.TemplateFactory(is_public=False)
response = APIClient().get(f"/api/v1.0/templates/{template.id!s}/")
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_templates_retrieve_authenticated_unrelated_public():
"""
Authenticated users should be able to retrieve a public template to which they are
not related.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory(is_public=True)
response = client.get(
f"/api/v1.0/templates/{template.id!s}/",
)
assert response.status_code == 200
assert response.json() == {
"id": str(template.id),
"abilities": {
"destroy": False,
"generate_document": True,
"manage_accesses": False,
"partial_update": False,
"retrieve": True,
"update": False,
},
"accesses": [],
"title": template.title,
"is_public": True,
"code": template.code,
"css": template.css,
}
def test_api_templates_retrieve_authenticated_unrelated_not_public():
"""
Authenticated users should not be allowed to retrieve a template that is not public and
to which they are not related.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory(is_public=False)
response = client.get(
f"/api/v1.0/templates/{template.id!s}/",
)
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():
"""
Authenticated users should be allowed to retrieve a template to which they
are directly related whatever the role.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
access1 = factories.UserTemplateAccessFactory(template=template, user=user)
access2 = factories.UserTemplateAccessFactory(template=template)
response = client.get(
f"/api/v1.0/templates/{template.id!s}/",
)
assert response.status_code == 200
content = response.json()
assert sorted(content.pop("accesses"), key=lambda x: x["user"]) == sorted(
[
{
"id": str(access1.id),
"user": str(user.id),
"team": "",
"role": access1.role,
"abilities": access1.get_abilities(user),
},
{
"id": str(access2.id),
"user": str(access2.user.id),
"team": "",
"role": access2.role,
"abilities": access2.get_abilities(user),
},
],
key=lambda x: x["user"],
)
assert response.json() == {
"id": str(template.id),
"title": template.title,
"abilities": template.get_abilities(user),
"is_public": template.is_public,
"code": template.code,
"css": template.css,
}
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_teams.return_value = []
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory(is_public=False)
factories.TeamTemplateAccessFactory(
template=template, team="readers", role="reader"
)
factories.TeamTemplateAccessFactory(
template=template, team="editors", role="editor"
)
factories.TeamTemplateAccessFactory(
template=template, team="administrators", role="administrator"
)
factories.TeamTemplateAccessFactory(template=template, team="owners", role="owner")
factories.TeamTemplateAccessFactory(template=template)
factories.TeamTemplateAccessFactory()
response = client.get(f"/api/v1.0/templates/{template.id!s}/")
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
@pytest.mark.parametrize(
"teams",
[
["readers"],
["unknown", "readers"],
["editors"],
["unknown", "editors"],
],
)
def test_api_templates_retrieve_authenticated_related_team_readers_or_editors(
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_teams.return_value = teams
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory(is_public=False)
access_reader = factories.TeamTemplateAccessFactory(
template=template, team="readers", role="reader"
)
access_editor = factories.TeamTemplateAccessFactory(
template=template, team="editors", role="editor"
)
access_administrator = factories.TeamTemplateAccessFactory(
template=template, team="administrators", role="administrator"
)
access_owner = factories.TeamTemplateAccessFactory(
template=template, team="owners", role="owner"
)
other_access = factories.TeamTemplateAccessFactory(template=template)
factories.TeamTemplateAccessFactory()
response = client.get(f"/api/v1.0/templates/{template.id!s}/")
assert response.status_code == 200
content = response.json()
expected_abilities = {
"destroy": False,
"retrieve": True,
"set_role_to": [],
"update": False,
"partial_update": False,
}
assert sorted(content.pop("accesses"), key=lambda x: x["id"]) == sorted(
[
{
"id": str(access_reader.id),
"user": None,
"team": "readers",
"role": access_reader.role,
"abilities": expected_abilities,
},
{
"id": str(access_editor.id),
"user": None,
"team": "editors",
"role": access_editor.role,
"abilities": expected_abilities,
},
{
"id": str(access_administrator.id),
"user": None,
"team": "administrators",
"role": access_administrator.role,
"abilities": expected_abilities,
},
{
"id": str(access_owner.id),
"user": None,
"team": "owners",
"role": access_owner.role,
"abilities": expected_abilities,
},
{
"id": str(other_access.id),
"user": None,
"team": other_access.team,
"role": other_access.role,
"abilities": expected_abilities,
},
],
key=lambda x: x["id"],
)
assert response.json() == {
"id": str(template.id),
"title": template.title,
"abilities": template.get_abilities(user),
"is_public": False,
"code": template.code,
"css": template.css,
}
@pytest.mark.parametrize(
"teams",
[
["administrators"],
["members", "administrators"],
["unknown", "administrators"],
],
)
def test_api_templates_retrieve_authenticated_related_team_administrators(
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_teams.return_value = teams
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory(is_public=False)
access_reader = factories.TeamTemplateAccessFactory(
template=template, team="readers", role="reader"
)
access_editor = factories.TeamTemplateAccessFactory(
template=template, team="editors", role="editor"
)
access_administrator = factories.TeamTemplateAccessFactory(
template=template, team="administrators", role="administrator"
)
access_owner = factories.TeamTemplateAccessFactory(
template=template, team="owners", role="owner"
)
other_access = factories.TeamTemplateAccessFactory(template=template)
factories.TeamTemplateAccessFactory()
response = client.get(f"/api/v1.0/templates/{template.id!s}/")
assert response.status_code == 200
content = response.json()
assert sorted(content.pop("accesses"), key=lambda x: x["id"]) == sorted(
[
{
"id": str(access_reader.id),
"user": None,
"team": "readers",
"role": "reader",
"abilities": {
"destroy": True,
"retrieve": True,
"set_role_to": ["administrator", "editor"],
"update": True,
"partial_update": True,
},
},
{
"id": str(access_editor.id),
"user": None,
"team": "editors",
"role": "editor",
"abilities": {
"destroy": True,
"retrieve": True,
"set_role_to": ["administrator", "reader"],
"update": True,
"partial_update": True,
},
},
{
"id": str(access_administrator.id),
"user": None,
"team": "administrators",
"role": "administrator",
"abilities": {
"destroy": True,
"retrieve": True,
"set_role_to": ["editor", "reader"],
"update": True,
"partial_update": True,
},
},
{
"id": str(access_owner.id),
"user": None,
"team": "owners",
"role": "owner",
"abilities": {
"destroy": False,
"retrieve": True,
"set_role_to": [],
"update": False,
"partial_update": False,
},
},
{
"id": str(other_access.id),
"user": None,
"team": other_access.team,
"role": other_access.role,
"abilities": other_access.get_abilities(user),
},
],
key=lambda x: x["id"],
)
assert response.json() == {
"id": str(template.id),
"title": template.title,
"abilities": template.get_abilities(user),
"is_public": False,
"code": template.code,
"css": template.css,
}
@pytest.mark.parametrize(
"teams",
[
["owners"],
["owners", "administrators"],
["members", "administrators", "owners"],
["unknown", "owners"],
],
)
def test_api_templates_retrieve_authenticated_related_team_owners(
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_teams.return_value = teams
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory(is_public=False)
access_reader = factories.TeamTemplateAccessFactory(
template=template, team="readers", role="reader"
)
access_editor = factories.TeamTemplateAccessFactory(
template=template, team="editors", role="editor"
)
access_administrator = factories.TeamTemplateAccessFactory(
template=template, team="administrators", role="administrator"
)
access_owner = factories.TeamTemplateAccessFactory(
template=template, team="owners", role="owner"
)
other_access = factories.TeamTemplateAccessFactory(template=template)
factories.TeamTemplateAccessFactory()
response = client.get(f"/api/v1.0/templates/{template.id!s}/")
assert response.status_code == 200
content = response.json()
assert sorted(content.pop("accesses"), key=lambda x: x["id"]) == sorted(
[
{
"id": str(access_reader.id),
"user": None,
"team": "readers",
"role": "reader",
"abilities": {
"destroy": True,
"retrieve": True,
"set_role_to": ["owner", "administrator", "editor"],
"update": True,
"partial_update": True,
},
},
{
"id": str(access_editor.id),
"user": None,
"team": "editors",
"role": "editor",
"abilities": {
"destroy": True,
"retrieve": True,
"set_role_to": ["owner", "administrator", "reader"],
"update": True,
"partial_update": True,
},
},
{
"id": str(access_administrator.id),
"user": None,
"team": "administrators",
"role": "administrator",
"abilities": {
"destroy": True,
"retrieve": True,
"set_role_to": ["owner", "editor", "reader"],
"update": True,
"partial_update": True,
},
},
{
"id": str(access_owner.id),
"user": None,
"team": "owners",
"role": "owner",
"abilities": {
# editable only if there is another owner role than the user's team...
"destroy": other_access.role == "owner",
"retrieve": True,
"set_role_to": ["administrator", "editor", "reader"]
if other_access.role == "owner"
else [],
"update": other_access.role == "owner",
"partial_update": other_access.role == "owner",
},
},
{
"id": str(other_access.id),
"user": None,
"team": other_access.team,
"role": other_access.role,
"abilities": other_access.get_abilities(user),
},
],
key=lambda x: x["id"],
)
assert response.json() == {
"id": str(template.id),
"title": template.title,
"abilities": template.get_abilities(user),
"is_public": False,
"code": template.code,
"css": template.css,
}
@@ -0,0 +1,230 @@
"""
Tests for Templates API endpoint in impress's core app: update
"""
import random
import pytest
from rest_framework.test import APIClient
from core import factories
from core.api import serializers
from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
def test_api_templates_update_anonymous():
"""Anonymous users should not be allowed to update a template."""
template = factories.TemplateFactory()
old_template_values = serializers.TemplateSerializer(instance=template).data
new_template_values = serializers.TemplateSerializer(
instance=factories.TemplateFactory()
).data
response = APIClient().put(
f"/api/v1.0/templates/{template.id!s}/",
new_template_values,
format="json",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
template.refresh_from_db()
template_values = serializers.TemplateSerializer(instance=template).data
assert template_values == old_template_values
def test_api_templates_update_authenticated_unrelated():
"""
Authenticated users should not be allowed to update a template to which they are not related.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory(is_public=False)
old_template_values = serializers.TemplateSerializer(instance=template).data
new_template_values = serializers.TemplateSerializer(
instance=factories.TemplateFactory()
).data
response = client.put(
f"/api/v1.0/templates/{template.id!s}/",
new_template_values,
format="json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
template.refresh_from_db()
template_values = serializers.TemplateSerializer(instance=template).data
assert template_values == old_template_values
@pytest.mark.parametrize("via", VIA)
def test_api_templates_update_authenticated_readers(via, mock_user_teams):
"""
Users who are readers of a template should not be allowed to update it.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role="reader")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="reader"
)
old_template_values = serializers.TemplateSerializer(instance=template).data
new_template_values = serializers.TemplateSerializer(
instance=factories.TemplateFactory()
).data
response = client.put(
f"/api/v1.0/templates/{template.id!s}/",
new_template_values,
format="json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
template.refresh_from_db()
template_values = serializers.TemplateSerializer(instance=template).data
assert template_values == old_template_values
@pytest.mark.parametrize("role", ["editor", "administrator", "owner"])
@pytest.mark.parametrize("via", VIA)
def test_api_templates_update_authenticated_editor_or_administrator_or_owner(
via, role, mock_user_teams
):
"""Administrator or owner of a template should be allowed to update it."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role=role
)
old_template_values = serializers.TemplateSerializer(instance=template).data
new_template_values = serializers.TemplateSerializer(
instance=factories.TemplateFactory()
).data
response = client.put(
f"/api/v1.0/templates/{template.id!s}/",
new_template_values,
format="json",
)
assert response.status_code == 200
template.refresh_from_db()
template_values = serializers.TemplateSerializer(instance=template).data
for key, value in template_values.items():
if key in ["id", "accesses"]:
assert value == old_template_values[key]
else:
assert value == new_template_values[key]
@pytest.mark.parametrize("via", VIA)
def test_api_templates_update_authenticated_owners(via, mock_user_teams):
"""Administrators of a template should be allowed to update it."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role="owner")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="owner"
)
old_template_values = serializers.TemplateSerializer(instance=template).data
new_template_values = serializers.TemplateSerializer(
instance=factories.TemplateFactory()
).data
response = client.put(
f"/api/v1.0/templates/{template.id!s}/", new_template_values, format="json"
)
assert response.status_code == 200
template.refresh_from_db()
template_values = serializers.TemplateSerializer(instance=template).data
for key, value in template_values.items():
if key in ["id", "accesses"]:
assert value == old_template_values[key]
else:
assert value == new_template_values[key]
@pytest.mark.parametrize("via", VIA)
def test_api_templates_update_administrator_or_owner_of_another(via, mock_user_teams):
"""
Being administrator or owner of a template should not grant authorization to update
another template.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(
template=template, user=user, role=random.choice(["administrator", "owner"])
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template,
team="lasuite",
role=random.choice(["administrator", "owner"]),
)
is_public = random.choice([True, False])
template = factories.TemplateFactory(title="Old title", is_public=is_public)
old_template_values = serializers.TemplateSerializer(instance=template).data
new_template_values = serializers.TemplateSerializer(
instance=factories.TemplateFactory()
).data
response = client.put(
f"/api/v1.0/templates/{template.id!s}/",
new_template_values,
format="json",
)
assert response.status_code == 403 if is_public else 404
template.refresh_from_db()
template_values = serializers.TemplateSerializer(instance=template).data
assert template_values == old_template_values
-678
View File
@@ -1,678 +0,0 @@
"""
Test contacts API endpoints in People's core app.
"""
from django.test.utils import override_settings
import pytest
from rest_framework.test import APIClient
from core import factories, models
from core.api import serializers
pytestmark = pytest.mark.django_db
CONTACT_DATA = {
"emails": [
{"type": "Work", "value": "john.doe@work.com"},
{"type": "Home", "value": "john.doe@home.com"},
],
"phones": [
{"type": "Work", "value": "(123) 456-7890"},
{"type": "Other", "value": "(987) 654-3210"},
],
"addresses": [
{
"type": "Home",
"street": "123 Main St",
"city": "Cityville",
"state": "CA",
"zip": "12345",
"country": "USA",
}
],
"links": [
{"type": "Blog", "value": "http://personalwebsite.com"},
{"type": "Website", "value": "http://workwebsite.com"},
],
"customFields": {"custom_field_1": "value1", "custom_field_2": "value2"},
"organizations": [
{
"name": "ACME Corporation",
"department": "IT",
"jobTitle": "Software Engineer",
},
{
"name": "XYZ Ltd",
"department": "Marketing",
"jobTitle": "Marketing Specialist",
},
],
}
def test_api_contacts_list_anonymous():
"""Anonymous users should not be allowed to list contacts."""
factories.ContactFactory.create_batch(2)
response = APIClient().get("/api/v1.0/contacts/")
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_contacts_list_authenticated_no_query():
"""
Authenticated users should be able to list contacts without applying a query.
Profile and base contacts should be excluded.
"""
identity = factories.IdentityFactory()
user = identity.user
contact = factories.ContactFactory(owner=user)
user.profile_contact = contact
user.save()
# Let's have 5 contacts in database:
assert user.profile_contact is not None # Excluded because profile contact
base_contact = factories.BaseContactFactory() # Excluded because overriden
factories.ContactFactory(
base=base_contact
) # Excluded because belongs to other user
contact2 = factories.ContactFactory(
base=base_contact, owner=user, full_name="Bernard"
) # Included
client = APIClient()
client.force_login(user)
response = client.get("/api/v1.0/contacts/")
assert response.status_code == 200
assert response.json() == [
{
"id": str(contact2.id),
"base": str(base_contact.id),
"owner": str(contact2.owner.id),
"data": contact2.data,
"full_name": contact2.full_name,
"short_name": contact2.short_name,
},
]
def test_api_contacts_list_authenticated_by_full_name():
"""
Authenticated users should be able to search users with a case insensitive and
partial query on the full name.
"""
identity = factories.IdentityFactory()
user = identity.user
dave = factories.BaseContactFactory(full_name="David Bowman")
nicole = factories.BaseContactFactory(full_name="Nicole Foole")
frank = factories.BaseContactFactory(full_name="Frank Poole")
factories.BaseContactFactory(full_name="Heywood Floyd")
# Full query should work
client = APIClient()
client.force_login(user)
response = client.get("/api/v1.0/contacts/?q=David%20Bowman")
assert response.status_code == 200
contact_ids = [contact["id"] for contact in response.json()]
assert contact_ids == [str(dave.id)]
# Partial query should work
response = client.get("/api/v1.0/contacts/?q=ank")
assert response.status_code == 200
contact_ids = [contact["id"] for contact in response.json()]
assert contact_ids == [str(frank.id)]
# Result that matches a trigram twice ranks better than result that matches once
response = client.get("/api/v1.0/contacts/?q=ole")
assert response.status_code == 200
contact_ids = [contact["id"] for contact in response.json()]
# "Nicole Foole" matches twice on "ole"
assert contact_ids == [str(nicole.id), str(frank.id)]
response = client.get("/api/v1.0/contacts/?q=ool")
assert response.status_code == 200
contact_ids = [contact["id"] for contact in response.json()]
assert contact_ids == [str(nicole.id), str(frank.id)]
def test_api_contacts_list_authenticated_uppercase_content():
"""Upper case content should be found by lower case query."""
identity = factories.IdentityFactory()
user = identity.user
dave = factories.BaseContactFactory(full_name="EEE", short_name="AAA")
# Unaccented full name
client = APIClient()
client.force_login(user)
response = client.get("/api/v1.0/contacts/?q=eee")
assert response.status_code == 200
contact_ids = [contact["id"] for contact in response.json()]
assert contact_ids == [str(dave.id)]
# Unaccented short name
response = client.get("/api/v1.0/contacts/?q=aaa")
assert response.status_code == 200
contact_ids = [contact["id"] for contact in response.json()]
assert contact_ids == [str(dave.id)]
def test_api_contacts_list_authenticated_capital_query():
"""Upper case query should find lower case content."""
identity = factories.IdentityFactory()
user = identity.user
dave = factories.BaseContactFactory(full_name="eee", short_name="aaa")
client = APIClient()
client.force_login(user)
# Unaccented full name
response = client.get("/api/v1.0/contacts/?q=EEE")
assert response.status_code == 200
contact_ids = [contact["id"] for contact in response.json()]
assert contact_ids == [str(dave.id)]
# Unaccented short name
response = client.get("/api/v1.0/contacts/?q=AAA")
assert response.status_code == 200
contact_ids = [contact["id"] for contact in response.json()]
assert contact_ids == [str(dave.id)]
def test_api_contacts_list_authenticated_accented_content():
"""Accented content should be found by unaccented query."""
identity = factories.IdentityFactory()
user = identity.user
dave = factories.BaseContactFactory(full_name="ééé", short_name="ààà")
client = APIClient()
client.force_login(user)
# Unaccented full name
response = client.get("/api/v1.0/contacts/?q=eee")
assert response.status_code == 200
contact_ids = [contact["id"] for contact in response.json()]
assert contact_ids == [str(dave.id)]
# Unaccented short name
response = client.get("/api/v1.0/contacts/?q=aaa")
assert response.status_code == 200
contact_ids = [contact["id"] for contact in response.json()]
assert contact_ids == [str(dave.id)]
def test_api_contacts_list_authenticated_accented_query():
"""Accented query should find unaccented content."""
identity = factories.IdentityFactory()
user = identity.user
dave = factories.BaseContactFactory(full_name="eee", short_name="aaa")
client = APIClient()
client.force_login(user)
# Unaccented full name
response = client.get("/api/v1.0/contacts/?q=ééé")
assert response.status_code == 200
contact_ids = [contact["id"] for contact in response.json()]
assert contact_ids == [str(dave.id)]
# Unaccented short name
response = client.get("/api/v1.0/contacts/?q=ààà")
assert response.status_code == 200
contact_ids = [contact["id"] for contact in response.json()]
assert contact_ids == [str(dave.id)]
def test_api_contacts_retrieve_anonymous():
"""Anonymous users should not be allowed to retrieve a user."""
client = APIClient()
contact = factories.ContactFactory()
response = client.get(f"/api/v1.0/contacts/{contact.id!s}/")
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_contacts_retrieve_authenticated_owned():
"""
Authenticated users should be allowed to retrieve a contact they own.
"""
identity = factories.IdentityFactory()
user = identity.user
contact = factories.ContactFactory(owner=user)
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/contacts/{contact.id!s}/")
assert response.status_code == 200
assert response.json() == {
"id": str(contact.id),
"base": str(contact.base.id),
"owner": str(contact.owner.id),
"data": contact.data,
"full_name": contact.full_name,
"short_name": contact.short_name,
}
def test_api_contacts_retrieve_authenticated_public():
"""
Authenticated users should be able to retrieve public contacts.
"""
identity = factories.IdentityFactory()
contact = factories.BaseContactFactory()
client = APIClient()
client.force_login(identity.user)
response = client.get(f"/api/v1.0/contacts/{contact.id!s}/")
assert response.status_code == 200
assert response.json() == {
"id": str(contact.id),
"base": None,
"owner": None,
"data": contact.data,
"full_name": contact.full_name,
"short_name": contact.short_name,
}
def test_api_contacts_retrieve_authenticated_other():
"""
Authenticated users should not be allowed to retrieve another user's contacts.
"""
identity = factories.IdentityFactory()
contact = factories.ContactFactory()
client = APIClient()
client.force_login(identity.user)
response = client.get(f"/api/v1.0/contacts/{contact.id!s}/")
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
def test_api_contacts_create_anonymous_forbidden():
"""Anonymous users should not be able to create contacts via the API."""
response = APIClient().post(
"/api/v1.0/contacts/",
{
"full_name": "David",
"short_name": "Bowman",
},
)
assert response.status_code == 401
assert not models.Contact.objects.exists()
def test_api_contacts_create_authenticated_missing_base():
"""Anonymous users should be able to create users."""
identity = factories.IdentityFactory(user__profile_contact=None)
client = APIClient()
client.force_login(identity.user)
response = client.post(
"/api/v1.0/contacts/",
{
"full_name": "David Bowman",
"short_name": "Dave",
},
format="json",
)
assert response.status_code == 400
assert models.Contact.objects.exists() is False
assert response.json() == {"base": ["This field is required."]}
def test_api_contacts_create_authenticated_successful():
"""Authenticated users should be able to create contacts."""
identity = factories.IdentityFactory(user__profile_contact=None)
user = identity.user
base_contact = factories.BaseContactFactory()
client = APIClient()
client.force_login(user)
# Existing override for another user should not interfere
factories.ContactFactory(base=base_contact)
response = client.post(
"/api/v1.0/contacts/",
{
"base": str(base_contact.id),
"full_name": "David Bowman",
"short_name": "Dave",
"data": CONTACT_DATA,
},
format="json",
)
assert response.status_code == 201
assert models.Contact.objects.count() == 3
contact = models.Contact.objects.get(owner=user)
assert response.json() == {
"id": str(contact.id),
"base": str(base_contact.id),
"data": CONTACT_DATA,
"full_name": "David Bowman",
"owner": str(user.id),
"short_name": "Dave",
}
assert contact.full_name == "David Bowman"
assert contact.short_name == "Dave"
assert contact.data == CONTACT_DATA
assert contact.base == base_contact
assert contact.owner == user
@override_settings(ALLOW_API_USER_CREATE=True)
def test_api_contacts_create_authenticated_existing_override():
"""
Trying to create a contact for base contact that is already overriden by the user
should receive a 400 error.
"""
identity = factories.IdentityFactory(user__profile_contact=None)
user = identity.user
base_contact = factories.BaseContactFactory()
factories.ContactFactory(base=base_contact, owner=user)
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/contacts/",
{
"base": str(base_contact.id),
"full_name": "David Bowman",
"short_name": "Dave",
"data": CONTACT_DATA,
},
format="json",
)
assert response.status_code == 400
assert models.Contact.objects.count() == 2
assert response.json() == {
"__all__": ["Contact with this Owner and Base already exists."]
}
def test_api_contacts_update_anonymous():
"""Anonymous users should not be allowed to update a contact."""
contact = factories.ContactFactory()
old_contact_values = serializers.ContactSerializer(instance=contact).data
new_contact_values = serializers.ContactSerializer(
instance=factories.ContactFactory()
).data
new_contact_values["base"] = str(factories.ContactFactory().id)
response = APIClient().put(
f"/api/v1.0/contacts/{contact.id!s}/",
new_contact_values,
format="json",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
contact.refresh_from_db()
contact_values = serializers.ContactSerializer(instance=contact).data
assert contact_values == old_contact_values
def test_api_contacts_update_authenticated_owned():
"""
Authenticated users should be allowed to update their own contacts.
"""
identity = factories.IdentityFactory(user__profile_contact=None)
user = identity.user
client = APIClient()
client.force_login(user)
contact = factories.ContactFactory(owner=user) # Owned by the logged-in user
old_contact_values = serializers.ContactSerializer(instance=contact).data
new_contact_values = serializers.ContactSerializer(
instance=factories.ContactFactory()
).data
new_contact_values["base"] = str(factories.ContactFactory().id)
response = client.put(
f"/api/v1.0/contacts/{contact.id!s}/",
new_contact_values,
format="json",
)
assert response.status_code == 200
contact.refresh_from_db()
contact_values = serializers.ContactSerializer(instance=contact).data
for key, value in contact_values.items():
if key in ["base", "owner", "id"]:
assert value == old_contact_values[key]
else:
assert value == new_contact_values[key]
def test_api_contacts_update_authenticated_profile():
"""
Authenticated users should be allowed to update their profile contact.
"""
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
contact = factories.ContactFactory(owner=user)
user.profile_contact = contact
user.save()
old_contact_values = serializers.ContactSerializer(instance=contact).data
new_contact_values = serializers.ContactSerializer(
instance=factories.ContactFactory()
).data
new_contact_values["base"] = str(factories.ContactFactory().id)
response = client.put(
f"/api/v1.0/contacts/{contact.id!s}/",
new_contact_values,
format="json",
)
assert response.status_code == 200
contact.refresh_from_db()
contact_values = serializers.ContactSerializer(instance=contact).data
for key, value in contact_values.items():
if key in ["base", "owner", "id"]:
assert value == old_contact_values[key]
else:
assert value == new_contact_values[key]
def test_api_contacts_update_authenticated_other():
"""
Authenticated users should not be allowed to update contacts owned by other users.
"""
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
contact = factories.ContactFactory() # owned by another user
old_contact_values = serializers.ContactSerializer(instance=contact).data
new_contact_values = serializers.ContactSerializer(
instance=factories.ContactFactory()
).data
new_contact_values["base"] = str(factories.ContactFactory().id)
response = client.put(
f"/api/v1.0/contacts/{contact.id!s}/",
new_contact_values,
format="json",
)
assert response.status_code == 403
contact.refresh_from_db()
contact_values = serializers.ContactSerializer(instance=contact).data
assert contact_values == old_contact_values
def test_api_contacts_delete_list_anonymous():
"""Anonymous users should not be allowed to delete a list of contacts."""
factories.ContactFactory.create_batch(2)
response = APIClient().delete("/api/v1.0/contacts/")
assert response.status_code == 401
assert models.Contact.objects.count() == 4
def test_api_contacts_delete_list_authenticated():
"""Authenticated users should not be allowed to delete a list of contacts."""
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
factories.ContactFactory.create_batch(2)
response = client.delete("/api/v1.0/contacts/")
assert response.status_code == 405
assert models.Contact.objects.count() == 4
def test_api_contacts_delete_anonymous():
"""Anonymous users should not be allowed to delete a contact."""
contact = factories.ContactFactory()
client = APIClient()
response = client.delete(f"/api/v1.0/contacts/{contact.id!s}/")
assert response.status_code == 401
assert models.Contact.objects.count() == 2
def test_api_contacts_delete_authenticated_public():
"""
Authenticated users should not be allowed to delete a public contact.
"""
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
contact = factories.BaseContactFactory()
response = client.delete(
f"/api/v1.0/contacts/{contact.id!s}/",
)
assert response.status_code == 403
assert models.Contact.objects.count() == 1
def test_api_contacts_delete_authenticated_owner():
"""
Authenticated users should be allowed to delete a contact they own.
"""
identity = factories.IdentityFactory()
user = identity.user
contact = factories.ContactFactory(owner=user)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/contacts/{contact.id!s}/",
)
assert response.status_code == 204
assert models.Contact.objects.count() == 1
assert models.Contact.objects.filter(id=contact.id).exists() is False
def test_api_contacts_delete_authenticated_profile():
"""
Authenticated users should be allowed to delete their profile contact.
"""
identity = factories.IdentityFactory()
user = identity.user
contact = factories.ContactFactory(owner=user, base=None)
user.profile_contact = contact
user.save()
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/contacts/{contact.id!s}/",
)
assert response.status_code == 204
assert models.Contact.objects.exists() is False
def test_api_contacts_delete_authenticated_other():
"""
Authenticated users should not be allowed to delete a contact they don't own.
"""
identity = factories.IdentityFactory()
user = identity.user
contact = factories.ContactFactory()
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/contacts/{contact.id!s}/",
)
assert response.status_code == 403
assert models.Contact.objects.count() == 2
@@ -1,424 +0,0 @@
"""
Unit tests for the Invitation model
"""
import time
import pytest
from rest_framework import status
from rest_framework.test import APIClient
from core import factories
from core.api import serializers
pytestmark = pytest.mark.django_db
def test_api_team_invitations__create__anonymous():
"""Anonymous users should not be able to create invitations."""
team = factories.TeamFactory()
invitation_values = serializers.InvitationSerializer(
factories.InvitationFactory.build()
).data
response = APIClient().post(
f"/api/v1.0/teams/{team.id}/invitations/",
invitation_values,
format="json",
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_team_invitations__create__authenticated_outsider():
"""Users outside of team should not be permitted to invite to team."""
identity = factories.IdentityFactory()
team = factories.TeamFactory()
invitation_values = serializers.InvitationSerializer(
factories.InvitationFactory.build()
).data
client = APIClient()
client.force_login(identity.user)
response = client.post(
f"/api/v1.0/teams/{team.id}/invitations/",
invitation_values,
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
@pytest.mark.parametrize(
"role",
["owner", "administrator"],
)
def test_api_team_invitations__create__privileged_members(role):
"""Owners and administrators should be able to invite new members."""
identity = factories.IdentityFactory()
team = factories.TeamFactory(users=[(identity.user, role)])
invitation_values = serializers.InvitationSerializer(
factories.InvitationFactory.build()
).data
client = APIClient()
client.force_login(identity.user)
response = client.post(
f"/api/v1.0/teams/{team.id}/invitations/",
invitation_values,
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
def test_api_team_invitations__create__members():
"""
Members should not be able to invite new members.
"""
identity = factories.IdentityFactory()
team = factories.TeamFactory(users=[(identity.user, "member")])
invitation_values = serializers.InvitationSerializer(
factories.InvitationFactory.build()
).data
client = APIClient()
client.force_login(identity.user)
response = client.post(
f"/api/v1.0/teams/{team.id}/invitations/",
invitation_values,
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.json() == {
"detail": "You are not allowed to manage invitation for this team."
}
def test_api_team_invitations__create__issuer_and_team_automatically_added():
"""Team and issuer fields should auto-complete."""
identity = factories.IdentityFactory()
team = factories.TeamFactory(users=[(identity.user, "owner")])
# Generate a random invitation
invitation = factories.InvitationFactory.build()
invitation_data = {"email": invitation.email, "role": invitation.role}
client = APIClient()
client.force_login(identity.user)
response = client.post(
f"/api/v1.0/teams/{team.id}/invitations/",
invitation_data,
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
# team and issuer automatically set
assert response.json()["team"] == str(team.id)
assert response.json()["issuer"] == str(identity.user.id)
def test_api_team_invitations__create__cannot_duplicate_invitation():
"""An email should not be invited multiple times to the same team."""
existing_invitation = factories.InvitationFactory()
team = existing_invitation.team
# Grant privileged role on the Team to the user
identity = factories.IdentityFactory()
factories.TeamAccessFactory(team=team, user=identity.user, role="administrator")
# Create a new invitation to the same team with the exact same email address
duplicated_invitation = serializers.InvitationSerializer(
factories.InvitationFactory.build(email=existing_invitation.email)
).data
client = APIClient()
client.force_login(identity.user)
response = client.post(
f"/api/v1.0/teams/{team.id}/invitations/",
duplicated_invitation,
format="json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["__all__"] == [
"Team invitation with this Email address and Team already exists."
]
def test_api_team_invitations__create__cannot_invite_existing_users():
"""
Should not be able to invite already existing users.
"""
user = factories.UserFactory()
team = factories.TeamFactory(users=[(user, "administrator")])
existing_user = factories.IdentityFactory(is_main=True)
# Build an invitation to the email of an exising identity in the db
invitation_values = serializers.InvitationSerializer(
factories.InvitationFactory.build(email=existing_user.email, team=team)
).data
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/teams/{team.id}/invitations/",
invitation_values,
format="json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["email"] == [
"This email is already associated to a registered user."
]
def test_api_team_invitations__list__anonymous_user():
"""Anonymous users should not be able to list invitations."""
team = factories.TeamFactory()
response = APIClient().get(f"/api/v1.0/teams/{team.id}/invitations/")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_api_team_invitations__list__authenticated():
"""
Authenticated user should be able to list invitations
in teams they belong to, including from other issuers.
"""
identity = factories.IdentityFactory()
other_user = factories.UserFactory()
team = factories.TeamFactory(
users=[(identity.user, "administrator"), (other_user, "owner")]
)
invitation = factories.InvitationFactory(
team=team, role="administrator", issuer=identity.user
)
other_invitations = factories.InvitationFactory.create_batch(
2, team=team, role="member", issuer=other_user
)
# invitations from other teams should not be listed
other_team = factories.TeamFactory()
factories.InvitationFactory.create_batch(2, team=other_team, role="member")
client = APIClient()
client.force_login(identity.user)
response = client.get(
f"/api/v1.0/teams/{team.id}/invitations/",
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["count"] == 3
assert sorted(response.json()["results"], key=lambda x: x["created_at"]) == sorted(
[
{
"id": str(i.id),
"created_at": i.created_at.isoformat().replace("+00:00", "Z"),
"email": str(i.email),
"team": str(team.id),
"role": i.role,
"issuer": str(i.issuer.id),
"is_expired": False,
}
for i in [invitation, *other_invitations]
],
key=lambda x: x["created_at"],
)
def test_api_team_invitations__list__expired_invitations_still_listed(settings):
"""
Expired invitations are still listed.
"""
identity = factories.IdentityFactory()
other_user = factories.UserFactory()
team = factories.TeamFactory(
users=[(identity.user, "administrator"), (other_user, "owner")]
)
# override settings to accelerate validation expiration
settings.INVITATION_VALIDITY_DURATION = 1 # second
expired_invitation = factories.InvitationFactory(
team=team,
role="member",
issuer=identity.user,
)
time.sleep(1)
client = APIClient()
client.force_login(identity.user)
response = client.get(
f"/api/v1.0/teams/{team.id}/invitations/",
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["count"] == 1
assert sorted(response.json()["results"], key=lambda x: x["created_at"]) == sorted(
[
{
"id": str(expired_invitation.id),
"created_at": expired_invitation.created_at.isoformat().replace(
"+00:00", "Z"
),
"email": str(expired_invitation.email),
"team": str(team.id),
"role": expired_invitation.role,
"issuer": str(expired_invitation.issuer.id),
"is_expired": True,
},
],
key=lambda x: x["created_at"],
)
def test_api_team_invitations__retrieve__anonymous_user():
"""
Anonymous user should not be able to retrieve invitations.
"""
invitation = factories.InvitationFactory()
response = APIClient().get(
f"/api/v1.0/teams/{invitation.team.id}/invitations/{invitation.id}/",
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_api_team_invitations__retrieve__unrelated_user():
"""
Authenticated unrelated users should not be able to retrieve invitations.
"""
user = factories.IdentityFactory(user=factories.UserFactory()).user
invitation = factories.InvitationFactory()
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/teams/{invitation.team.id}/invitations/{invitation.id}/",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_api_team_invitations__retrieve__team_member():
"""
Authenticated team members should be able to retrieve invitations
whatever their role in the team.
"""
user = factories.IdentityFactory(user=factories.UserFactory()).user
invitation = factories.InvitationFactory()
factories.TeamAccessFactory(team=invitation.team, user=user, role="member")
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/teams/{invitation.team.id}/invitations/{invitation.id}/",
)
assert response.status_code == status.HTTP_200_OK
assert response.json() == {
"id": str(invitation.id),
"created_at": invitation.created_at.isoformat().replace("+00:00", "Z"),
"email": invitation.email,
"team": str(invitation.team.id),
"role": str(invitation.role),
"issuer": str(invitation.issuer.id),
"is_expired": False,
}
@pytest.mark.parametrize(
"method",
["put", "patch"],
)
def test_api_team_invitations__update__forbidden(method):
"""
Update of invitations is currently forbidden.
"""
user = factories.IdentityFactory(user=factories.UserFactory()).user
invitation = factories.InvitationFactory()
factories.TeamAccessFactory(team=invitation.team, user=user, role="owner")
client = APIClient()
client.force_login(user)
response = None
if method == "put":
response = client.put(
f"/api/v1.0/teams/{invitation.team.id}/invitations/{invitation.id}/",
)
if method == "patch":
response = client.patch(
f"/api/v1.0/teams/{invitation.team.id}/invitations/{invitation.id}/",
)
assert response is not None
assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
assert response.json()["detail"] == f'Method "{method.upper()}" not allowed.'
def test_api_team_invitations__delete__anonymous():
"""Anonymous user should not be able to delete invitations."""
invitation = factories.InvitationFactory()
response = APIClient().delete(
f"/api/v1.0/teams/{invitation.team.id}/invitations/{invitation.id}/",
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_api_team_invitations__delete__authenticated_outsider():
"""Members outside of team should not cancel invitations."""
identity = factories.IdentityFactory()
team = factories.TeamFactory()
invitation = factories.InvitationFactory(team=team)
client = APIClient()
client.force_login(identity.user)
response = client.delete(
f"/api/v1.0/teams/{team.id}/invitations/{invitation.id}/",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
@pytest.mark.parametrize("role", ["owner", "administrator"])
def test_api_team_invitations__delete__privileged_members(role):
"""Privileged member should be able to cancel invitation."""
identity = factories.IdentityFactory()
team = factories.TeamFactory(users=[(identity.user, role)])
invitation = factories.InvitationFactory(team=team)
client = APIClient()
client.force_login(identity.user)
response = client.delete(
f"/api/v1.0/teams/{team.id}/invitations/{invitation.id}/",
)
assert response.status_code == status.HTTP_204_NO_CONTENT
def test_api_team_invitations__delete__members():
"""Member should not be able to cancel invitation."""
identity = factories.IdentityFactory()
team = factories.TeamFactory(users=[(identity.user, "member")])
invitation = factories.InvitationFactory(team=team)
client = APIClient()
client.force_login(identity.user)
response = client.delete(
f"/api/v1.0/teams/{team.id}/invitations/{invitation.id}/",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert (
response.json()["detail"]
== "You do not have permission to perform this action."
)
@@ -0,0 +1,974 @@
"""
Test template accesses API endpoints for users in impress's core app.
"""
import random
from uuid import uuid4
import pytest
from rest_framework.test import APIClient
from core import factories, models
from core.api import serializers
from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
def test_api_template_accesses_list_anonymous():
"""Anonymous users should not be allowed to list template accesses."""
template = factories.TemplateFactory()
factories.UserTemplateAccessFactory.create_batch(2, template=template)
response = APIClient().get(f"/api/v1.0/templates/{template.id!s}/accesses/")
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_template_accesses_list_authenticated_unrelated():
"""
Authenticated users should not be allowed to list template accesses for a template
to which they are not related.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
factories.UserTemplateAccessFactory.create_batch(3, template=template)
# Accesses for other templates to which the user is related should not be listed either
other_access = factories.UserTemplateAccessFactory(user=user)
factories.UserTemplateAccessFactory(template=other_access.template)
response = client.get(
f"/api/v1.0/templates/{template.id!s}/accesses/",
)
assert response.status_code == 200
assert response.json() == {
"count": 0,
"next": None,
"previous": None,
"results": [],
}
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_list_authenticated_related(via, mock_user_teams):
"""
Authenticated users should be able to list template accesses for a template
to which they are directly related, whatever their role in the template.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
user_access = None
if via == USER:
user_access = models.TemplateAccess.objects.create(
template=template,
user=user,
role=random.choice(models.RoleChoices.choices)[0],
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
user_access = models.TemplateAccess.objects.create(
template=template,
team="lasuite",
role=random.choice(models.RoleChoices.choices)[0],
)
access1 = factories.TeamTemplateAccessFactory(template=template)
access2 = factories.UserTemplateAccessFactory(template=template)
# Accesses for other templates to which the user is related should not be listed either
other_access = factories.UserTemplateAccessFactory(user=user)
factories.UserTemplateAccessFactory(template=other_access.template)
response = client.get(
f"/api/v1.0/templates/{template.id!s}/accesses/",
)
assert response.status_code == 200
content = response.json()
assert len(content["results"]) == 3
assert sorted(content["results"], key=lambda x: x["id"]) == sorted(
[
{
"id": str(user_access.id),
"user": str(user.id) if via == "user" else None,
"team": "lasuite" if via == "team" else "",
"role": user_access.role,
"abilities": user_access.get_abilities(user),
},
{
"id": str(access1.id),
"user": None,
"team": access1.team,
"role": access1.role,
"abilities": access1.get_abilities(user),
},
{
"id": str(access2.id),
"user": str(access2.user.id),
"team": "",
"role": access2.role,
"abilities": access2.get_abilities(user),
},
],
key=lambda x: x["id"],
)
def test_api_template_accesses_retrieve_anonymous():
"""
Anonymous users should not be allowed to retrieve a template access.
"""
access = factories.UserTemplateAccessFactory()
response = APIClient().get(
f"/api/v1.0/templates/{access.template_id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_template_accesses_retrieve_authenticated_unrelated():
"""
Authenticated users should not be allowed to retrieve a template access for
a template to which they are not related.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
access = factories.UserTemplateAccessFactory(template=template)
response = client.get(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
# Accesses related to another template should be excluded even if the user is related to it
for access in [
factories.UserTemplateAccessFactory(),
factories.UserTemplateAccessFactory(user=user),
]:
response = client.get(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 404
assert response.json() == {
"detail": "No TemplateAccess matches the given query."
}
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_retrieve_authenticated_related(via, mock_user_teams):
"""
A user who is related to a template should be allowed to retrieve the
associated template user accesses.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(template=template, team="lasuite")
access = factories.UserTemplateAccessFactory(template=template)
response = client.get(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 200
assert response.json() == {
"id": str(access.id),
"user": str(access.user.id),
"team": "",
"role": access.role,
"abilities": access.get_abilities(user),
}
def test_api_template_accesses_create_anonymous():
"""Anonymous users should not be allowed to create template accesses."""
user = factories.UserFactory()
template = factories.TemplateFactory()
response = APIClient().post(
f"/api/v1.0/templates/{template.id!s}/accesses/",
{
"user": str(user.id),
"template": str(template.id),
"role": random.choice(models.RoleChoices.choices)[0],
},
format="json",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
assert models.TemplateAccess.objects.exists() is False
def test_api_template_accesses_create_authenticated_unrelated():
"""
Authenticated users should not be allowed to create template accesses for a template to
which they are not related.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
other_user = factories.UserFactory()
template = factories.TemplateFactory()
response = client.post(
f"/api/v1.0/templates/{template.id!s}/accesses/",
{
"user": str(other_user.id),
},
format="json",
)
assert response.status_code == 403
assert not models.TemplateAccess.objects.filter(user=other_user).exists()
@pytest.mark.parametrize("role", ["reader", "editor"])
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_create_authenticated_editor_or_reader(
via, role, mock_user_teams
):
"""Editors or readers of a template should not be allowed to create template accesses."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role=role
)
other_user = factories.UserFactory()
for new_role in [role[0] for role in models.RoleChoices.choices]:
response = client.post(
f"/api/v1.0/templates/{template.id!s}/accesses/",
{
"user": str(other_user.id),
"role": new_role,
},
format="json",
)
assert response.status_code == 403
assert not models.TemplateAccess.objects.filter(user=other_user).exists()
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_create_authenticated_administrator(via, mock_user_teams):
"""
Administrators of a template should be able to create template accesses
except for the "owner" role.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(
template=template, user=user, role="administrator"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="administrator"
)
other_user = factories.UserFactory()
# It should not be allowed to create an owner access
response = client.post(
f"/api/v1.0/templates/{template.id!s}/accesses/",
{
"user": str(other_user.id),
"role": "owner",
},
format="json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "Only owners of a resource can assign other users as owners."
}
# It should be allowed to create a lower access
role = random.choice(
[role[0] for role in models.RoleChoices.choices if role[0] != "owner"]
)
response = client.post(
f"/api/v1.0/templates/{template.id!s}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == 201
assert models.TemplateAccess.objects.filter(user=other_user).count() == 1
new_template_access = models.TemplateAccess.objects.filter(user=other_user).get()
assert response.json() == {
"abilities": new_template_access.get_abilities(user),
"id": str(new_template_access.id),
"team": "",
"role": role,
"user": str(other_user.id),
}
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_create_authenticated_owner(via, mock_user_teams):
"""
Owners of a template should be able to create template accesses whatever the role.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role="owner")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="owner"
)
other_user = factories.UserFactory()
role = random.choice([role[0] for role in models.RoleChoices.choices])
response = client.post(
f"/api/v1.0/templates/{template.id!s}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == 201
assert models.TemplateAccess.objects.filter(user=other_user).count() == 1
new_template_access = models.TemplateAccess.objects.filter(user=other_user).get()
assert response.json() == {
"id": str(new_template_access.id),
"user": str(other_user.id),
"team": "",
"role": role,
"abilities": new_template_access.get_abilities(user),
}
def test_api_template_accesses_update_anonymous():
"""Anonymous users should not be allowed to update a template access."""
access = factories.UserTemplateAccessFactory()
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
}
api_client = APIClient()
for field, value in new_values.items():
response = api_client.put(
f"/api/v1.0/templates/{access.template_id!s}/accesses/{access.id!s}/",
{**old_values, field: value},
format="json",
)
assert response.status_code == 401
access.refresh_from_db()
updated_values = serializers.TemplateAccessSerializer(instance=access).data
assert updated_values == old_values
def test_api_template_accesses_update_authenticated_unrelated():
"""
Authenticated users should not be allowed to update a template access for a template to which
they are not related.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
access = factories.UserTemplateAccessFactory()
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
}
for field, value in new_values.items():
response = client.put(
f"/api/v1.0/templates/{access.template_id!s}/accesses/{access.id!s}/",
{**old_values, field: value},
format="json",
)
assert response.status_code == 403
access.refresh_from_db()
updated_values = serializers.TemplateAccessSerializer(instance=access).data
assert updated_values == old_values
@pytest.mark.parametrize("role", ["reader", "editor"])
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_update_authenticated_editor_or_reader(
via, role, mock_user_teams
):
"""Editors or readers of a template should not be allowed to update its accesses."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role=role
)
access = factories.UserTemplateAccessFactory(template=template)
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
}
for field, value in new_values.items():
response = client.put(
f"/api/v1.0/templates/{access.template_id!s}/accesses/{access.id!s}/",
{**old_values, field: value},
format="json",
)
assert response.status_code == 403
access.refresh_from_db()
updated_values = serializers.TemplateAccessSerializer(instance=access).data
assert updated_values == old_values
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_update_administrator_except_owner(via, mock_user_teams):
"""
A user who is a direct administrator in a template should be allowed to update a user
access for this template, as long as they don't try to set the role to owner.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(
template=template, user=user, role="administrator"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="administrator"
)
access = factories.UserTemplateAccessFactory(
template=template,
role=random.choice(["administrator", "editor", "reader"]),
)
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(["administrator", "editor", "reader"]),
}
for field, value in new_values.items():
new_data = {**old_values, field: value}
response = client.put(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
)
if (
new_data["role"] == old_values["role"]
): # we are not really updating the role
assert response.status_code == 403
else:
assert response.status_code == 200
access.refresh_from_db()
updated_values = serializers.TemplateAccessSerializer(instance=access).data
if field == "role":
assert updated_values == {**old_values, "role": new_values["role"]}
else:
assert updated_values == old_values
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_update_administrator_from_owner(via, mock_user_teams):
"""
A user who is an administrator in a template, should not be allowed to update
the user access of an "owner" for this template.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(
template=template, user=user, role="administrator"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="administrator"
)
other_user = factories.UserFactory()
access = factories.UserTemplateAccessFactory(
template=template, user=other_user, role="owner"
)
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
}
for field, value in new_values.items():
response = client.put(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
data={**old_values, field: value},
format="json",
)
assert response.status_code == 403
access.refresh_from_db()
updated_values = serializers.TemplateAccessSerializer(instance=access).data
assert updated_values == old_values
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_update_administrator_to_owner(via, mock_user_teams):
"""
A user who is an administrator in a template, should not be allowed to update
the user access of another user to grant template ownership.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(
template=template, user=user, role="administrator"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="administrator"
)
other_user = factories.UserFactory()
access = factories.UserTemplateAccessFactory(
template=template,
user=other_user,
role=random.choice(["administrator", "editor", "reader"]),
)
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": "owner",
}
for field, value in new_values.items():
new_data = {**old_values, field: value}
response = client.put(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
)
# We are not allowed or not really updating the role
if field == "role" or new_data["role"] == old_values["role"]:
assert response.status_code == 403
else:
assert response.status_code == 200
access.refresh_from_db()
updated_values = serializers.TemplateAccessSerializer(instance=access).data
assert updated_values == old_values
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_update_owner(via, mock_user_teams):
"""
A user who is an owner in a template should be allowed to update
a user access for this template whatever the role.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role="owner")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="owner"
)
factories.UserFactory()
access = factories.UserTemplateAccessFactory(
template=template,
)
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
}
for field, value in new_values.items():
new_data = {**old_values, field: value}
response = client.put(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
)
if (
new_data["role"] == old_values["role"]
): # we are not really updating the role
assert response.status_code == 403
else:
assert response.status_code == 200
access.refresh_from_db()
updated_values = serializers.TemplateAccessSerializer(instance=access).data
if field == "role":
assert updated_values == {**old_values, "role": new_values["role"]}
else:
assert updated_values == old_values
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_update_owner_self(via, mock_user_teams):
"""
A user who is owner of a template should be allowed to update
their own user access provided there are other owners in the template.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
access = None
if via == USER:
access = factories.UserTemplateAccessFactory(
template=template, user=user, role="owner"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
access = factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="owner"
)
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_role = random.choice(["administrator", "editor", "reader"])
response = client.put(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
data={**old_values, "role": new_role},
format="json",
)
assert response.status_code == 403
access.refresh_from_db()
assert access.role == "owner"
# Add another owner and it should now work
factories.UserTemplateAccessFactory(template=template, role="owner")
response = client.put(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
data={**old_values, "role": new_role},
format="json",
)
assert response.status_code == 200
access.refresh_from_db()
assert access.role == new_role
# Delete
def test_api_template_accesses_delete_anonymous():
"""Anonymous users should not be allowed to destroy a template access."""
access = factories.UserTemplateAccessFactory()
response = APIClient().delete(
f"/api/v1.0/templates/{access.template_id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 401
assert models.TemplateAccess.objects.count() == 1
def test_api_template_accesses_delete_authenticated():
"""
Authenticated users should not be allowed to delete a template access for a
template to which they are not related.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
access = factories.UserTemplateAccessFactory()
response = client.delete(
f"/api/v1.0/templates/{access.template_id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 403
assert models.TemplateAccess.objects.count() == 1
@pytest.mark.parametrize("role", ["reader", "editor"])
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_delete_editor_or_reader(via, role, mock_user_teams):
"""
Authenticated users should not be allowed to delete a template access for a
template in which they are a simple editor or reader.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role=role
)
access = factories.UserTemplateAccessFactory(template=template)
assert models.TemplateAccess.objects.count() == 2
assert models.TemplateAccess.objects.filter(user=access.user).exists()
response = client.delete(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 403
assert models.TemplateAccess.objects.count() == 2
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_delete_administrators_except_owners(
via, mock_user_teams
):
"""
Users who are administrators in a template should be allowed to delete an access
from the template provided it is not ownership.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(
template=template, user=user, role="administrator"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="administrator"
)
access = factories.UserTemplateAccessFactory(
template=template, role=random.choice(["reader", "editor", "administrator"])
)
assert models.TemplateAccess.objects.count() == 2
assert models.TemplateAccess.objects.filter(user=access.user).exists()
response = client.delete(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 204
assert models.TemplateAccess.objects.count() == 1
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_delete_administrator_on_owners(via, mock_user_teams):
"""
Users who are administrators in a template should not be allowed to delete an ownership
access from the template.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(
template=template, user=user, role="administrator"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="administrator"
)
access = factories.UserTemplateAccessFactory(template=template, role="owner")
assert models.TemplateAccess.objects.count() == 2
assert models.TemplateAccess.objects.filter(user=access.user).exists()
response = client.delete(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 403
assert models.TemplateAccess.objects.count() == 2
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_delete_owners(via, mock_user_teams):
"""
Users should be able to delete the template access of another user
for a template of which they are owner.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role="owner")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="owner"
)
access = factories.UserTemplateAccessFactory(template=template)
assert models.TemplateAccess.objects.count() == 2
assert models.TemplateAccess.objects.filter(user=access.user).exists()
response = client.delete(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 204
assert models.TemplateAccess.objects.count() == 1
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_delete_owners_last_owner(via, mock_user_teams):
"""
It should not be possible to delete the last owner access from a template
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
access = None
if via == USER:
access = factories.UserTemplateAccessFactory(
template=template, user=user, role="owner"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
access = factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="owner"
)
assert models.TemplateAccess.objects.count() == 1
response = client.delete(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 403
assert models.TemplateAccess.objects.count() == 1
+87 -556
View File
@@ -1,21 +1,12 @@
"""
Test users API endpoints in the People core app.
Test users API endpoints in the impress core app.
"""
from unittest import mock
import pytest
from rest_framework.status import (
HTTP_200_OK,
HTTP_401_UNAUTHORIZED,
HTTP_403_FORBIDDEN,
HTTP_405_METHOD_NOT_ALLOWED,
)
from rest_framework.test import APIClient
from core import factories, models
from core.api import serializers
from core.api.viewsets import Pagination
pytestmark = pytest.mark.django_db
@@ -25,529 +16,81 @@ def test_api_users_list_anonymous():
factories.UserFactory()
client = APIClient()
response = client.get("/api/v1.0/users/")
assert response.status_code == HTTP_401_UNAUTHORIZED
assert "Authentication credentials were not provided." in response.content.decode(
"utf-8"
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_users_list_authenticated():
"""
Authenticated users should be able to list all users.
Authenticated users should be able to list users.
"""
identity = factories.IdentityFactory()
user = factories.UserFactory()
client = APIClient()
client.force_login(identity.user)
client.force_login(user)
factories.UserFactory.create_batch(2)
response = client.get(
"/api/v1.0/users/",
)
assert response.status_code == HTTP_200_OK
assert len(response.json()["results"]) == 3
def test_api_users_authenticated_list_by_email():
"""
Authenticated users should be able to search users with a case-insensitive and
partial query on the email.
"""
user = factories.UserFactory(admin_email="tester@ministry.fr")
factories.IdentityFactory(user=user, email=user.admin_email, name="john doe")
client = APIClient()
client.force_login(user)
dave = factories.IdentityFactory(
email="david.bowman@work.com", name=None, is_main=True
)
nicole = factories.IdentityFactory(
email="nicole_foole@work.com", name=None, is_main=True
)
frank = factories.IdentityFactory(
email="frank_poole@work.com", name=None, is_main=True
)
factories.IdentityFactory(email="heywood_floyd@work.com", name=None, is_main=True)
# Full query should work
response = client.get(
"/api/v1.0/users/?q=david.bowman@work.com",
)
assert response.status_code == HTTP_200_OK
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids[0] == str(dave.user.id)
# Partial query should work
response = client.get("/api/v1.0/users/?q=fran")
assert response.status_code == HTTP_200_OK
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids[0] == str(frank.user.id)
# Result that matches a trigram twice ranks better than result that matches once
response = client.get("/api/v1.0/users/?q=ole")
assert response.status_code == HTTP_200_OK
user_ids = [user["id"] for user in response.json()["results"]]
# "Nicole Foole" matches twice on "ole"
assert user_ids == [str(nicole.user.id), str(frank.user.id)]
# Even with a low similarity threshold, query should match expected emails
response = client.get("/api/v1.0/users/?q=ool")
assert response.status_code == HTTP_200_OK
assert response.json()["results"] == [
{
"id": str(nicole.user.id),
"email": nicole.email,
"name": nicole.name,
"is_device": nicole.user.is_device,
"is_staff": nicole.user.is_staff,
"language": nicole.user.language,
"timezone": str(nicole.user.timezone),
},
{
"id": str(frank.user.id),
"email": frank.email,
"name": frank.name,
"is_device": frank.user.is_device,
"is_staff": frank.user.is_staff,
"language": frank.user.language,
"timezone": str(frank.user.timezone),
},
]
def test_api_users_authenticated_list_by_name():
"""
Authenticated users should be able to search users with a case-insensitive and
partial query on the name.
"""
user = factories.UserFactory(admin_email="tester@ministry.fr")
factories.IdentityFactory(user=user, email=user.admin_email, name="john doe")
client = APIClient()
client.force_login(user)
dave = factories.IdentityFactory(name="dave bowman", email=None, is_main=True)
nicole = factories.IdentityFactory(name="nicole foole", email=None, is_main=True)
frank = factories.IdentityFactory(name="frank poole", email=None, is_main=True)
factories.IdentityFactory(name="heywood floyd", email=None, is_main=True)
# Full query should work
response = client.get(
"/api/v1.0/users/?q=david.bowman@work.com",
)
assert response.status_code == HTTP_200_OK
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids[0] == str(dave.user.id)
# Partial query should work
response = client.get("/api/v1.0/users/?q=fran")
assert response.status_code == HTTP_200_OK
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids[0] == str(frank.user.id)
# Result that matches a trigram twice ranks better than result that matches once
response = client.get("/api/v1.0/users/?q=ole")
assert response.status_code == HTTP_200_OK
user_ids = [user["id"] for user in response.json()["results"]]
# "Nicole Foole" matches twice on "ole"
assert user_ids == [str(nicole.user.id), str(frank.user.id)]
# Even with a low similarity threshold, query should match expected user
response = client.get("/api/v1.0/users/?q=ool")
assert response.status_code == HTTP_200_OK
assert response.json()["results"] == [
{
"id": str(nicole.user.id),
"email": nicole.email,
"name": nicole.name,
"is_device": nicole.user.is_device,
"is_staff": nicole.user.is_staff,
"language": nicole.user.language,
"timezone": str(nicole.user.timezone),
},
{
"id": str(frank.user.id),
"email": frank.email,
"name": frank.name,
"is_device": frank.user.is_device,
"is_staff": frank.user.is_staff,
"language": frank.user.language,
"timezone": str(frank.user.timezone),
},
]
def test_api_users_authenticated_list_by_name_and_email():
"""
Authenticated users should be able to search users with a case-insensitive and
partial query on the name and email.
"""
user = factories.UserFactory(admin_email="tester@ministry.fr")
factories.IdentityFactory(user=user, email=user.admin_email, name="john doe")
client = APIClient()
client.force_login(user)
nicole = factories.IdentityFactory(
email="nicole_foole@work.com", name="nicole foole", is_main=True
)
frank = factories.IdentityFactory(
email="oleg_poole@work.com", name=None, is_main=True
)
david = factories.IdentityFactory(email=None, name="david role", is_main=True)
# Result that matches a trigram in name and email ranks better than result that matches once
response = client.get("/api/v1.0/users/?q=ole")
assert response.status_code == HTTP_200_OK
user_ids = [user["id"] for user in response.json()["results"]]
# "Nicole Foole" matches twice on "ole" in her name and twice on "ole" in her email
# "Oleg poole" matches twice on "ole" in her email
# "David role" matches once on "ole" in his name
assert user_ids == [str(nicole.user.id), str(frank.user.id), str(david.user.id)]
def test_api_users_authenticated_list_exclude_users_already_in_team(
django_assert_num_queries,
):
"""
Authenticated users should be able to search users
but the result should exclude all users already in the given team.
"""
user = factories.UserFactory(admin_email="tester@ministry.fr")
factories.IdentityFactory(user=user, email=user.email, name="john doe")
client = APIClient()
client.force_login(user)
dave = factories.IdentityFactory(name="dave bowman", email=None, is_main=True)
nicole = factories.IdentityFactory(name="nicole foole", email=None, is_main=True)
frank = factories.IdentityFactory(name="frank poole", email=None, is_main=True)
mary = factories.IdentityFactory(name="mary poole", email=None, is_main=True)
factories.IdentityFactory(name="heywood floyd", email=None, is_main=True)
factories.IdentityFactory(name="Andrei Smyslov", email=None, is_main=True)
factories.TeamFactory.create_batch(10)
# Add Dave and Frank in the same team
team = factories.TeamFactory(
users=[
(dave.user, models.RoleChoices.MEMBER),
(frank.user, models.RoleChoices.MEMBER),
]
)
factories.TeamFactory(users=[(nicole.user, models.RoleChoices.MEMBER)])
# Search user to add him/her to a team, we give a team id to the request
# to exclude all users already in the team
# We can't find David Bowman because he is already a member of the given team
# 2 queries are needed here:
# - user authenticated
# - search user query
with django_assert_num_queries(2):
response = client.get(
f"/api/v1.0/users/?q=bowman&team_id={team.id}",
)
assert response.status_code == HTTP_200_OK
assert response.json()["results"] == []
# We can only find Nicole and Mary because Frank is already a member of the given team
# 4 queries are needed here:
# - user authenticated
# - search user query
# - User
# - Identity
with django_assert_num_queries(4):
response = client.get(
f"/api/v1.0/users/?q=ool&team_id={team.id}",
)
assert response.status_code == HTTP_200_OK
user_ids = sorted([user["id"] for user in response.json()["results"]])
assert user_ids == sorted([str(mary.user.id), str(nicole.user.id)])
def test_api_users_authenticated_list_multiple_identities_single_user():
"""
User with multiple identities should appear only once in results.
"""
user = factories.UserFactory(admin_email="tester@ministry.fr")
factories.IdentityFactory(user=user, email=user.admin_email, name="eva karl")
client = APIClient()
client.force_login(user)
dave = factories.UserFactory()
factories.IdentityFactory(
user=dave, email="dave.bowman@work.com", name="dave bowman"
)
factories.IdentityFactory(user=dave, email="dave.bowman@fun.fr", name="dave bowman")
# Full query should work
response = client.get(
"/api/v1.0/users/?q=david.bowman@work.com",
)
assert response.status_code == HTTP_200_OK
# A single user is returned, despite similarity matching both emails
assert response.json()["count"] == 1
assert response.json()["results"][0]["id"] == str(dave.id)
def test_api_users_authenticated_list_multiple_identities_multiple_users():
"""
User with multiple identities should be ranked
on their best matching identity.
"""
user = factories.UserFactory(admin_email="tester@ministry.fr")
factories.IdentityFactory(user=user, email=user.admin_email, name="john doe")
client = APIClient()
client.force_login(user)
dave = factories.UserFactory()
dave_identity = factories.IdentityFactory(
user=dave, email="dave.bowman@work.com", is_main=True, name="dave bowman"
)
factories.IdentityFactory(user=dave, email="babibou@ehehe.com", name="babihou")
davina_identity = factories.IdentityFactory(
user=factories.UserFactory(), email="davina.bowan@work.com", name="davina"
)
prue_identity = factories.IdentityFactory(
user=factories.UserFactory(),
email="prudence.crandall@work.com",
name="prudence",
)
# Full query should work
response = client.get(
"/api/v1.0/users/?q=david.bowman@work.com",
)
assert response.status_code == HTTP_200_OK
assert response.json()["count"] == 3
assert response.json()["results"] == [
{
"id": str(dave.id),
"email": dave_identity.email,
"name": dave_identity.name,
"is_device": dave_identity.user.is_device,
"is_staff": dave_identity.user.is_staff,
"language": dave_identity.user.language,
"timezone": str(dave_identity.user.timezone),
},
{
"id": str(davina_identity.user.id),
"email": davina_identity.email,
"name": davina_identity.name,
"is_device": davina_identity.user.is_device,
"is_staff": davina_identity.user.is_staff,
"language": davina_identity.user.language,
"timezone": str(davina_identity.user.timezone),
},
{
"id": str(prue_identity.user.id),
"email": prue_identity.email,
"name": prue_identity.name,
"is_device": prue_identity.user.is_device,
"is_staff": prue_identity.user.is_staff,
"language": prue_identity.user.language,
"timezone": str(prue_identity.user.timezone),
},
]
def test_api_users_authenticated_list_uppercase_content():
"""Upper case content should be found by lower case query."""
user = factories.UserFactory(admin_email="tester@ministry.fr")
factories.IdentityFactory(user=user, email=user.admin_email, name="eva karl")
client = APIClient()
client.force_login(user)
dave = factories.IdentityFactory(
email="DAVID.BOWMAN@INTENSEWORK.COM", name="DAVID BOWMAN"
)
# Unaccented full address
response = client.get(
"/api/v1.0/users/?q=david.bowman@intensework.com",
)
assert response.status_code == HTTP_200_OK
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(dave.user.id)]
# Partial query
response = client.get(
"/api/v1.0/users/?q=david",
)
assert response.status_code == HTTP_200_OK
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(dave.user.id)]
def test_api_users_list_authenticated_capital_query():
"""Upper case query should find lower case content."""
user = factories.UserFactory(admin_email="tester@ministry.fr")
factories.IdentityFactory(user=user, email=user.admin_email, name="eva karl")
client = APIClient()
client.force_login(user)
dave = factories.IdentityFactory(email="david.bowman@work.com", name="david bowman")
# Full uppercase query
response = client.get(
"/api/v1.0/users/?q=DAVID.BOWMAN@WORK.COM",
)
assert response.status_code == HTTP_200_OK
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(dave.user.id)]
# Partial uppercase email
response = client.get(
"/api/v1.0/users/?q=DAVID",
)
assert response.status_code == HTTP_200_OK
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(dave.user.id)]
def test_api_contacts_list_authenticated_accented_query():
"""Accented content should be found by unaccented query."""
user = factories.UserFactory(admin_email="tester@ministry.fr")
factories.IdentityFactory(user=user, email=user.admin_email, name="john doe")
client = APIClient()
client.force_login(user)
helene = factories.IdentityFactory(
email="helene.bowman@work.com", name="helene bowman"
)
# Accented full query
response = client.get(
"/api/v1.0/users/?q=hélène.bowman@work.com",
)
assert response.status_code == HTTP_200_OK
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(helene.user.id)]
# Unaccented partial email
response = client.get(
"/api/v1.0/users/?q=hélène",
)
assert response.status_code == HTTP_200_OK
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(helene.user.id)]
@mock.patch.object(Pagination, "get_page_size", return_value=3)
def test_api_users_list_pagination(
_mock_page_size,
):
"""Pagination should work as expected."""
identity = factories.IdentityFactory()
user = identity.user
client = APIClient()
client.force_login(user)
factories.UserFactory.create_batch(4)
# Get page 1
response = client.get(
"/api/v1.0/users/",
)
assert response.status_code == HTTP_200_OK
assert response.status_code == 200
content = response.json()
assert content["count"] == 5
assert len(content["results"]) == 3
assert content["next"] == "http://testserver/api/v1.0/users/?page=2"
assert content["previous"] is None
# Get page 2
response = client.get(
"/api/v1.0/users/?page=2",
)
assert response.status_code == HTTP_200_OK
content = response.json()
assert content["count"] == 5
assert content["next"] is None
assert content["previous"] == "http://testserver/api/v1.0/users/"
assert len(content["results"]) == 2
@pytest.mark.parametrize("page_size", [1, 10, 99, 100])
def test_api_users_list_pagination_page_size(
page_size,
):
"""Page's size on pagination should work as expected."""
identity = factories.IdentityFactory()
user = identity.user
def test_api_users_list_query_email():
"""
Authenticated users should be able to list users
and filter by email.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
for i in range(page_size):
factories.UserFactory.create(admin_email=f"user-{i}@people.com")
dave = factories.UserFactory(email="david.bowman@work.com")
nicole = factories.UserFactory(email="nicole_foole@work.com")
frank = factories.UserFactory(email="frank_poole@work.com")
factories.UserFactory(email="heywood_floyd@work.com")
response = client.get(
f"/api/v1.0/users/?page_size={page_size}",
"/api/v1.0/users/?q=david.bowman@work.com",
)
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(dave.id)]
assert response.status_code == HTTP_200_OK
content = response.json()
response = client.get("/api/v1.0/users/?q=oole")
assert content["count"] == page_size + 1
assert len(content["results"]) == page_size
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(nicole.id), str(frank.id)]
@pytest.mark.parametrize("page_size", [101, 200])
def test_api_users_list_pagination_wrong_page_size(
page_size,
):
"""Page's size on pagination should be limited to "max_page_size"."""
identity = factories.IdentityFactory()
user = identity.user
def test_api_users_list_query_email_exclude_doc_user():
"""
Authenticated users should be able to list users
and filter by email and exclude users who have access to a document.
"""
user = factories.UserFactory()
document = factories.DocumentFactory()
client = APIClient()
client.force_login(user)
for i in range(page_size):
factories.UserFactory.create(admin_email=f"user-{i}@people.com")
nicole = factories.UserFactory(email="nicole_foole@work.com")
frank = factories.UserFactory(email="frank_poole@work.com")
factories.UserFactory(email="heywood_floyd@work.com")
response = client.get(
f"/api/v1.0/users/?page_size={page_size}",
)
factories.UserDocumentAccessFactory(document=document, user=frank)
assert response.status_code == HTTP_200_OK
content = response.json()
response = client.get("/api/v1.0/users/?q=oole&document_id=" + str(document.id))
assert content["count"] == page_size + 1
# Length should not exceed "max_page_size" default value
assert len(content["results"]) == 100
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(nicole.id)]
def test_api_users_retrieve_me_anonymous():
@@ -555,7 +98,7 @@ def test_api_users_retrieve_me_anonymous():
factories.UserFactory.create_batch(2)
client = APIClient()
response = client.get("/api/v1.0/users/me/")
assert response.status_code == HTTP_401_UNAUTHORIZED
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
@@ -563,31 +106,20 @@ def test_api_users_retrieve_me_anonymous():
def test_api_users_retrieve_me_authenticated():
"""Authenticated users should be able to retrieve their own user via the "/users/me" path."""
identity = factories.IdentityFactory(is_main=True)
user = identity.user
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Define profile contact
contact = factories.ContactFactory(owner=user)
user.profile_contact = contact
user.save()
factories.UserFactory.create_batch(2)
response = client.get(
"/api/v1.0/users/me/",
)
assert response.status_code == HTTP_200_OK
assert response.status_code == 200
assert response.json() == {
"id": str(user.id),
"name": str(identity.name),
"email": str(identity.email),
"language": user.language,
"timezone": str(user.timezone),
"is_device": False,
"is_staff": False,
"email": user.email,
}
@@ -597,7 +129,7 @@ def test_api_users_retrieve_anonymous():
user = factories.UserFactory()
response = client.get(f"/api/v1.0/users/{user.id!s}/")
assert response.status_code == HTTP_401_UNAUTHORIZED
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
@@ -608,8 +140,7 @@ def test_api_users_retrieve_authenticated_self():
Authenticated users should be allowed to retrieve their own user.
The returned object should not contain the password.
"""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -617,7 +148,7 @@ def test_api_users_retrieve_authenticated_self():
response = client.get(
f"/api/v1.0/users/{user.id!s}/",
)
assert response.status_code == HTTP_405_METHOD_NOT_ALLOWED
assert response.status_code == 405
assert response.json() == {"detail": 'Method "GET" not allowed.'}
@@ -626,17 +157,17 @@ def test_api_users_retrieve_authenticated_other():
Authenticated users should be able to retrieve another user's detail view with
limited information.
"""
identity = factories.IdentityFactory()
user = factories.UserFactory()
client = APIClient()
client.force_login(identity.user)
client.force_login(user)
other_user = factories.UserFactory()
response = client.get(
f"/api/v1.0/users/{other_user.id!s}/",
)
assert response.status_code == HTTP_405_METHOD_NOT_ALLOWED
assert response.status_code == 405
assert response.json() == {"detail": 'Method "GET" not allowed.'}
@@ -649,17 +180,16 @@ def test_api_users_create_anonymous():
"password": "mypassword",
},
)
assert response.status_code == HTTP_401_UNAUTHORIZED
assert "Authentication credentials were not provided." in response.content.decode(
"utf-8"
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
assert models.User.objects.exists() is False
def test_api_users_create_authenticated():
"""Authenticated users should not be able to create users via the API."""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -672,7 +202,7 @@ def test_api_users_create_authenticated():
},
format="json",
)
assert response.status_code == HTTP_405_METHOD_NOT_ALLOWED
assert response.status_code == 405
assert response.json() == {"detail": 'Method "POST" not allowed.'}
assert models.User.objects.exclude(id=user.id).exists() is False
@@ -690,7 +220,7 @@ def test_api_users_update_anonymous():
format="json",
)
assert response.status_code == HTTP_401_UNAUTHORIZED
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
@@ -706,8 +236,7 @@ def test_api_users_update_authenticated_self():
Authenticated users should be able to update their own user but only "language"
and "timezone" fields.
"""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -723,7 +252,7 @@ def test_api_users_update_authenticated_self():
format="json",
)
assert response.status_code == HTTP_200_OK
assert response.status_code == 200
user.refresh_from_db()
user_values = dict(serializers.UserSerializer(instance=user).data)
for key, value in user_values.items():
@@ -735,10 +264,10 @@ def test_api_users_update_authenticated_self():
def test_api_users_update_authenticated_other():
"""Authenticated users should not be allowed to update other users."""
identity = factories.IdentityFactory()
user = factories.UserFactory()
client = APIClient()
client.force_login(identity.user)
client.force_login(user)
user = factories.UserFactory()
old_user_values = dict(serializers.UserSerializer(instance=user).data)
@@ -750,7 +279,7 @@ def test_api_users_update_authenticated_other():
format="json",
)
assert response.status_code == HTTP_403_FORBIDDEN
assert response.status_code == 403
user.refresh_from_db()
user_values = dict(serializers.UserSerializer(instance=user).data)
for key, value in user_values.items():
@@ -772,7 +301,7 @@ def test_api_users_patch_anonymous():
{key: new_value},
format="json",
)
assert response.status_code == HTTP_401_UNAUTHORIZED
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
@@ -788,8 +317,7 @@ def test_api_users_patch_authenticated_self():
Authenticated users should be able to patch their own user but only "language"
and "timezone" fields.
"""
identity = factories.IdentityFactory()
user = identity.user
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -805,7 +333,7 @@ def test_api_users_patch_authenticated_self():
{key: new_value},
format="json",
)
assert response.status_code == HTTP_200_OK
assert response.status_code == 200
user.refresh_from_db()
user_values = dict(serializers.UserSerializer(instance=user).data)
@@ -818,10 +346,10 @@ def test_api_users_patch_authenticated_self():
def test_api_users_patch_authenticated_other():
"""Authenticated users should not be allowed to patch other users."""
identity = factories.IdentityFactory()
user = factories.UserFactory()
client = APIClient()
client.force_login(identity.user)
client.force_login(user)
user = factories.UserFactory()
old_user_values = dict(serializers.UserSerializer(instance=user).data)
@@ -835,7 +363,7 @@ def test_api_users_patch_authenticated_other():
{key: new_value},
format="json",
)
assert response.status_code == HTTP_403_FORBIDDEN
assert response.status_code == 403
user.refresh_from_db()
user_values = dict(serializers.UserSerializer(instance=user).data)
@@ -850,23 +378,23 @@ def test_api_users_delete_list_anonymous():
client = APIClient()
response = client.delete("/api/v1.0/users/")
assert response.status_code == HTTP_401_UNAUTHORIZED
assert response.status_code == 401
assert models.User.objects.count() == 2
def test_api_users_delete_list_authenticated():
"""Authenticated users should not be allowed to delete a list of users."""
factories.UserFactory.create_batch(2)
identity = factories.IdentityFactory()
user = factories.UserFactory()
client = APIClient()
client.force_login(identity.user)
client.force_login(user)
response = client.delete(
"/api/v1.0/users/",
)
assert response.status_code == HTTP_405_METHOD_NOT_ALLOWED
assert response.status_code == 405
assert models.User.objects.count() == 3
@@ -876,7 +404,7 @@ def test_api_users_delete_anonymous():
response = APIClient().delete(f"/api/v1.0/users/{user.id!s}/")
assert response.status_code == HTTP_401_UNAUTHORIZED
assert response.status_code == 401
assert models.User.objects.count() == 1
@@ -884,28 +412,31 @@ def test_api_users_delete_authenticated():
"""
Authenticated users should not be allowed to delete a user other than themselves.
"""
identity = factories.IdentityFactory()
other_user = factories.UserFactory()
user = factories.UserFactory()
client = APIClient()
client.force_login(identity.user)
client.force_login(user)
response = client.delete(f"/api/v1.0/users/{other_user.id!s}/")
other_user = factories.UserFactory()
assert response.status_code == HTTP_405_METHOD_NOT_ALLOWED
response = client.delete(
f"/api/v1.0/users/{other_user.id!s}/",
)
assert response.status_code == 405
assert models.User.objects.count() == 2
def test_api_users_delete_self():
"""Authenticated users should not be able to delete their own user."""
identity = factories.IdentityFactory()
user = factories.UserFactory()
client = APIClient()
client.force_login(identity.user)
client.force_login(user)
response = client.delete(
f"/api/v1.0/users/{identity.user.id!s}/",
f"/api/v1.0/users/{user.id!s}/",
)
assert response.status_code == HTTP_405_METHOD_NOT_ALLOWED
assert response.status_code == 405
assert models.User.objects.count() == 1
@@ -1,164 +0,0 @@
"""
Unit tests for the Contact model
"""
from django.core.exceptions import ValidationError
import pytest
from core import factories
pytestmark = pytest.mark.django_db
def test_models_contacts_str_full_name():
"""The str representation should be the contact's full name."""
contact = factories.ContactFactory(full_name="David Bowman")
assert str(contact) == "David Bowman"
def test_models_contacts_str_short_name():
"""The str representation should be the contact's short name if full name is not set."""
contact = factories.ContactFactory(full_name=None, short_name="Dave")
assert str(contact) == "Dave"
def test_models_contacts_base_self():
"""A contact should not point to itself as a base contact."""
contact = factories.ContactFactory()
contact.base = contact
with pytest.raises(ValidationError) as excinfo:
contact.save()
error_message = (
"{'__all__': ['A contact cannot point to a base contact that itself points to a "
"base contact.', 'A contact cannot be based on itself.']}"
)
assert str(excinfo.value) == error_message
def test_models_contacts_base_to_base():
"""A contact should not point to a base contact that is itself derived from a base contact."""
contact = factories.ContactFactory()
with pytest.raises(ValidationError) as excinfo:
factories.ContactFactory(base=contact)
error_message = (
"{'__all__': ['A contact cannot point to a base contact that itself points to a "
"base contact.']}"
)
assert str(excinfo.value) == error_message
def test_models_contacts_owner_base_unique():
"""There should be only one contact deriving from a given base contact for a given owner."""
contact = factories.ContactFactory()
with pytest.raises(ValidationError) as excinfo:
factories.ContactFactory(base=contact.base, owner=contact.owner)
assert (
str(excinfo.value)
== "{'__all__': ['Contact with this Owner and Base already exists.']}"
)
def test_models_contacts_base_not_owned():
"""A contact cannot have a base and not be owned."""
with pytest.raises(ValidationError) as excinfo:
factories.ContactFactory(owner=None)
assert (
str(excinfo.value)
== "{'__all__': ['A contact overriding a base contact must be owned.']}"
)
def test_models_contacts_profile_not_owned():
"""A contact cannot be defined as profile for a user if is not owned."""
base_contact = factories.ContactFactory(owner=None, base=None)
with pytest.raises(ValidationError) as excinfo:
factories.UserFactory(profile_contact=base_contact)
assert (
str(excinfo.value)
== "{'__all__': ['Users can only declare as profile a contact they own.']}"
)
def test_models_contacts_profile_owned_by_other():
"""A contact cannot be defined as profile for a user if is owned by another user."""
contact = factories.ContactFactory()
with pytest.raises(ValidationError) as excinfo:
factories.UserFactory(profile_contact=contact)
assert (
str(excinfo.value)
== "{'__all__': ['Users can only declare as profile a contact they own.']}"
)
def test_models_contacts_data_valid():
"""Contact information matching the jsonschema definition should be valid"""
factories.ContactFactory(
data={
"emails": [
{"type": "Work", "value": "john.doe@work.com"},
{"type": "Home", "value": "john.doe@home.com"},
],
"phones": [
{"type": "Work", "value": "(123) 456-7890"},
{"type": "Other", "value": "(987) 654-3210"},
],
"addresses": [
{
"type": "Home",
"street": "123 Main St",
"city": "Cityville",
"state": "CA",
"zip": "12345",
"country": "USA",
}
],
"links": [
{"type": "Blog", "value": "http://personalwebsite.com"},
{"type": "Website", "value": "http://workwebsite.com"},
{"type": "LinkedIn", "value": "https://www.linkedin.com/in/johndoe"},
{"type": "Facebook", "value": "https://www.facebook.com/in/johndoe"},
],
"customFields": {"custom_field_1": "value1", "custom_field_2": "value2"},
"organizations": [
{
"name": "ACME Corporation",
"department": "IT",
"jobTitle": "Software Engineer",
},
{
"name": "XYZ Ltd",
"department": "Marketing",
"jobTitle": "Marketing Specialist",
},
],
}
)
def test_models_contacts_data_invalid():
"""Invalid contact information should be rejected with a clear error message."""
with pytest.raises(ValidationError) as excinfo:
factories.ContactFactory(
data={
"emails": [
{"type": "invalid type", "value": "john.doe@work.com"},
],
}
)
assert str(excinfo.value) == (
"{'data': [\"Validation error in 'emails.0.type': 'invalid type' is not one of ['Work', "
"'Home', 'Other']\"]}"
)
@@ -0,0 +1,419 @@
"""
Unit tests for the DocumentAccess model
"""
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ValidationError
import pytest
from core import factories
pytestmark = pytest.mark.django_db
def test_models_document_accesses_str():
"""
The str representation should include user email, document title and role.
"""
user = factories.UserFactory(email="david.bowman@example.com")
access = factories.UserDocumentAccessFactory(
role="reader",
user=user,
document__title="admins",
)
assert str(access) == "david.bowman@example.com is reader in document admins"
def test_models_document_accesses_unique_user():
"""Document accesses should be unique for a given couple of user and document."""
access = factories.UserDocumentAccessFactory()
with pytest.raises(
ValidationError,
match="This user is already in this document.",
):
factories.UserDocumentAccessFactory(user=access.user, document=access.document)
def test_models_document_accesses_several_empty_teams():
"""A document can have several document accesses with an empty team."""
access = factories.UserDocumentAccessFactory()
factories.UserDocumentAccessFactory(document=access.document)
def test_models_document_accesses_unique_team():
"""Document accesses should be unique for a given couple of team and document."""
access = factories.TeamDocumentAccessFactory()
with pytest.raises(
ValidationError,
match="This team is already in this document.",
):
factories.TeamDocumentAccessFactory(team=access.team, document=access.document)
def test_models_document_accesses_several_null_users():
"""A document can have several document accesses with a null user."""
access = factories.TeamDocumentAccessFactory()
factories.TeamDocumentAccessFactory(document=access.document)
def test_models_document_accesses_user_and_team_set():
"""User and team can't both be set on a document access."""
with pytest.raises(
ValidationError,
match="Either user or team must be set, not both.",
):
factories.UserDocumentAccessFactory(team="my-team")
def test_models_document_accesses_user_and_team_empty():
"""User and team can't both be empty on a document access."""
with pytest.raises(
ValidationError,
match="Either user or team must be set, not both.",
):
factories.UserDocumentAccessFactory(user=None)
# get_abilities
def test_models_document_access_get_abilities_anonymous():
"""Check abilities returned for an anonymous user."""
access = factories.UserDocumentAccessFactory()
abilities = access.get_abilities(AnonymousUser())
assert abilities == {
"destroy": False,
"retrieve": False,
"update": False,
"partial_update": False,
"set_role_to": [],
}
def test_models_document_access_get_abilities_authenticated():
"""Check abilities returned for an authenticated user."""
access = factories.UserDocumentAccessFactory()
user = factories.UserFactory()
abilities = access.get_abilities(user)
assert abilities == {
"destroy": False,
"retrieve": False,
"update": False,
"partial_update": False,
"set_role_to": [],
}
# - for owner
def test_models_document_access_get_abilities_for_owner_of_self_allowed():
"""
Check abilities of self access for the owner of a document when
there is more than one owner left.
"""
access = factories.UserDocumentAccessFactory(role="owner")
factories.UserDocumentAccessFactory(document=access.document, role="owner")
abilities = access.get_abilities(access.user)
assert abilities == {
"destroy": True,
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["administrator", "editor", "reader"],
}
def test_models_document_access_get_abilities_for_owner_of_self_last():
"""
Check abilities of self access for the owner of a document when there is only one owner left.
"""
access = factories.UserDocumentAccessFactory(role="owner")
abilities = access.get_abilities(access.user)
assert abilities == {
"destroy": False,
"retrieve": True,
"update": False,
"partial_update": False,
"set_role_to": [],
}
def test_models_document_access_get_abilities_for_owner_of_owner():
"""Check abilities of owner access for the owner of a document."""
access = factories.UserDocumentAccessFactory(role="owner")
factories.UserDocumentAccessFactory(document=access.document) # another one
user = factories.UserDocumentAccessFactory(
document=access.document, role="owner"
).user
abilities = access.get_abilities(user)
assert abilities == {
"destroy": True,
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["administrator", "editor", "reader"],
}
def test_models_document_access_get_abilities_for_owner_of_administrator():
"""Check abilities of administrator access for the owner of a document."""
access = factories.UserDocumentAccessFactory(role="administrator")
factories.UserDocumentAccessFactory(document=access.document) # another one
user = factories.UserDocumentAccessFactory(
document=access.document, role="owner"
).user
abilities = access.get_abilities(user)
assert abilities == {
"destroy": True,
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["owner", "editor", "reader"],
}
def test_models_document_access_get_abilities_for_owner_of_editor():
"""Check abilities of editor access for the owner of a document."""
access = factories.UserDocumentAccessFactory(role="editor")
factories.UserDocumentAccessFactory(document=access.document) # another one
user = factories.UserDocumentAccessFactory(
document=access.document, role="owner"
).user
abilities = access.get_abilities(user)
assert abilities == {
"destroy": True,
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["owner", "administrator", "reader"],
}
def test_models_document_access_get_abilities_for_owner_of_reader():
"""Check abilities of reader access for the owner of a document."""
access = factories.UserDocumentAccessFactory(role="reader")
factories.UserDocumentAccessFactory(document=access.document) # another one
user = factories.UserDocumentAccessFactory(
document=access.document, role="owner"
).user
abilities = access.get_abilities(user)
assert abilities == {
"destroy": True,
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["owner", "administrator", "editor"],
}
# - for administrator
def test_models_document_access_get_abilities_for_administrator_of_owner():
"""Check abilities of owner access for the administrator of a document."""
access = factories.UserDocumentAccessFactory(role="owner")
factories.UserDocumentAccessFactory(document=access.document) # another one
user = factories.UserDocumentAccessFactory(
document=access.document, role="administrator"
).user
abilities = access.get_abilities(user)
assert abilities == {
"destroy": False,
"retrieve": True,
"update": False,
"partial_update": False,
"set_role_to": [],
}
def test_models_document_access_get_abilities_for_administrator_of_administrator():
"""Check abilities of administrator access for the administrator of a document."""
access = factories.UserDocumentAccessFactory(role="administrator")
factories.UserDocumentAccessFactory(document=access.document) # another one
user = factories.UserDocumentAccessFactory(
document=access.document, role="administrator"
).user
abilities = access.get_abilities(user)
assert abilities == {
"destroy": True,
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["editor", "reader"],
}
def test_models_document_access_get_abilities_for_administrator_of_editor():
"""Check abilities of editor access for the administrator of a document."""
access = factories.UserDocumentAccessFactory(role="editor")
factories.UserDocumentAccessFactory(document=access.document) # another one
user = factories.UserDocumentAccessFactory(
document=access.document, role="administrator"
).user
abilities = access.get_abilities(user)
assert abilities == {
"destroy": True,
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["administrator", "reader"],
}
def test_models_document_access_get_abilities_for_administrator_of_reader():
"""Check abilities of reader access for the administrator of a document."""
access = factories.UserDocumentAccessFactory(role="reader")
factories.UserDocumentAccessFactory(document=access.document) # another one
user = factories.UserDocumentAccessFactory(
document=access.document, role="administrator"
).user
abilities = access.get_abilities(user)
assert abilities == {
"destroy": True,
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["administrator", "editor"],
}
# - for editor
def test_models_document_access_get_abilities_for_editor_of_owner():
"""Check abilities of owner access for the editor of a document."""
access = factories.UserDocumentAccessFactory(role="owner")
factories.UserDocumentAccessFactory(document=access.document) # another one
user = factories.UserDocumentAccessFactory(
document=access.document, role="editor"
).user
abilities = access.get_abilities(user)
assert abilities == {
"destroy": False,
"retrieve": True,
"update": False,
"partial_update": False,
"set_role_to": [],
}
def test_models_document_access_get_abilities_for_editor_of_administrator():
"""Check abilities of administrator access for the editor of a document."""
access = factories.UserDocumentAccessFactory(role="administrator")
factories.UserDocumentAccessFactory(document=access.document) # another one
user = factories.UserDocumentAccessFactory(
document=access.document, role="editor"
).user
abilities = access.get_abilities(user)
assert abilities == {
"destroy": False,
"retrieve": True,
"update": False,
"partial_update": False,
"set_role_to": [],
}
def test_models_document_access_get_abilities_for_editor_of_editor_user(
django_assert_num_queries,
):
"""Check abilities of editor access for the editor of a document."""
access = factories.UserDocumentAccessFactory(role="editor")
factories.UserDocumentAccessFactory(document=access.document) # another one
user = factories.UserDocumentAccessFactory(
document=access.document, role="editor"
).user
with django_assert_num_queries(1):
abilities = access.get_abilities(user)
assert abilities == {
"destroy": False,
"retrieve": True,
"update": False,
"partial_update": False,
"set_role_to": [],
}
# - for reader
def test_models_document_access_get_abilities_for_reader_of_owner():
"""Check abilities of owner access for the reader of a document."""
access = factories.UserDocumentAccessFactory(role="owner")
factories.UserDocumentAccessFactory(document=access.document) # another one
user = factories.UserDocumentAccessFactory(
document=access.document, role="reader"
).user
abilities = access.get_abilities(user)
assert abilities == {
"destroy": False,
"retrieve": True,
"update": False,
"partial_update": False,
"set_role_to": [],
}
def test_models_document_access_get_abilities_for_reader_of_administrator():
"""Check abilities of administrator access for the reader of a document."""
access = factories.UserDocumentAccessFactory(role="administrator")
factories.UserDocumentAccessFactory(document=access.document) # another one
user = factories.UserDocumentAccessFactory(
document=access.document, role="reader"
).user
abilities = access.get_abilities(user)
assert abilities == {
"destroy": False,
"retrieve": True,
"update": False,
"partial_update": False,
"set_role_to": [],
}
def test_models_document_access_get_abilities_for_reader_of_reader_user(
django_assert_num_queries,
):
"""Check abilities of reader access for the reader of a document."""
access = factories.UserDocumentAccessFactory(role="reader")
factories.UserDocumentAccessFactory(document=access.document) # another one
user = factories.UserDocumentAccessFactory(
document=access.document, role="reader"
).user
with django_assert_num_queries(1):
abilities = access.get_abilities(user)
assert abilities == {
"destroy": False,
"retrieve": True,
"update": False,
"partial_update": False,
"set_role_to": [],
}
def test_models_document_access_get_abilities_preset_role(django_assert_num_queries):
"""No query is done if the role is preset, e.g., with a query annotation."""
access = factories.UserDocumentAccessFactory(role="reader")
user = factories.UserDocumentAccessFactory(
document=access.document, role="reader"
).user
access.user_roles = ["reader"]
with django_assert_num_queries(0):
abilities = access.get_abilities(user)
assert abilities == {
"destroy": False,
"retrieve": True,
"update": False,
"partial_update": False,
"set_role_to": [],
}
@@ -0,0 +1,324 @@
"""
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
import pytest
from core import factories, models
pytestmark = pytest.mark.django_db
def test_models_documents_str():
"""The str representation should be the title of the document."""
document = factories.DocumentFactory(title="admins")
assert str(document) == "admins"
def test_models_documents_id_unique():
"""The "id" field should be unique."""
document = factories.DocumentFactory()
with pytest.raises(ValidationError, match="Document with this Id already exists."):
factories.DocumentFactory(id=document.id)
def test_models_documents_title_null():
"""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 can be empty."""
document = models.Document.objects.create(title="")
assert document.title == ""
def test_models_documents_title_max_length():
"""The "title" field should be 100 characters maximum."""
factories.DocumentFactory(title="a" * 255)
with pytest.raises(
ValidationError,
match=r"Ensure this value has at most 255 characters \(it has 256\)\.",
):
factories.DocumentFactory(title="a" * 256)
def test_models_documents_file_key():
"""The file key should be built from the instance uuid."""
document = factories.DocumentFactory(id="9531a5f1-42b1-496c-b3f4-1c09ed139b3c")
assert document.file_key == "9531a5f1-42b1-496c-b3f4-1c09ed139b3c/file"
# get_abilities
@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,
"manage_accesses": False,
"partial_update": False,
"retrieve": False,
"update": False,
"versions_destroy": False,
"versions_list": False,
"versions_retrieve": False,
}
@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,
"versions_destroy": False,
"versions_list": False,
"versions_retrieve": False,
}
@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,
"link_configuration": False,
"manage_accesses": False,
"partial_update": True,
"retrieve": True,
"update": True,
"versions_destroy": False,
"versions_list": False,
"versions_retrieve": False,
}
def test_models_documents_get_abilities_owner():
"""Check abilities returned for the owner of a document."""
user = factories.UserFactory()
access = factories.UserDocumentAccessFactory(role="owner", user=user)
abilities = access.document.get_abilities(access.user)
assert abilities == {
"attachment_upload": True,
"destroy": True,
"link_configuration": True,
"manage_accesses": True,
"partial_update": True,
"retrieve": True,
"update": True,
"versions_destroy": True,
"versions_list": True,
"versions_retrieve": True,
}
def test_models_documents_get_abilities_administrator():
"""Check abilities returned for the administrator of a document."""
access = factories.UserDocumentAccessFactory(role="administrator")
abilities = access.document.get_abilities(access.user)
assert abilities == {
"attachment_upload": True,
"destroy": False,
"link_configuration": True,
"manage_accesses": True,
"partial_update": True,
"retrieve": True,
"update": True,
"versions_destroy": True,
"versions_list": True,
"versions_retrieve": True,
}
def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
"""Check abilities returned for the editor of a document."""
access = factories.UserDocumentAccessFactory(role="editor")
with django_assert_num_queries(1):
abilities = access.document.get_abilities(access.user)
assert abilities == {
"attachment_upload": True,
"destroy": False,
"link_configuration": False,
"manage_accesses": False,
"partial_update": True,
"retrieve": True,
"update": True,
"versions_destroy": False,
"versions_list": True,
"versions_retrieve": True,
}
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", document__link_role="reader"
)
with django_assert_num_queries(1):
abilities = access.document.get_abilities(access.user)
assert abilities == {
"attachment_upload": False,
"destroy": False,
"link_configuration": False,
"manage_accesses": False,
"partial_update": False,
"retrieve": True,
"update": False,
"versions_destroy": False,
"versions_list": True,
"versions_retrieve": True,
}
def test_models_documents_get_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", 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,
"link_configuration": False,
"manage_accesses": False,
"partial_update": False,
"retrieve": True,
"update": False,
"versions_destroy": False,
"versions_list": True,
"versions_retrieve": True,
}
def test_models_documents_get_versions_slice(settings):
"""
The "get_versions_slice" method should allow navigating all versions of
the document with pagination.
"""
settings.DOCUMENT_VERSIONS_PAGE_SIZE = 4
# Create a document with 7 versions
document = factories.DocumentFactory()
for i in range(6):
document.content = f"bar{i:d}"
document.save()
# Add a version not related to the first document
factories.DocumentFactory()
# - Get default max versions
response = document.get_versions_slice()
assert response["is_truncated"] is True
assert len(response["versions"]) == 4
assert response["next_version_id_marker"] != ""
expected_keys = ["etag", "is_latest", "last_modified", "version_id"]
for i in range(4):
assert list(response["versions"][i].keys()) == expected_keys
# - Get page 2
response = document.get_versions_slice(
from_version_id=response["next_version_id_marker"]
)
assert response["is_truncated"] is False
assert len(response["versions"]) == 3
assert response["next_version_id_marker"] == ""
# - Get custom max versions
response = document.get_versions_slice(page_size=2)
assert response["is_truncated"] is True
assert len(response["versions"]) == 2
assert response["next_version_id_marker"] != ""
def test_models_documents_version_duplicate():
"""A new version should be created in object storage only if the content has changed."""
document = factories.DocumentFactory()
file_key = str(document.pk)
response = default_storage.connection.meta.client.list_object_versions(
Bucket=default_storage.bucket_name, Prefix=file_key
)
assert len(response["Versions"]) == 1
# Save again with the same content
document.save()
response = default_storage.connection.meta.client.list_object_versions(
Bucket=default_storage.bucket_name, Prefix=file_key
)
assert len(response["Versions"]) == 1
# Save modified content
document.content = "new content"
document.save()
response = default_storage.connection.meta.client.list_object_versions(
Bucket=default_storage.bucket_name, Prefix=file_key
)
assert len(response["Versions"]) == 2

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