Compare commits

...

64 Commits

Author SHA1 Message Date
Samuel Paccoud - DINUM 644c37651e (backend) annotate number of accesses on documents
The new UI will display the number of accesses on each document.

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

I introduced a specific serializer for the document list view with
less fields. For a list of 20 documents with 5 accesses, we go down
from 3x5x20= 300 queries to just 3 queries.
2024-11-16 00:32:01 +01:00
Nathan Panchout bbe9b6b6cf (frontend) add styledCss props to Box component
In order to facilitate DX and not to use a string in the code for the css.
We add the $styledCss props to the Box component.
This object comes from Styled component
2024-11-15 10:33:56 +01:00
Anthony LC 23231563c9 💄(frontend) text color on Blocknote code block options
The options for the code block in the Blocknote
editor was not visible. We changed the text color
to make it visible.
A fix will be made to the code block options in the
next blocknote release.
2024-11-14 17:36:11 +01:00
Anthony LC d75c8668c5 🚨(frontend) blocknote cast to Dictionnary
The last Blocknote upgrade (0.19.0) gives us
a warning with the dictionnary typing.
We cast it to the correct type to remove the warning.
2024-11-14 17:36:11 +01:00
Anthony LC f266232b5a ♻️(frontend) use next/router instead of next/navigation
The last upgrade of next.js gives a warning
when we were using next/navigation with the
pages router.
This commit fixes this issue.
2024-11-14 17:36:11 +01:00
Anthony LC a8362e8e88 ⬆️(dependencies) update js dependencies 2024-11-14 17:36:11 +01:00
Anthony LC e4dfae1905 ♻️(frontend) simplify useDocStore
We moved the editor store to its own store in the previous
commit. This change allow us to simplify useDocStore.
2024-11-13 15:25:29 +01:00
Anthony LC a09e740648 ♻️(frontend) move editor store to useEditorStore
Previous changes migrated the editor store to
doc-management, we move it back doc-editor and
simplify it.
2024-11-13 15:25:29 +01:00
Anthony LC 5ee6a43f08 (frontend) add useBroadcastStore
Add the useBroadcastStore.
It will give us the ability to easily
broadcast actions to all connected clients.

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

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

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

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

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

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

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

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

We also have 2 editability options:
- readonly
- editable

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

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

Changed:
- ♻️(frontend) More multi theme friendly
- ♻️ Bootstrap frontend
- ♻️ Add username in email

Fixed:
- 🛂(backend) do not duplicate user when disabled
- 🐛(frontend) invalidate queries after removing user
- 🐛(backend) Fix dysfunctional permissions on document create
- 🐛(backend) fix nginx docker container
- 🐛(frontend) fix copy paste firefox
2024-10-17 17:50:57 +02:00
Anthony LC b360bd8494 ⬆️(frontend) upgrade blocknote to 0.17.0
Version 0.17.0 of Blocknote fixes the
copy paste issue in the editor with Firefox.
2024-10-17 17:15:22 +02:00
Samuel Paccoud - DINUM 6a95d24441 🛂(backend) do not duplicate user when disabled
When a user is disabled and tries to login, we
don't want the user to be duplicated,
the user should not be able to login.

Fixes #324

Work initially contributed by @qbey on:
https://github.com/numerique-gouv/people/pull/456
2024-10-17 16:54:40 +02:00
Anthony LC e816f0afc8 🚸(frontend) add toast error when AI request fails
When the AI request fails, a toast error is
displayed to the user.
2024-10-17 16:22:13 +02:00
lindenb1 7e8732822b 🐛(docker) update docker-compose.yml to make nginx depend on app-dev
Modified docker-compose.yml to ensure nginx starts only after app-dev.

Signed-off-by: lindenb1 <linden@b1-systems.de>
2024-10-17 14:50:21 +02:00
Anthony LC 9ed6b11bb1 ⬆️(dependencies) update js dependencies 2024-10-17 13:11:01 +02:00
Anthony LC 62124ae475 🌐(frontend) translate last features
Translate:
- IA Buttons
- doc clipboard markdown / html
2024-10-17 10:11:37 +02:00
Anthony LC c327928921 🐛(frontend) fix flaky e2e test
A test on e2e was flaky, this commit fixes it.
2024-10-16 22:58:52 +02:00
Anthony LC be26a9457f 🔧(helm) add ai setting to environments
Add the ai setting to the environments.
2024-10-16 22:58:52 +02:00
Anthony LC 5dc43cbc8b (frontend) add ai blocknote feature
Add AI button to the editor toolbar.
We can use AI to generate content with our editor.
A list of predefined actions are available to use.
2024-10-16 22:58:52 +02:00
Anthony LC 9abf6888aa 🎨(frontend) reduce prop drilling thanks to doc store
We start to have a deep prop drilling with doc,
time to use the doc store to reduce that.
We still prefer to pass the doc as a prop to
keep our component as "pure" as possible, but if
the drilling is too deep, better
to use the doc store.
2024-10-16 22:58:52 +02:00
Anthony LC aff3b43c9d (backend) create ai endpoint
We created 2 new action endpoints on the document
to perform AI operations:
- POST /api/v1.0/documents/{uuid}/ai-transform
- POST /api/v1.0/documents/{uuid}/ai-translate
2024-10-16 22:58:52 +02:00
Samuel Paccoud - DINUM e8d95facdf (backend) allow uploading more types of attachments
We want to allow users to upload files to a document, not just images.
We try to enforce coherence between the file extension and the real
mime type of its content. If a file is deemed unsafe, it is still accepted
during upload and the information is stored as metadata on the object
for display to readers.
2024-10-16 19:40:28 +02:00
Samuel Paccoud - DINUM a9f08df566 (backend) move freezegun to dev dependencies
Freezegun is for testing and should not be installed in the
production image.
2024-10-16 19:40:28 +02:00
Samuel Paccoud - DINUM 2fecbc1162 🚚(backend) split test file for api template accesses
The number of lines in this file had exceeded 1000 lines.
2024-10-16 19:16:50 +02:00
Samuel Paccoud - DINUM 1fc3029d12 🐛(backend) fix dysfunctional permissions on document create
When creating a document access, users were benefitting on the targeted
document from the highest access right they have among all documents.
This is because we forgot to filter on the document ID when retrieving
the role of the user. We improved all tests to secure this issue.
2024-10-16 19:16:50 +02:00
rvveber bbcb5e0cf1 (frontend) added copy-as buttons for HTML and Markdown
Add buttons to copy editor content as HTML or Markdown. Closes #300
2024-10-16 17:57:10 +02:00
renovate[bot] e4a7ac0f3c ⬆️(dependencies) update python dependencies 2024-10-16 10:41:25 +02:00
Anthony LC 24630791d8 ♻️(email) use full name instead of email
If the full name is available,
we will use it to identify the user in the email
instead of the email address.
2024-10-16 09:36:33 +02:00
Anthony LC 97d00b678f 🚨(docker) fix docker warning about casing
When we build the docker image, we get a warning
about the casing in the Dockerfile. This commit
fixes the casing in the Dockerfile.
2024-10-14 22:20:54 +02:00
Anthony LC 52eb973164 (CI) refecto test-e2e
With the new container available, we can simplify
the workflow by removing the build step
and using the container directly.
2024-10-14 22:20:54 +02:00
Anthony LC 789879a9cc 🧑‍💻(project) improve frontend bootstrap
We were providing a frontend development container
to the developers, but it was not working properly.
Problem of hot reload was present for Windows and
Linux users.
We stop to provide this development container and
we will provide a container connected to the build
of the frontend.
You can still access the frontend after bootstrap
on the "localhost:3000", but if you want to develop
you will have to install the frontend dependencies
localy and run the frontend in development mode.
This will be more efficient and will avoid the
problem of hot reload, and right on folder access.
2024-10-14 22:20:54 +02:00
Anthony LC 52c52d53b7 🔧(docker) add missing frontent env
The env MEDIA_URL was missing in the frontend
Dockerfile. It is not necessary in our
running environment (staging / preprod ...) but it
is necessary if we want to run the frontend with
a different media url.
SW_DEACTIVATED was missing as well, we need to
deactivate the service worker in the frontend when
we test with Playwright.
2024-10-14 22:20:54 +02:00
Anthony LC 54fe6a2319 🐛(frontend) invalidate queries after removing user
When we remove a user from the list of members,
we need to invalidate the user query for the
user to be found again.
We improve the error message when a user is
already a member of the document.
2024-10-14 19:58:41 +02:00
169 changed files with 9594 additions and 7536 deletions
-52
View File
@@ -1,52 +0,0 @@
name: Deploy
on:
push:
tags:
- 'preprod'
- 'production'
jobs:
notify-argocd:
runs-on: ubuntu-latest
steps:
-
uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "impress,secrets"
-
name: Checkout repository
uses: actions/checkout@v2
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
-
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/impress/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
-
name: Call argocd github webhook
run: |
data='{"ref": "'$GITHUB_REF'","repository": {"html_url":"'$GITHUB_SERVER_URL'/'$GITHUB_REPOSITORY'"}}'
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${ARGOCD_WEBHOOK_SECRET}'' | awk '{print "X-Hub-Signature: sha1="$2}')
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" $ARGOCD_WEBHOOK_URL
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${ARGOCD_PRODUCTION_WEBHOOK_SECRET}'' | awk '{print "X-Hub-Signature: sha1="$2}')
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" $ARGOCD_PRODUCTION_WEBHOOK_URL
start-test-on-preprod:
needs:
- notify-argocd
runs-on: ubuntu-latest
if: startsWith(github.event.ref, 'refs/tags/preprod')
steps:
-
name: Debug
run: |
echo "Start test when preprod is ready"
-3
View File
@@ -8,9 +8,6 @@ on:
- 'main'
tags:
- 'v*'
pull_request:
branches:
- 'main'
env:
DOCKER_USER: 1001:127
-22
View File
@@ -1,22 +0,0 @@
name: Helmfile lint
run-name: Helmfile lint
on:
pull_request:
branches:
- 'main'
jobs:
helmfile-lint:
runs-on: ubuntu-latest
container:
image: ghcr.io/helmfile/helmfile:latest
steps:
-
uses: numerique-gouv/action-helmfile-lint@main
with:
app-id: ${{ secrets.APP_ID }}
age-key: ${{ secrets.SOPS_PRIVATE }}
private-key: ${{ secrets.PRIVATE_KEY }}
helmfile-src: "src/helm"
repositories: "impress,secrets"
+7 -119
View File
@@ -39,29 +39,6 @@ jobs:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
build-front:
runs-on: ubuntu-latest
needs: install-front
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Restore the frontend cache
uses: actions/cache@v4
id: front-node_modules
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
- name: Build CI App
run: cd src/frontend/ && yarn ci:build
- name: Cache build frontend
uses: actions/cache@v4
with:
path: src/frontend/apps/impress/out/
key: build-front-${{ github.run_id }}
test-front:
runs-on: ubuntu-latest
needs: install-front
@@ -98,25 +75,11 @@ jobs:
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
@@ -124,42 +87,11 @@ jobs:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
- name: Restore the build cache
uses: actions/cache@v4
id: cache-build
with:
path: src/frontend/apps/impress/out/
key: build-front-${{ github.run_id }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build the Docker images
uses: docker/bake-action@v4
with:
targets: |
app-dev
y-provider
load: true
set: |
*.cache-from=type=gha,scope=cached-stage
*.cache-to=type=gha,scope=cached-stage,mode=max
- name: Set e2e env variables
run: cat env.d/development/common.e2e.dist >> env.d/development/common.dist
- name: Start Docker services
run: |
make run
- name: 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'
run: make bootstrap FLUSH_ARGS='--no-input' cache=
- name: Install Playwright Browsers
run: cd src/frontend/apps/e2e && yarn install-playwright chromium
@@ -176,25 +108,12 @@ jobs:
test-e2e-other-browser:
runs-on: ubuntu-latest
needs: build-front
needs: test-e2e-chromium
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
@@ -202,42 +121,11 @@ jobs:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
- name: Restore the build cache
uses: actions/cache@v4
id: cache-build
with:
path: src/frontend/apps/impress/out/
key: build-front-${{ github.run_id }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build the Docker images
uses: docker/bake-action@v4
with:
targets: |
app-dev
y-provider
load: true
set: |
*.cache-from=type=gha,scope=cached-stage
*.cache-to=type=gha,scope=cached-stage,mode=max
- name: Set e2e env variables
run: cat env.d/development/common.e2e.dist >> env.d/development/common.dist
- name: Start Docker services
run: |
make run
- name: 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'
run: make bootstrap FLUSH_ARGS='--no-input' cache=
- name: Install Playwright Browsers
run: cd src/frontend/apps/e2e && yarn install-playwright firefox webkit chromium
+73 -3
View File
@@ -9,9 +9,78 @@ and this project adheres to
## [Unreleased]
## Added
- ✨(backend) annotate number of accesses on documents in list view #411
- ✨(backend) allow users to mark/unmark documents as favorite #411
- 🌐(frontend) Add German translation #255
- ✨(frontend) Add a broadcast store #387
## Changed
♻️(frontend) More multi theme friendly #325
- ⚡️(backend) optimize number of queries on document list view #411
- 🚸(backend) improve users similarity search and sort results #391
- 🌐(backend) add german translation #259
- ♻️(frontend) simplify stores #402
- ✨(frontend) update $css Box props type to add styled components RuleSet #423
## Fixed
- 🦺(backend) add comma to sub regex #408
- 🐛(editor) collaborative user tag hidden when read only #385
- 🐛(frontend) user have view access when revoked #387
## [1.7.0] - 2024-10-24
## Added
- 📝Contributing.md #352
- 🌐(frontend) add localization to editor #368
- ✨Public and restricted doc editable #357
- ✨(frontend) Add full name if available #380
- ✨(backend) Add view accesses ability #376
## Changed
- ♻️(frontend) list accesses if user has abilities #376
- ♻️(frontend) avoid documents indexing in search engine #372
- 👔(backend) doc restricted by default #388
## Fixed
- 🐛(backend) require right to manage document accesses to see invitations #369
- 🐛(i18n) same frontend and backend language using shared cookies #365
- 🐛(frontend) add default toolbar buttons #355
- 🐛(frontend) throttle error correctly display #378
## Removed
- 🔥(helm) remove infra related codes #366
## [1.6.0] - 2024-10-17
## Added
- ✨AI to doc editor #250
- ✨(backend) allow uploading more types of attachments #309
- ✨(frontend) add buttons to copy document to clipboard as HTML/Markdown #318
## Changed
- ♻️(frontend) more multi theme friendly #325
- ♻️ Bootstrap frontend #257
- ♻️ Add username in email #314
## Fixed
- 🛂(backend) do not duplicate user when disabled
- 🐛(frontend) invalidate queries after removing user #336
- 🐛(backend) Fix dysfunctional permissions on document create #329
- 🐛(backend) fix nginx docker container #340
- 🐛(frontend) fix copy paste firefox #353
## [1.5.1] - 2024-10-10
@@ -20,7 +89,6 @@ and this project adheres to
- 🐛(db) fix users duplicate #316
## [1.5.0] - 2024-10-09
## Added
@@ -193,7 +261,9 @@ and this project adheres to
- 🚀 Impress, project to manage your documents easily and collaboratively.
[unreleased]: https://github.com/numerique-gouv/impress/compare/v1.5.1...main
[unreleased]: https://github.com/numerique-gouv/impress/compare/v1.7.0...main
[v1.7.0]: https://github.com/numerique-gouv/impress/releases/v1.7.0
[v1.6.0]: https://github.com/numerique-gouv/impress/releases/v1.6.0
[1.5.1]: https://github.com/numerique-gouv/impress/releases/v1.5.1
[1.5.0]: https://github.com/numerique-gouv/impress/releases/v1.5.0
[1.4.0]: https://github.com/numerique-gouv/impress/releases/v1.4.0
+79
View File
@@ -0,0 +1,79 @@
# Contributing to the Project
Thank you for taking the time to contribute! Please follow these guidelines to ensure a smooth and productive workflow. 🚀🚀🚀
To get started with the project, please refer to the [README.md](https://github.com/numerique-gouv/impress/blob/main/README.md) for detailed instructions.
## Creating an Issue
When creating an issue, please provide the following details:
1. **Title**: A concise and descriptive title for the issue.
2. **Description**: A detailed explanation of the issue, including relevant context or screenshots if applicable.
3. **Steps to Reproduce**: If the issue is a bug, include the steps needed to reproduce the problem.
4. **Expected vs. Actual Behavior**: Describe what you expected to happen and what actually happened.
5. **Labels**: Add appropriate labels to categorize the issue (e.g., bug, feature request, documentation).
## Selecting an issue
We use a [GitHub Project](https://github.com/orgs/numerique-gouv/projects/13) in order to prioritize our workload.
Please check in priority the issues that are in the **todo** column and have a higher priority (P0 -> P2).
## Commit Message Format
All commit messages must adhere to the following format:
`<gitmoji>(type) title description`
* <**gitmoji**>: Use a gitmoji to represent the purpose of the commit. For example, ✨ for adding a new feature or 🔥 for removing something, see the list here: <https://gitmoji.dev/>.
* **(type)**: Describe the type of change. Common types include `backend`, `frontend`, `CI`, `docker` etc...
* **title**: A short, descriptive title for the change, starting with a lowercase character.
* **description**: Include additional details about what was changed and why.
### Example Commit Message
```
✨(frontend) add user authentication logic
Implemented login and signup features, and integrated OAuth2 for social login.
```
## Changelog Update
Please add a line to the changelog describing your development. The changelog entry should include a brief summary of the changes, this helps in tracking changes effectively and keeping everyone informed. We usually include the title of the pull request, followed by the pull request ID to finish the log entry. The changelog line should be less than 80 characters in total.
### Example Changelog Message
```
## [Unreleased]
## Added
- ✨(frontend) add AI to the project #321
```
## Pull Requests
It is nice to add information about the purpose of the pull request to help reviewers understand the context and intent of the changes. If you can, add some pictures or a small video to show the changes.
### Don't forget to:
- check your commits
- check the linting: `make lint && make frontend-lint`
- check the tests: `make test`
- add a changelog entry
Once all the required tests have passed, you can request a review from the project maintainers.
## Code Style
Please maintain consistency in code style. Run any linting tools available to make sure the code is clean and follows the project's conventions.
## Tests
Make sure that all new features or fixes have corresponding tests. Run the test suite before pushing your changes to ensure that nothing is broken.
## Asking for Help
If you need any help while contributing, feel free to open a discussion or ask for guidance in the issue tracker. We are more than happy to assist!
Thank you for your contributions! 👍
+14 -13
View File
@@ -1,7 +1,7 @@
# Django impress
# ---- base image to inherit from ----
FROM python:3.12.6-alpine3.20 as base
FROM python:3.12.6-alpine3.20 AS base
# Upgrade pip to its latest release to speed up dependencies installation
RUN python -m pip install --upgrade pip setuptools
@@ -11,7 +11,7 @@ RUN apk update && \
apk upgrade
# ---- Back-end builder image ----
FROM base as back-builder
FROM base AS back-builder
WORKDIR /builder
@@ -23,7 +23,7 @@ RUN mkdir /install && \
# ---- mails ----
FROM node:20 as mail-builder
FROM node:20 AS mail-builder
COPY ./src/mail /mail/app
@@ -34,7 +34,7 @@ RUN yarn install --frozen-lockfile && \
# ---- static link collector ----
FROM base as link-collector
FROM base AS link-collector
ARG IMPRESS_STATIC_ROOT=/data/static
# Install pango & rdfind
@@ -59,20 +59,21 @@ RUN DJANGO_CONFIGURATION=Build DJANGO_JWT_PRIVATE_SIGNING_KEY=Dummy \
RUN rdfind -makesymlinks true -followsymlinks true -makeresultsfile false ${IMPRESS_STATIC_ROOT}
# ---- Core application image ----
FROM base as core
FROM base AS core
ENV PYTHONUNBUFFERED=1
# Install required system libs
RUN apk add \
gettext \
cairo \
libffi-dev \
gdk-pixbuf \
pango \
pandoc \
font-noto-emoji \
file \
font-noto \
font-noto-emoji \
gettext \
gdk-pixbuf \
libffi-dev \
pandoc \
pango \
shared-mime-info
# Copy entrypoint
@@ -97,7 +98,7 @@ WORKDIR /app
ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
# ---- Development image ----
FROM core as backend-development
FROM core AS backend-development
# Switch back to the root user to install development dependencies
USER root:root
@@ -123,7 +124,7 @@ ENV DB_HOST=postgresql \
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
# ---- Production image ----
FROM core as backend-production
FROM core AS backend-production
ARG IMPRESS_STATIC_ROOT=/data/static
+41 -9
View File
@@ -81,7 +81,7 @@ bootstrap: \
data/static \
create-env-files \
build \
run-frontend-dev \
run-with-frontend \
migrate \
demo \
back-i18n-compile \
@@ -90,11 +90,28 @@ bootstrap: \
.PHONY: bootstrap
# -- Docker/compose
build: ## build the app-dev container
@$(COMPOSE) build app-dev --no-cache
@$(COMPOSE) build frontend-dev --no-cache
build: cache ?= --no-cache
build: ## build the project containers
@$(MAKE) build-backend cache=$(cache)
@$(MAKE) build-yjs-provider cache=$(cache)
@$(MAKE) build-frontend cache=$(cache)
.PHONY: build
build-backend: cache ?=
build-backend: ## build the app-dev container
@$(COMPOSE) build app-dev $(cache)
.PHONY: build-backend
build-yjs-provider: cache ?=
build-yjs-provider: ## build the y-provider container
@$(COMPOSE) build y-provider $(cache)
.PHONY: build-yjs-provider
build-frontend: cache ?=
build-frontend: ## build the frontend container
@$(COMPOSE) build frontend-dev $(cache)
.PHONY: build-frontend
down: ## stop and remove containers, networks, images, and volumes
@$(COMPOSE) down
.PHONY: down
@@ -105,11 +122,17 @@ logs: ## display app-dev logs (follow mode)
run: ## start the wsgi (production) and development server
@$(COMPOSE) up --force-recreate -d celery-dev
@$(COMPOSE) up --force-recreate -d nginx
@$(COMPOSE) up --force-recreate -d y-provider
@echo "Wait for postgresql to be up..."
@$(WAIT_DB)
.PHONY: run
run-with-frontend: ## Start all the containers needed (backend to frontend)
@$(MAKE) run
@$(COMPOSE) up --force-recreate -d frontend-dev
.PHONY: run-with-frontend
status: ## an alias for "docker compose ps"
@$(COMPOSE) ps
.PHONY: status
@@ -286,10 +309,19 @@ help:
@grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(firstword $(MAKEFILE_LIST)) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "$(GREEN)%-30s$(RESET) %s\n", $$1, $$2}'
.PHONY: help
# Front
run-frontend-dev: ## Install and run the frontend dev
@$(COMPOSE) up --force-recreate -d frontend-dev
.PHONY: run-frontend-dev
# Front
frontend-install: ## install the frontend locally
cd $(PATH_FRONT_IMPRESS) && yarn
.PHONY: frontend-install
frontend-lint: ## run the frontend linter
cd $(PATH_FRONT) && yarn lint
.PHONY: frontend-lint
run-frontend-development: ## Run the frontend in development mode
@$(COMPOSE) stop frontend-dev
cd $(PATH_FRONT_IMPRESS) && yarn dev
.PHONY: run-frontend-development
frontend-i18n-extract: ## Extract the frontend translation inside a json to be used for crowdin
cd $(PATH_FRONT) && yarn i18n:extract
@@ -314,7 +346,7 @@ start-tilt: ## start the kubernetes cluster using kind
tilt up -f ./bin/Tiltfile
.PHONY: build-k8s-cluster
VERSION_TYPE ?= minor
bump-packages-version: VERSION_TYPE ?= minor
bump-packages-version: ## bump the version of the project - VERSION_TYPE can be "major", "minor", "patch"
cd ./src/mail && yarn version --no-git-tag-version --$(VERSION_TYPE)
cd ./src/frontend/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
+31 -10
View File
@@ -37,14 +37,6 @@ The easiest way to start working on the project is to use GNU Make:
$ make bootstrap FLUSH_ARGS='--no-input'
```
Then you can access to the project in development mode by going to http://localhost:3000.
You will be prompted to log in, the default credentials are:
```bash
username: impress
password: impress
```
---
This command builds the `app` container, installs dependencies, performs
database migrations and compile translations. It's a good idea to use this
command each time you are pulling code from the project repository to avoid
@@ -52,12 +44,41 @@ dependency-releated or migration-releated issues.
Your Docker services should now be up and running 🎉
Note that if you need to run them afterwards, you can use the eponym Make rule:
You can access to the project by going to http://localhost:3000.
You will be prompted to log in, the default credentials are:
```bash
username: impress
password: impress
```
📝 Note that if you need to run them afterwards, you can use the eponym Make rule:
```bash
$ make run-frontend-dev
$ make run-with-frontend
```
---
⚠️ For the frontend developper, it is often better to run the frontend in development mode locally.
To do so, install the frontend dependencies with the following command:
```bash
$ make frontend-install
```
And run the frontend locally in development mode with the following command:
```bash
$ make run-frontend-development
```
To start all the services, except the frontend container, you can use the following command:
```bash
$ make run
```
---
### Adding content
You can create a basic demo site by running:
+13 -21
View File
@@ -63,7 +63,6 @@ services:
- mailcatcher
- redis
- createbuckets
- nginx
celery-dev:
user: ${DOCKER_USER:-1000}
@@ -118,14 +117,22 @@ services:
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
depends_on:
- keycloak
- app-dev
nginx-front:
image: nginx:1.25
frontend-dev:
user: "${DOCKER_USER:-1000}"
build:
context: .
dockerfile: ./src/frontend/Dockerfile
target: frontend-production
args:
API_ORIGIN: "http://localhost:8071"
Y_PROVIDER_URL: "ws://localhost:4444"
MEDIA_URL: "http://localhost:8083"
SW_DEACTIVATED: "true"
image: impress:frontend-development
ports:
- "3000:3000"
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
@@ -161,21 +168,6 @@ services:
- /home/frontend/servers/y-provider/node_modules/
- /home/frontend/servers/y-provider/dist/
frontend-dev:
user: "${DOCKER_USER:-1000}"
build:
context: .
dockerfile: ./src/frontend/Dockerfile
target: impress-dev
ports:
- "3000:3000"
volumes:
- ./src/frontend/apps/impress:/home/frontend/apps/impress
- /home/frontend/node_modules/
depends_on:
- y-provider
- celery-dev
kc_postgresql:
image: postgres:14.3
ports:
+5
View File
@@ -39,3 +39,8 @@ LOGOUT_REDIRECT_URL=http://localhost:3000
OIDC_REDIRECT_ALLOWED_HOSTS=["http://localhost:8083", "http://localhost:3000"]
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
# AI
AI_BASE_URL=https://openaiendpoint.com
AI_API_KEY=password
AI_MODEL=llama
+35
View File
@@ -1,9 +1,12 @@
"""Permission handlers for the impress core app."""
from django.core import exceptions
from django.db.models import Q
from rest_framework import permissions
from core.models import DocumentAccess, RoleChoices
ACTION_FOR_METHOD_TO_PERMISSION = {
"versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"}
}
@@ -59,6 +62,38 @@ class IsOwnedOrPublic(IsAuthenticated):
return False
class CanCreateInvitationPermission(permissions.BasePermission):
"""
Custom permission class to handle permission checks for managing invitations.
"""
def has_permission(self, request, view):
user = request.user
# Ensure the user is authenticated
if not (bool(request.auth) or request.user.is_authenticated):
return False
# Apply permission checks only for creation (POST requests)
if view.action != "create":
return True
# Check if resource_id is passed in the context
try:
document_id = view.kwargs["resource_id"]
except KeyError as exc:
raise exceptions.ValidationError(
"You must set a document ID in kwargs to manage document invitations."
) from exc
# Check if the user has access to manage invitations (Owner/Admin roles)
return DocumentAccess.objects.filter(
Q(user=user) | Q(team__in=user.teams),
document=document_id,
role__in=[RoleChoices.OWNER, RoleChoices.ADMIN],
).exists()
class AccessPermission(permissions.BasePermission):
"""Permission class for access objects."""
+129 -49
View File
@@ -6,9 +6,11 @@ from django.conf import settings
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
import magic
from rest_framework import exceptions, serializers
from core import models
from core import enums, models
from core.services.ai_services import AI_ACTIONS
class UserSerializer(serializers.ModelSerializer):
@@ -69,6 +71,7 @@ class BaseAccessSerializer(serializers.ModelSerializer):
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],
**{self.Meta.resource_field_name: resource_id}, # pylint: disable=no-member
).exists():
raise exceptions.PermissionDenied(
"You are not allowed to manage accesses for this resource."
@@ -134,32 +137,65 @@ class BaseResourceSerializer(serializers.ModelSerializer):
return {}
class DocumentSerializer(BaseResourceSerializer):
"""Serialize documents."""
class ListDocumentSerializer(BaseResourceSerializer):
"""Serialize documents with limited fields for display in lists."""
content = serializers.CharField(required=False)
accesses = DocumentAccessSerializer(many=True, read_only=True)
is_favorite = serializers.BooleanField(read_only=True)
nb_accesses = serializers.IntegerField(read_only=True)
class Meta:
model = models.Document
fields = [
"id",
"content",
"title",
"accesses",
"abilities",
"content",
"created_at",
"is_favorite",
"link_role",
"link_reach",
"created_at",
"nb_accesses",
"title",
"updated_at",
]
read_only_fields = [
"id",
"accesses",
"abilities",
"created_at",
"is_favorite",
"link_role",
"link_reach",
"nb_accesses",
"updated_at",
]
class DocumentSerializer(ListDocumentSerializer):
"""Serialize documents with all fields for display in detail views."""
content = serializers.CharField(required=False)
class Meta:
model = models.Document
fields = [
"id",
"abilities",
"content",
"created_at",
"is_favorite",
"link_role",
"link_reach",
"nb_accesses",
"title",
"updated_at",
]
read_only_fields = [
"id",
"abilities",
"created_at",
"is_avorite",
"link_role",
"link_reach",
"nb_accesses",
"updated_at",
]
@@ -218,16 +254,42 @@ class FileUploadSerializer(serializers.Serializer):
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}"
)
extension = file.name.rpartition(".")[-1] if "." in file.name else None
# Read the first few bytes to determine the MIME type accurately
mime = magic.Magic(mime=True)
magic_mime_type = mime.from_buffer(file.read(1024))
file.seek(0) # Reset file pointer to the beginning after reading
self.context["is_unsafe"] = (
magic_mime_type in settings.DOCUMENT_UNSAFE_MIME_TYPES
)
extension_mime_type, _ = mimetypes.guess_type(file.name)
# Try guessing a coherent extension from the mimetype
if extension_mime_type != magic_mime_type:
self.context["is_unsafe"] = True
guessed_ext = mimetypes.guess_extension(magic_mime_type)
# Missing extensions or extensions longer than 5 characters (it's as long as an extension
# can be) are replaced by the extension we eventually guessed from mimetype.
if (extension is None or len(extension) > 5) and guessed_ext:
extension = guessed_ext[1:]
if extension is None:
raise serializers.ValidationError("Could not determine file extension.")
self.context["expected_extension"] = extension
return file
def validate(self, attrs):
"""Override validate to add the computed extension to validated_data."""
attrs["expected_extension"] = self.context["expected_extension"]
attrs["is_unsafe"] = self.context["is_unsafe"]
return attrs
class TemplateSerializer(BaseResourceSerializer):
"""Serialize templates."""
@@ -299,48 +361,36 @@ class InvitationSerializer(serializers.ModelSerializer):
return {}
def validate(self, attrs):
"""Validate and restrict invitation to new user based on email."""
"""Validate invitation data."""
request = self.context.get("request")
user = getattr(request, "user", None)
role = attrs.get("role")
try:
document_id = self.context["resource_id"]
except KeyError as exc:
raise exceptions.ValidationError(
"You must set a document ID in kwargs to create a new document invitation."
) from exc
attrs["document_id"] = self.context["resource_id"]
if not user and user.is_authenticated:
raise exceptions.PermissionDenied(
"Anonymous users are not allowed to create invitations."
)
# Only set the issuer if the instance is being created
if self.instance is None:
attrs["issuer"] = user
if not models.DocumentAccess.objects.filter(
Q(user=user) | Q(team__in=user.teams),
document=document_id,
role__in=[models.RoleChoices.OWNER, models.RoleChoices.ADMIN],
).exists():
raise exceptions.PermissionDenied(
"You are not allowed to manage invitations for this document."
)
return attrs
if (
role == models.RoleChoices.OWNER
and not models.DocumentAccess.objects.filter(
def validate_role(self, role):
"""Custom validation for the role field."""
request = self.context.get("request")
user = getattr(request, "user", None)
document_id = self.context["resource_id"]
# If the role is OWNER, check if the user has OWNER access
if role == models.RoleChoices.OWNER:
if not models.DocumentAccess.objects.filter(
Q(user=user) | Q(team__in=user.teams),
document=document_id,
role=models.RoleChoices.OWNER,
).exists()
):
raise exceptions.PermissionDenied(
"Only owners of a document can invite other users as owners."
)
).exists():
raise serializers.ValidationError(
"Only owners of a document can invite other users as owners."
)
attrs["document_id"] = document_id
attrs["issuer"] = user
return attrs
return role
class VersionFilterSerializer(serializers.Serializer):
@@ -350,3 +400,33 @@ class VersionFilterSerializer(serializers.Serializer):
page_size = serializers.IntegerField(
required=False, min_value=1, max_value=50, default=20
)
class AITransformSerializer(serializers.Serializer):
"""Serializer for AI transform requests."""
action = serializers.ChoiceField(choices=AI_ACTIONS, required=True)
text = serializers.CharField(required=True)
def validate_text(self, value):
"""Ensure the text field is not empty."""
if len(value.strip()) == 0:
raise serializers.ValidationError("Text field cannot be empty.")
return value
class AITranslateSerializer(serializers.Serializer):
"""Serializer for AI translate requests."""
language = serializers.ChoiceField(
choices=tuple(enums.ALL_LANGUAGES.items()), required=True
)
text = serializers.CharField(required=True)
def validate_text(self, value):
"""Ensure the text field is not empty."""
if len(value.strip()) == 0:
raise serializers.ValidationError("Text field cannot be empty.")
return value
+96
View File
@@ -1,8 +1,14 @@
"""Util to generate S3 authorization headers for object storage access control"""
import time
from abc import ABC, abstractmethod
from django.conf import settings
from django.core.cache import cache
from django.core.files.storage import default_storage
import botocore
from rest_framework.throttling import BaseThrottle
def generate_s3_authorization_headers(key):
@@ -31,3 +37,93 @@ def generate_s3_authorization_headers(key):
auth.add_auth(request)
return request
class AIBaseRateThrottle(BaseThrottle, ABC):
"""Base throttle class for AI-related rate limiting with backoff."""
def __init__(self, rates):
"""Initialize instance attributes with configurable rates."""
super().__init__()
self.rates = rates
self.cache_key = None
self.recent_requests_minute = 0
self.recent_requests_hour = 0
self.recent_requests_day = 0
@abstractmethod
def get_cache_key(self, request, view):
"""Abstract method to generate cache key for throttling."""
def allow_request(self, request, view):
"""Check if the request is allowed based on rate limits."""
self.cache_key = self.get_cache_key(request, view)
if not self.cache_key:
return True # Allow if no cache key is generated
now = time.time()
history = cache.get(self.cache_key, [])
# Keep requests within the last 24 hours
history = [req for req in history if req > now - 86400]
# Calculate recent requests
self.recent_requests_minute = len([req for req in history if req > now - 60])
self.recent_requests_hour = len([req for req in history if req > now - 3600])
self.recent_requests_day = len(history)
# Check rate limits
if self.recent_requests_minute >= self.rates["minute"]:
return False
if self.recent_requests_hour >= self.rates["hour"]:
return False
if self.recent_requests_day >= self.rates["day"]:
return False
# Log the request
history.append(now)
cache.set(self.cache_key, history, timeout=86400)
return True
def wait(self):
"""Implement a backoff strategy by increasing wait time based on limits hit."""
if self.recent_requests_day >= self.rates["day"]:
return 86400
if self.recent_requests_hour >= self.rates["hour"]:
return 3600
if self.recent_requests_minute >= self.rates["minute"]:
return 60
return None
class AIDocumentRateThrottle(AIBaseRateThrottle):
"""Throttle for limiting AI requests per document with backoff."""
def __init__(self, *args, **kwargs):
super().__init__(settings.AI_DOCUMENT_RATE_THROTTLE_RATES)
def get_cache_key(self, request, view):
"""Include document ID in the cache key."""
document_id = view.kwargs["pk"]
return f"document_{document_id}_throttle_ai"
class AIUserRateThrottle(AIBaseRateThrottle):
"""Throttle that limits requests per user or IP with backoff and rate limits."""
def __init__(self, *args, **kwargs):
super().__init__(settings.AI_USER_RATE_THROTTLE_RATES)
def get_cache_key(self, request, view=None):
"""Generate a cache key based on the user ID or IP for anonymous users."""
if request.user.is_authenticated:
return f"user_{request.user.id!s}_throttle_ai"
return f"anonymous_{self.get_ident(request)}_throttle_ai"
def get_ident(self, request):
"""Return the request IP address."""
x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
return (
x_forwarded_for.split(",")[0]
if x_forwarded_for
else request.META.get("REMOTE_ADDR")
)
+249 -65
View File
@@ -1,19 +1,22 @@
"""API endpoints"""
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.contrib.postgres.search import TrigramSimilarity
from django.core.exceptions import ValidationError
from django.core.files.storage import default_storage
from django.db.models import (
Count,
Exists,
Min,
OuterRef,
Q,
Subquery,
Value,
)
from django.http import Http404
@@ -22,6 +25,7 @@ from rest_framework import (
decorators,
exceptions,
filters,
metadata,
mixins,
pagination,
status,
@@ -31,7 +35,8 @@ from rest_framework import (
response as drf_response,
)
from core import models
from core import enums, models
from core.services.ai_services import AIService
from . import permissions, serializers, utils
@@ -155,8 +160,21 @@ class UserViewSet(
# Filter users by email similarity
if query := self.request.GET.get("q", ""):
# For performance reasons we filter first by similarity, which relies on an index,
# then only calculate precise similarity scores for sorting purposes
queryset = queryset.filter(email__trigram_word_similar=query)
queryset = queryset.annotate(
similarity=TrigramSimilarity("email", query)
)
# When the query only is on the name part, we should try to make many proposals
# But when the query looks like an email we should only propose serious matches
threshold = 0.6 if "@" in query else 0.1
queryset = queryset.filter(similarity__gt=threshold).order_by(
"-similarity"
)
return queryset
@decorators.action(
@@ -176,42 +194,6 @@ class UserViewSet(
)
class ResourceViewsetMixin:
"""Mixin with methods common to all resource viewsets that are managed with accesses."""
filter_backends = [filters.OrderingFilter]
ordering_fields = ["created_at", "updated_at", "title"]
ordering = ["-created_at"]
def get_queryset(self):
"""Custom queryset to get user related resources."""
queryset = super().get_queryset()
user = self.request.user
if not user.is_authenticated:
return queryset
user_roles_query = (
self.access_model_class.objects.filter(
Q(user=user) | Q(team__in=user.teams),
**{self.resource_field_name: OuterRef("pk")},
)
.values(self.resource_field_name)
.annotate(roles_array=ArrayAgg("role"))
.values("roles_array")
)
return queryset.annotate(user_roles=Subquery(user_roles_query)).distinct()
def perform_create(self, serializer):
"""Set the current user as owner of the newly created object."""
obj = serializer.save()
self.access_model_class.objects.create(
user=self.request.user,
role=models.RoleChoices.OWNER,
**{self.resource_field_name: obj},
)
class ResourceAccessViewsetMixin:
"""Mixin with methods common to all access viewsets."""
@@ -303,8 +285,24 @@ class ResourceAccessViewsetMixin:
serializer.save()
class DocumentMetadata(metadata.SimpleMetadata):
"""Custom metadata class to add information"""
def determine_metadata(self, request, view):
"""Add language choices only for the list endpoint."""
simple_metadata = super().determine_metadata(request, view)
if request.path.endswith("/documents/"):
simple_metadata["actions"]["POST"]["language"] = {
"choices": [
{"value": code, "display_name": name}
for code, name in enums.ALL_LANGUAGES.items()
]
}
return simple_metadata
class DocumentViewSet(
ResourceViewsetMixin,
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
mixins.UpdateModelMixin,
@@ -312,19 +310,59 @@ class DocumentViewSet(
):
"""Document ViewSet"""
filter_backends = [filters.OrderingFilter]
metadata_class = DocumentMetadata
ordering = ["-updated_at"]
ordering_fields = ["created_at", "is_favorite", "updated_at", "title"]
permission_classes = [
permissions.AccessPermission,
]
serializer_class = serializers.DocumentSerializer
access_model_class = models.DocumentAccess
resource_field_name = "document"
queryset = models.Document.objects.all()
ordering = ["-updated_at"]
serializer_class = serializers.DocumentSerializer
def get_serializer_class(self):
"""
Use ListDocumentSerializer for list actions, otherwise use DocumentSerializer.
"""
if self.action == "list":
return serializers.ListDocumentSerializer
return self.serializer_class
def get_queryset(self):
"""Optimize queryset to include favorite status for the current user."""
queryset = super().get_queryset()
user = self.request.user
# Annotate the number of accesses associated with each document
queryset = queryset.annotate(nb_accesses=Count("accesses"))
if not user.is_authenticated:
# If the user is not authenticated, annotate `is_favorite` as False
return queryset.annotate(is_favorite=Value(False))
# Annotate the queryset to indicate if the document is favorited by the current user
favorite_exists = models.DocumentFavorite.objects.filter(
document_id=OuterRef("pk"), user=user
)
queryset = queryset.annotate(is_favorite=Exists(favorite_exists))
# Annotate the queryset with the logged-in user roles
user_roles_query = (
models.DocumentAccess.objects.filter(
Q(user=user) | Q(team__in=user.teams),
document_id=OuterRef("pk"),
)
.values("document")
.annotate(roles_array=ArrayAgg("role"))
.values("roles_array")
)
return queryset.annotate(user_roles=Subquery(user_roles_query)).distinct()
def list(self, request, *args, **kwargs):
"""Restrict resources returned by the list endpoint"""
queryset = self.filter_queryset(self.get_queryset())
user = self.request.user
if user.is_authenticated:
queryset = queryset.filter(
Q(accesses__user=user)
@@ -368,6 +406,15 @@ class DocumentViewSet(
return drf_response.Response(serializer.data)
def perform_create(self, serializer):
"""Set the current user as owner of the newly created object."""
obj = serializer.save()
models.DocumentAccess.objects.create(
document=obj,
user=self.request.user,
role=models.RoleChoices.OWNER,
)
@decorators.action(detail=True, methods=["get"], url_path="versions")
def versions_list(self, request, *args, **kwargs):
"""
@@ -456,14 +503,48 @@ class DocumentViewSet(
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.is_valid(raise_exception=True)
serializer.save()
return drf_response.Response(serializer.data, status=status.HTTP_200_OK)
@decorators.action(detail=True, methods=["post", "delete"], url_path="favorite")
def favorite(self, request, *args, **kwargs):
"""
Mark or unmark the document as a favorite for the logged-in user based on the HTTP method.
"""
# Check permissions first
document = self.get_object()
user = request.user
if request.method == "POST":
# Try to mark as favorite
try:
models.DocumentFavorite.objects.create(document=document, user=user)
except ValidationError:
return drf_response.Response(
{"detail": "Document already marked as favorite"},
status=status.HTTP_200_OK,
)
return drf_response.Response(
{"detail": "Document marked as favorite"},
status=status.HTTP_201_CREATED,
)
# Handle DELETE method to unmark as favorite
deleted, _ = models.DocumentFavorite.objects.filter(
document=document, user=user
).delete()
if deleted:
return drf_response.Response(
{"detail": "Document unmarked as favorite"},
status=status.HTTP_204_NO_CONTENT,
)
return drf_response.Response(
{"detail": "Document was already not marked as favorite"},
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"""
@@ -472,19 +553,23 @@ class DocumentViewSet(
# 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]
serializer.is_valid(raise_exception=True)
# 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}"
extension = serializer.validated_data["expected_extension"]
key = f"{document.key_base}/{ATTACHMENTS_FOLDER:s}/{file_id!s}.{extension:s}"
# Prepare metadata for storage
extra_args = {"Metadata": {"owner": str(request.user.id)}}
if serializer.validated_data["is_unsafe"]:
extra_args["Metadata"]["is_unsafe"] = "true"
file = serializer.validated_data["file"]
default_storage.connection.meta.client.upload_fileobj(
file, default_storage.bucket_name, key, ExtraArgs=extra_args
)
default_storage.save(key, file)
return drf_response.Response(
{"file": f"{settings.MEDIA_URL:s}{key:s}"}, status=status.HTTP_201_CREATED
)
@@ -531,6 +616,63 @@ class DocumentViewSet(
request = utils.generate_s3_authorization_headers(f"{pk:s}/{attachment_key:s}")
return drf_response.Response("authorized", headers=request.headers, status=200)
@decorators.action(
detail=True,
methods=["post"],
name="Apply a transformation action on a piece of text with AI",
url_path="ai-transform",
throttle_classes=[utils.AIDocumentRateThrottle, utils.AIUserRateThrottle],
)
def ai_transform(self, request, *args, **kwargs):
"""
POST /api/v1.0/documents/<resource_id>/ai-transform
with expected data:
- text: str
- action: str [prompt, correct, rephrase, summarize]
Return JSON response with the processed text.
"""
# Check permissions first
self.get_object()
serializer = serializers.AITransformSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
text = serializer.validated_data["text"]
action = serializer.validated_data["action"]
response = AIService().transform(text, action)
return drf_response.Response(response, status=status.HTTP_200_OK)
@decorators.action(
detail=True,
methods=["post"],
name="Translate a piece of text with AI",
serializer_class=serializers.AITranslateSerializer,
url_path="ai-translate",
throttle_classes=[utils.AIDocumentRateThrottle, utils.AIUserRateThrottle],
)
def ai_translate(self, request, *args, **kwargs):
"""
POST /api/v1.0/documents/<resource_id>/ai-translate
with expected data:
- text: str
- language: str [settings.LANGUAGES]
Return JSON response with the translated text.
"""
# Check permissions first
self.get_object()
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
text = serializer.validated_data["text"]
language = serializer.validated_data["language"]
response = AIService().translate(text, language)
return drf_response.Response(response, status=status.HTTP_200_OK)
class DocumentAccessViewSet(
ResourceAccessViewsetMixin,
@@ -576,13 +718,16 @@ class DocumentAccessViewSet(
"""Add a new access to the document and send an email to the new added user."""
access = serializer.save()
language = self.request.headers.get("Content-Language", "en-us")
access.document.email_invitation(
language, access.user.email, access.role, self.request.user.email
language,
access.user.email,
access.role,
self.request.user,
)
class TemplateViewSet(
ResourceViewsetMixin,
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
mixins.RetrieveModelMixin,
@@ -591,15 +736,35 @@ class TemplateViewSet(
):
"""Template ViewSet"""
filter_backends = [filters.OrderingFilter]
permission_classes = [
permissions.IsAuthenticatedOrSafe,
permissions.AccessPermission,
]
ordering = ["-created_at"]
ordering_fields = ["created_at", "updated_at", "title"]
serializer_class = serializers.TemplateSerializer
access_model_class = models.TemplateAccess
resource_field_name = "template"
queryset = models.Template.objects.all()
def get_queryset(self):
"""Custom queryset to get user related templates."""
queryset = super().get_queryset()
user = self.request.user
if not user.is_authenticated:
return queryset
user_roles_query = (
models.TemplateAccess.objects.filter(
Q(user=user) | Q(team__in=user.teams),
template_id=OuterRef("pk"),
)
.values("template")
.annotate(roles_array=ArrayAgg("role"))
.values("roles_array")
)
return queryset.annotate(user_roles=Subquery(user_roles_query)).distinct()
def list(self, request, *args, **kwargs):
"""Restrict templates returned by the list endpoint"""
queryset = self.filter_queryset(self.get_queryset())
@@ -621,6 +786,15 @@ class TemplateViewSet(
serializer = self.get_serializer(queryset, many=True)
return drf_response.Response(serializer.data)
def perform_create(self, serializer):
"""Set the current user as owner of the newly created object."""
obj = serializer.save()
models.TemplateAccess.objects.create(
template=obj,
user=self.request.user,
role=models.RoleChoices.OWNER,
)
@decorators.action(
detail=True,
methods=["post"],
@@ -726,7 +900,10 @@ class InvitationViewset(
lookup_field = "id"
pagination_class = Pagination
permission_classes = [permissions.IsAuthenticated, permissions.AccessPermission]
permission_classes = [
permissions.CanCreateInvitationPermission,
permissions.AccessPermission,
]
queryset = (
models.Invitation.objects.all()
.select_related("document")
@@ -761,10 +938,16 @@ class InvitationViewset(
)
queryset = (
# The logged-in user should be part of a document to see its accesses
# The logged-in user should be administrator or owner to see its accesses
queryset.filter(
Q(document__accesses__user=user)
| Q(document__accesses__team__in=teams),
Q(
document__accesses__user=user,
document__accesses__role__in=models.PRIVILEGED_ROLES,
)
| Q(
document__accesses__team__in=teams,
document__accesses__role__in=models.PRIVILEGED_ROLES,
),
)
# Abilities are computed based on logged-in user's role and
# the user role on each document access
@@ -778,6 +961,7 @@ class InvitationViewset(
invitation = serializer.save()
language = self.request.headers.get("Content-Language", "en-us")
invitation.document.email_invitation(
language, invitation.email, invitation.role, self.request.user.email
language, invitation.email, invitation.role, self.request.user
)
+4 -2
View File
@@ -84,6 +84,8 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
user = self.get_existing_user(sub, email)
if user:
if not user.is_active:
raise SuspiciousOperation(_("User account is disabled"))
self.update_user_if_needed(user, claims)
elif self.get_settings("OIDC_CREATE_USER", True):
user = User.objects.create(sub=sub, password="!", **claims) # noqa: S106
@@ -101,11 +103,11 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
def get_existing_user(self, sub, email):
"""Fetch existing user by sub or email."""
try:
return User.objects.get(sub=sub, is_active=True)
return User.objects.get(sub=sub)
except User.DoesNotExist:
if email and settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION:
try:
return User.objects.get(email=email, is_active=True)
return User.objects.get(email=email)
except User.DoesNotExist:
pass
return None
+5 -9
View File
@@ -2,15 +2,11 @@
Core application enums declaration
"""
from django.conf import global_settings, settings
from django.conf import global_settings
from django.utils.translation import gettext_lazy as _
# Django sets `LANGUAGES` by default with all supported languages. We can use it for
# the choice of languages which should not be limited to the few languages active in
# the app.
# In Django's code base, `LANGUAGES` is set by default with all supported languages.
# We can use it for the choice of languages which should not be limited to the few languages
# active in the app.
# pylint: disable=no-member
ALL_LANGUAGES = getattr(
settings,
"ALL_LANGUAGES",
[(language, _(name)) for language, name in global_settings.LANGUAGES],
)
ALL_LANGUAGES = {language: _(name) for language, name in global_settings.LANGUAGES}
+25
View File
@@ -27,6 +27,24 @@ class UserFactory(factory.django.DjangoModelFactory):
language = factory.fuzzy.FuzzyChoice([lang[0] for lang in settings.LANGUAGES])
password = make_password("password")
@factory.post_generation
def with_owned_document(self, create, extracted, **kwargs):
"""
Create a document for which the user is owner to check
that there is no interference
"""
if create and (extracted is True):
UserDocumentAccessFactory(user=self, role="owner")
@factory.post_generation
def with_owned_template(self, create, extracted, **kwargs):
"""
Create a template for which the user is owner to check
that there is no interference
"""
if create and (extracted is True):
UserTemplateAccessFactory(user=self, role="owner")
class DocumentFactory(factory.django.DjangoModelFactory):
"""A factory to create documents"""
@@ -62,6 +80,13 @@ class DocumentFactory(factory.django.DjangoModelFactory):
for item in extracted:
models.LinkTrace.objects.create(document=self, user=item)
@factory.post_generation
def favorited_by(self, create, extracted, **kwargs):
"""Mark document as favorited by a list of users."""
if create and extracted:
for item in extracted:
models.DocumentFavorite.objects.create(document=self, user=item)
class UserDocumentAccessFactory(factory.django.DjangoModelFactory):
"""Create fake document user accesses for testing."""
@@ -0,0 +1,18 @@
# Generated by Django 5.1.2 on 2024-10-25 11:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0007_fix_users_duplicate'),
]
operations = [
migrations.AlterField(
model_name='document',
name='link_reach',
field=models.CharField(choices=[('restricted', 'Restricted'), ('authenticated', 'Authenticated'), ('public', 'Public')], default='restricted', max_length=20),
),
]
@@ -0,0 +1,37 @@
# Generated by Django 5.1.2 on 2024-11-08 07:59
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0008_alter_document_link_reach'),
]
operations = [
migrations.AlterField(
model_name='user',
name='language',
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
),
migrations.CreateModel(
name='DocumentFavorite',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorited_by_users', to='core.document')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorite_documents', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Document favorite',
'verbose_name_plural': 'Document favorites',
'db_table': 'impress_document_favorite',
'constraints': [models.UniqueConstraint(fields=('user', 'document'), name='unique_document_favorite_user', violation_error_message='This document is already targeted by a favorite relation instance for the same user.')],
},
),
]
+66 -26
View File
@@ -72,6 +72,9 @@ class RoleChoices(models.TextChoices):
OWNER = "owner", _("Owner")
PRIVILEGED_ROLES = [RoleChoices.ADMIN, RoleChoices.OWNER]
class LinkReachChoices(models.TextChoices):
"""Defines types of access for links"""
@@ -127,17 +130,17 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
"""User model to work with OIDC only authentication."""
sub_validator = validators.RegexValidator(
regex=r"^[\w.@+-]+\Z",
regex=r"^[\w.@+-:]+\Z",
message=_(
"Enter a valid sub. This value may contain only letters, "
"numbers, and @/./+/-/_ characters."
"numbers, and @/./+/-/_/: characters."
),
)
sub = models.CharField(
_("sub"),
help_text=_(
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
),
max_length=255,
unique=True,
@@ -333,7 +336,7 @@ class Document(BaseModel):
link_reach = models.CharField(
max_length=20,
choices=LinkReachChoices.choices,
default=LinkReachChoices.AUTHENTICATED,
default=LinkReachChoices.RESTRICTED,
)
link_role = models.CharField(
max_length=20, choices=LinkRoleChoices.choices, default=LinkRoleChoices.READER
@@ -493,7 +496,8 @@ class Document(BaseModel):
# Compute version roles before adding link roles because we don't
# want anonymous users to access versions (we wouldn't know from
# which date to allow them anyway)
can_get_versions = bool(roles)
# Anonymous users should also not see document accesses
has_role = bool(roles)
# Add role provided by the document link
if self.link_reach == LinkReachChoices.PUBLIC or (
@@ -508,27 +512,35 @@ class Document(BaseModel):
can_get = bool(roles)
return {
"accesses_manage": is_owner_or_admin,
"accesses_view": has_role,
"ai_transform": is_owner_or_admin or is_editor,
"ai_translate": is_owner_or_admin or is_editor,
"attachment_upload": is_owner_or_admin or is_editor,
"destroy": RoleChoices.OWNER in roles,
"favorite": can_get and user.is_authenticated,
"link_configuration": is_owner_or_admin,
"manage_accesses": is_owner_or_admin,
"invite_owner": RoleChoices.OWNER in roles,
"partial_update": is_owner_or_admin or is_editor,
"retrieve": can_get,
"update": is_owner_or_admin or is_editor,
"versions_destroy": is_owner_or_admin,
"versions_list": can_get_versions,
"versions_retrieve": can_get_versions,
"versions_list": has_role,
"versions_retrieve": has_role,
}
def email_invitation(self, language, email, role, username_sender):
def email_invitation(self, language, email, role, sender):
"""Send email invitation."""
sender_name = sender.full_name or sender.email
domain = Site.objects.get_current().domain
try:
with override(language):
title = _("%(username)s shared a document with you: %(document)s") % {
"username": username_sender,
title = _(
"%(sender_name)s shared a document with you: %(document)s"
) % {
"sender_name": sender_name,
"document": self.title,
}
template_vars = {
@@ -536,7 +548,10 @@ class Document(BaseModel):
"domain": domain,
"document": self,
"link": f"{domain}/docs/{self.id}/",
"username": username_sender,
"sender_name": sender_name,
"sender_name_email": f"{sender.full_name} ({sender.email})"
if sender.full_name
else sender.email,
"role": RoleChoices(role).label.lower(),
}
msg_html = render_to_string("mail/html/invitation.html", template_vars)
@@ -586,6 +601,37 @@ class LinkTrace(BaseModel):
return f"{self.user!s} trace on document {self.document!s}"
class DocumentFavorite(BaseModel):
"""Relation model to store a user's favorite documents."""
document = models.ForeignKey(
Document,
on_delete=models.CASCADE,
related_name="favorited_by_users",
)
user = models.ForeignKey(
User, on_delete=models.CASCADE, related_name="favorite_documents"
)
class Meta:
db_table = "impress_document_favorite"
verbose_name = _("Document favorite")
verbose_name_plural = _("Document favorites")
constraints = [
models.UniqueConstraint(
fields=["user", "document"],
name="unique_document_favorite_user",
violation_error_message=_(
"This document is already targeted by a favorite relation instance "
"for the same user."
),
),
]
def __str__(self):
return f"{self.user!s} favorite on document {self.document!s}"
class DocumentAccess(BaseAccess):
"""Relation model to give access to a document for a user or a team with a role."""
@@ -667,7 +713,7 @@ class Template(BaseModel):
return {
"destroy": RoleChoices.OWNER in roles,
"generate_document": can_get,
"manage_accesses": is_owner_or_admin,
"accesses_manage": is_owner_or_admin,
"update": is_owner_or_admin or is_editor,
"partial_update": is_owner_or_admin or is_editor,
"retrieve": can_get,
@@ -872,8 +918,6 @@ class Invitation(BaseModel):
def get_abilities(self, user):
"""Compute and return abilities for a given user."""
can_delete = False
can_update = False
roles = []
if user.is_authenticated:
@@ -888,17 +932,13 @@ class Invitation(BaseModel):
except (self._meta.model.DoesNotExist, IndexError):
roles = []
can_delete = bool(
set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
)
can_update = bool(
set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
)
is_admin_or_owner = bool(
set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
)
return {
"destroy": can_delete,
"update": can_update,
"partial_update": can_update,
"retrieve": bool(roles),
"destroy": is_admin_or_owner,
"update": is_admin_or_owner,
"partial_update": is_admin_or_owner,
"retrieve": is_admin_or_owner,
}
+89
View File
@@ -0,0 +1,89 @@
"""AI services."""
import json
import re
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from openai import OpenAI
from core import enums
AI_ACTIONS = {
"prompt": (
"Answer the prompt in markdown format. Return JSON: "
'{"answer": "Your markdown answer"}. '
"Do not provide any other information."
),
"correct": (
"Correct grammar and spelling of the markdown text, "
"preserving language and markdown formatting. "
'Return JSON: {"answer": "your corrected markdown text"}. '
"Do not provide any other information."
),
"rephrase": (
"Rephrase the given markdown text, "
"preserving language and markdown formatting. "
'Return JSON: {"answer": "your rephrased markdown text"}. '
"Do not provide any other information."
),
"summarize": (
"Summarize the markdown text, preserving language and markdown formatting. "
'Return JSON: {"answer": "your markdown summary"}. '
"Do not provide any other information."
),
}
AI_TRANSLATE = (
"Translate the markdown text to {language:s}, preserving markdown formatting. "
'Return JSON: {{"answer": "your translated markdown text in {language:s}"}}. '
"Do not provide any other information."
)
class AIService:
"""Service class for AI-related operations."""
def __init__(self):
"""Ensure that the AI configuration is set properly."""
if (
settings.AI_BASE_URL is None
or settings.AI_API_KEY is None
or settings.AI_MODEL is None
):
raise ImproperlyConfigured("AI configuration not set")
self.client = OpenAI(base_url=settings.AI_BASE_URL, api_key=settings.AI_API_KEY)
def call_ai_api(self, system_content, text):
"""Helper method to call the OpenAI API and process the response."""
response = self.client.chat.completions.create(
model=settings.AI_MODEL,
response_format={"type": "json_object"},
messages=[
{"role": "system", "content": system_content},
{"role": "user", "content": json.dumps({"markdown_input": text})},
],
)
content = response.choices[0].message.content
sanitized_content = re.sub(r"(?<!\\)\n", "\\\\n", content)
sanitized_content = re.sub(r"(?<!\\)\t", "\\\\t", sanitized_content)
json_response = json.loads(sanitized_content)
if "answer" not in json_response:
raise RuntimeError("AI response does not contain an answer")
return json_response
def transform(self, text, action):
"""Transform text based on specified action."""
system_content = AI_ACTIONS[action]
return self.call_ai_api(system_content, text)
def translate(self, text, language):
"""Translate text to a specified language."""
language_display = enums.ALL_LANGUAGES.get(language, language)
system_content = AI_TRANSLATE.format(language=language_display)
return self.call_ai_api(system_content, text)
@@ -305,3 +305,63 @@ def test_authentication_get_userinfo_invalid_response():
match="Invalid response format or token verification failed",
):
oidc_backend.get_userinfo("fake_access_token", None, None)
def test_authentication_getter_existing_disabled_user_via_sub(
django_assert_num_queries, monkeypatch
):
"""
If an existing user matches the sub but is disabled,
an error should be raised and a user should not be created.
"""
klass = OIDCAuthenticationBackend()
db_user = UserFactory(is_active=False)
def get_userinfo_mocked(*args):
return {
"sub": db_user.sub,
"email": db_user.email,
"first_name": "John",
"last_name": "Doe",
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with (
django_assert_num_queries(1),
pytest.raises(SuspiciousOperation, match="User account is disabled"),
):
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
assert models.User.objects.count() == 1
def test_authentication_getter_existing_disabled_user_via_email(
django_assert_num_queries, monkeypatch
):
"""
If an existing user does not matches the sub but matches the email and is disabled,
an error should be raised and a user should not be created.
"""
klass = OIDCAuthenticationBackend()
db_user = UserFactory(is_active=False)
def get_userinfo_mocked(*args):
return {
"sub": "random",
"email": db_user.email,
"first_name": "John",
"last_name": "Doe",
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with (
django_assert_num_queries(2),
pytest.raises(SuspiciousOperation, match="User account is disabled"),
):
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
assert models.User.objects.count() == 1
@@ -149,7 +149,7 @@ def test_api_document_accesses_retrieve_authenticated_unrelated():
Authenticated users should not be allowed to retrieve a document access for
a document to which they are not related.
"""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
@@ -246,7 +246,7 @@ def test_api_document_accesses_update_authenticated_unrelated():
Authenticated users should not be allowed to update a document access for a document to which
they are not related.
"""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
@@ -279,7 +279,7 @@ 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()
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
@@ -321,7 +321,7 @@ def test_api_document_accesses_update_administrator_except_owner(via, mock_user_
A user who is a direct administrator in a document should be allowed to update a user
access for this document, as long as they don't try to set the role to owner.
"""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
@@ -378,7 +378,7 @@ def test_api_document_accesses_update_administrator_from_owner(via, mock_user_te
A user who is an administrator in a document, should not be allowed to update
the user access of an "owner" for this document.
"""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
@@ -425,7 +425,7 @@ def test_api_document_accesses_update_administrator_to_owner(via, mock_user_team
A user who is an administrator in a document, should not be allowed to update
the user access of another user to grant document ownership.
"""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
@@ -479,7 +479,7 @@ def test_api_document_accesses_update_owner(via, mock_user_teams):
A user who is an owner in a document should be allowed to update
a user access for this document whatever the role.
"""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
@@ -535,7 +535,7 @@ def test_api_document_accesses_update_owner_self(via, mock_user_teams):
A user who is owner of a document should be allowed to update
their own user access provided there are other owners in the document.
"""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
@@ -605,7 +605,7 @@ def test_api_document_accesses_delete_authenticated():
Authenticated users should not be allowed to delete a document access for a
document to which they are not related.
"""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
@@ -617,7 +617,7 @@ def test_api_document_accesses_delete_authenticated():
)
assert response.status_code == 403
assert models.DocumentAccess.objects.count() == 1
assert models.DocumentAccess.objects.count() == 2
@pytest.mark.parametrize("role", ["reader", "editor"])
@@ -627,7 +627,7 @@ def test_api_document_accesses_delete_reader_or_editor(via, role, mock_user_team
Authenticated users should not be allowed to delete a document access for a
document in which they are a simple reader or editor.
"""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
@@ -643,7 +643,7 @@ def test_api_document_accesses_delete_reader_or_editor(via, role, mock_user_team
access = factories.UserDocumentAccessFactory(document=document)
assert models.DocumentAccess.objects.count() == 2
assert models.DocumentAccess.objects.count() == 3
assert models.DocumentAccess.objects.filter(user=access.user).exists()
response = client.delete(
@@ -651,7 +651,7 @@ def test_api_document_accesses_delete_reader_or_editor(via, role, mock_user_team
)
assert response.status_code == 403
assert models.DocumentAccess.objects.count() == 2
assert models.DocumentAccess.objects.count() == 3
@pytest.mark.parametrize("via", VIA)
@@ -699,7 +699,7 @@ def test_api_document_accesses_delete_administrator_on_owners(via, mock_user_tea
Users who are administrators in a document should not be allowed to delete an ownership
access from the document.
"""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
@@ -717,7 +717,7 @@ def test_api_document_accesses_delete_administrator_on_owners(via, mock_user_tea
access = factories.UserDocumentAccessFactory(document=document, role="owner")
assert models.DocumentAccess.objects.count() == 2
assert models.DocumentAccess.objects.count() == 3
assert models.DocumentAccess.objects.filter(user=access.user).exists()
response = client.delete(
@@ -725,7 +725,7 @@ def test_api_document_accesses_delete_administrator_on_owners(via, mock_user_tea
)
assert response.status_code == 403
assert models.DocumentAccess.objects.count() == 2
assert models.DocumentAccess.objects.count() == 3
@pytest.mark.parametrize("via", VIA)
@@ -766,7 +766,7 @@ def test_api_document_accesses_delete_owners_last_owner(via, mock_user_teams):
"""
It should not be possible to delete the last owner access from a document
"""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
@@ -783,10 +783,10 @@ def test_api_document_accesses_delete_owners_last_owner(via, mock_user_teams):
document=document, team="lasuite", role="owner"
)
assert models.DocumentAccess.objects.count() == 1
assert models.DocumentAccess.objects.count() == 2
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 403
assert models.DocumentAccess.objects.count() == 1
assert models.DocumentAccess.objects.count() == 2
@@ -18,13 +18,13 @@ 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()
other_user = factories.UserFactory()
response = APIClient().post(
f"/api/v1.0/documents/{document.id!s}/accesses/",
{
"user": str(user.id),
"user_id": str(other_user.id),
"document": str(document.id),
"role": random.choice(models.RoleChoices.choices)[0],
},
@@ -43,7 +43,7 @@ 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()
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
@@ -54,7 +54,7 @@ def test_api_document_accesses_create_authenticated_unrelated():
response = client.post(
f"/api/v1.0/documents/{document.id!s}/accesses/",
{
"user": str(other_user.id),
"user_id": str(other_user.id),
},
format="json",
)
@@ -69,7 +69,7 @@ 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()
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
@@ -89,7 +89,7 @@ def test_api_document_accesses_create_authenticated_reader_or_editor(
response = client.post(
f"/api/v1.0/documents/{document.id!s}/accesses/",
{
"user": str(other_user.id),
"user_id": str(other_user.id),
"role": new_role,
},
format="json",
@@ -107,7 +107,7 @@ def test_api_document_accesses_create_authenticated_administrator(via, mock_user
except for the "owner" role.
An email should be sent to the accesses to notify them of the adding.
"""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
@@ -129,7 +129,7 @@ def test_api_document_accesses_create_authenticated_administrator(via, mock_user
response = client.post(
f"/api/v1.0/documents/{document.id!s}/accesses/",
{
"user": str(other_user.id),
"user_id": str(other_user.id),
"role": "owner",
},
format="json",
@@ -171,7 +171,10 @@ def test_api_document_accesses_create_authenticated_administrator(via, mock_user
email = mail.outbox[0]
assert email.to == [other_user["email"]]
email_content = " ".join(email.body.split())
assert f"{user.email} shared a document with you: {document.title}" in email_content
assert (
f"{user.full_name} shared a document with you: {document.title}"
in email_content
)
assert "docs/" + str(document.id) + "/" in email_content
@@ -225,5 +228,8 @@ def test_api_document_accesses_create_authenticated_owner(via, mock_user_teams):
email = mail.outbox[0]
assert email.to == [other_user["email"]]
email_content = " ".join(email.body.split())
assert f"{user.email} shared a document with you: {document.title}" in email_content
assert (
f"{user.full_name} shared a document with you: {document.title}"
in email_content
)
assert "docs/" + str(document.id) + "/" in email_content
File diff suppressed because it is too large Load Diff
@@ -39,7 +39,7 @@ 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()
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
@@ -231,7 +231,7 @@ 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()
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
@@ -475,7 +475,7 @@ def test_api_document_versions_delete_authenticated(reach):
Authenticated users should not be allowed to delete a document version for a
public document to which they are not related.
"""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
@@ -500,7 +500,7 @@ def test_api_document_versions_delete_reader_or_editor(via, role, mock_user_team
Authenticated users should not be allowed to delete a document version for a
document in which they are a simple reader or editor.
"""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
@@ -0,0 +1,346 @@
"""
Test AI transform API endpoint for users in impress's core app.
"""
from unittest.mock import MagicMock, patch
from django.core.cache import cache
from django.test import override_settings
import pytest
from rest_framework.test import APIClient
from core import factories
from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
@pytest.fixture(autouse=True)
def clear_cache():
"""Fixture to clear the cache before each test."""
cache.clear()
@pytest.fixture
def ai_settings():
"""Fixture to set AI settings."""
with override_settings(
AI_BASE_URL="http://example.com", AI_API_KEY="test-key", AI_MODEL="llama"
):
yield
@pytest.mark.parametrize(
"reach, role",
[
("restricted", "reader"),
("restricted", "editor"),
("authenticated", "reader"),
("authenticated", "editor"),
("public", "reader"),
],
)
def test_api_documents_ai_transform_anonymous_forbidden(reach, role):
"""
Anonymous users should not be able to request AI transform if the link reach
and role don't allow it.
"""
document = factories.DocumentFactory(link_reach=reach, link_role=role)
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
response = APIClient().post(url, {"text": "hello", "action": "prompt"})
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_transform_anonymous_success(mock_create):
"""
Anonymous users should be able to request AI transform to a document
if the link reach and role permit it.
"""
document = factories.DocumentFactory(link_reach="public", link_role="editor")
answer = '{"answer": "Salut"}'
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=answer))]
)
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
response = APIClient().post(url, {"text": "Hello", "action": "summarize"})
assert response.status_code == 200
assert response.json() == {"answer": "Salut"}
mock_create.assert_called_once_with(
model="llama",
response_format={"type": "json_object"},
messages=[
{
"role": "system",
"content": (
"Summarize the markdown text, preserving language and markdown formatting. "
'Return JSON: {"answer": "your markdown summary"}. Do not provide any other '
"information."
),
},
{"role": "user", "content": '{"markdown_input": "Hello"}'},
],
)
@pytest.mark.parametrize(
"reach, role",
[
("restricted", "reader"),
("restricted", "editor"),
("authenticated", "reader"),
("public", "reader"),
],
)
def test_api_documents_ai_transform_authenticated_forbidden(reach, role):
"""
Users who are not related to a document can't request AI transform if the
link reach and role don't allow it.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach, link_role=role)
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
response = client.post(url, {"text": "Hello", "action": "prompt"})
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
@pytest.mark.parametrize(
"reach, role",
[
("authenticated", "editor"),
("public", "editor"),
],
)
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_transform_authenticated_success(mock_create, reach, role):
"""
Autenticated who are not related to a document should be able to request AI transform
if the link reach and role permit it.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach, link_role=role)
answer = '{"answer": "Salut"}'
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=answer))]
)
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
response = client.post(url, {"text": "Hello", "action": "prompt"})
assert response.status_code == 200
assert response.json() == {"answer": "Salut"}
mock_create.assert_called_once_with(
model="llama",
response_format={"type": "json_object"},
messages=[
{
"role": "system",
"content": (
'Answer the prompt in markdown format. Return JSON: {"answer": '
'"Your markdown answer"}. Do not provide any other information.'
),
},
{"role": "user", "content": '{"markdown_input": "Hello"}'},
],
)
@pytest.mark.parametrize("via", VIA)
def test_api_documents_ai_transform_reader(via, mock_user_teams):
"""
Users who are simple readers on a document should not be allowed to request AI transform.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_role="reader")
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role="reader")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="reader"
)
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
response = client.post(url, {"text": "Hello", "action": "prompt"})
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
@pytest.mark.parametrize("role", ["editor", "administrator", "owner"])
@pytest.mark.parametrize("via", VIA)
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_transform_success(mock_create, via, role, mock_user_teams):
"""
Editors, administrators and owners of a document should be able to request AI transform.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
)
answer = '{"answer": "Salut"}'
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=answer))]
)
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
response = client.post(url, {"text": "Hello", "action": "prompt"})
assert response.status_code == 200
assert response.json() == {"answer": "Salut"}
mock_create.assert_called_once_with(
model="llama",
response_format={"type": "json_object"},
messages=[
{
"role": "system",
"content": (
'Answer the prompt in markdown format. Return JSON: {"answer": '
'"Your markdown answer"}. Do not provide any other information.'
),
},
{"role": "user", "content": '{"markdown_input": "Hello"}'},
],
)
def test_api_documents_ai_transform_empty_text():
"""The text should not be empty when requesting AI transform."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="public", link_role="editor")
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
response = client.post(url, {"text": " ", "action": "prompt"})
assert response.status_code == 400
assert response.json() == {"text": ["This field may not be blank."]}
def test_api_documents_ai_transform_invalid_action():
"""The action should valid when requesting AI transform."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="public", link_role="editor")
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
response = client.post(url, {"text": "Hello", "action": "invalid"})
assert response.status_code == 400
assert response.json() == {"action": ['"invalid" is not a valid choice.']}
@override_settings(AI_DOCUMENT_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10})
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_transform_throttling_document(mock_create):
"""
Throttling per document should be triggered on the AI transform endpoint.
For full throttle class test see: `test_api_utils_ai_document_rate_throttles`
"""
client = APIClient()
document = factories.DocumentFactory(link_reach="public", link_role="editor")
answer = '{"answer": "Salut"}'
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=answer))]
)
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
for _ in range(3):
user = factories.UserFactory()
client.force_login(user)
response = client.post(url, {"text": "Hello", "action": "summarize"})
assert response.status_code == 200
assert response.json() == {"answer": "Salut"}
user = factories.UserFactory()
client.force_login(user)
response = client.post(url, {"text": "Hello", "action": "summarize"})
assert response.status_code == 429
assert response.json() == {
"detail": "Request was throttled. Expected available in 60 seconds."
}
@override_settings(AI_USER_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10})
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_transform_throttling_user(mock_create):
"""
Throttling per user should be triggered on the AI transform endpoint.
For full throttle class test see: `test_api_utils_ai_user_rate_throttles`
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
answer = '{"answer": "Salut"}'
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=answer))]
)
for _ in range(3):
document = factories.DocumentFactory(link_reach="public", link_role="editor")
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
response = client.post(url, {"text": "Hello", "action": "summarize"})
assert response.status_code == 200
assert response.json() == {"answer": "Salut"}
document = factories.DocumentFactory(link_reach="public", link_role="editor")
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
response = client.post(url, {"text": "Hello", "action": "summarize"})
assert response.status_code == 429
assert response.json() == {
"detail": "Request was throttled. Expected available in 60 seconds."
}
@@ -0,0 +1,370 @@
"""
Test AI translate API endpoint for users in impress's core app.
"""
from unittest.mock import MagicMock, patch
from django.core.cache import cache
from django.test import override_settings
import pytest
from rest_framework.test import APIClient
from core import factories
from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
@pytest.fixture(autouse=True)
def clear_cache():
"""Fixture to clear the cache before each test."""
cache.clear()
@pytest.fixture
def ai_settings():
"""Fixture to set AI settings."""
with override_settings(
AI_BASE_URL="http://example.com", AI_API_KEY="test-key", AI_MODEL="llama"
):
yield
def test_api_documents_ai_translate_viewset_options_metadata():
"""The documents endpoint should give us the list of available languages."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory(link_reach="public", link_role="editor")
response = APIClient().options("/api/v1.0/documents/")
assert response.status_code == 200
metadata = response.json()
assert metadata["name"] == "Document List"
assert metadata["actions"]["POST"]["language"]["choices"][0] == {
"value": "af",
"display_name": "Afrikaans",
}
@pytest.mark.parametrize(
"reach, role",
[
("restricted", "reader"),
("restricted", "editor"),
("authenticated", "reader"),
("authenticated", "editor"),
("public", "reader"),
],
)
def test_api_documents_ai_translate_anonymous_forbidden(reach, role):
"""
Anonymous users should not be able to request AI translate if the link reach
and role don't allow it.
"""
document = factories.DocumentFactory(link_reach=reach, link_role=role)
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
response = APIClient().post(url, {"text": "hello", "language": "es"})
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_translate_anonymous_success(mock_create):
"""
Anonymous users should be able to request AI translate to a document
if the link reach and role permit it.
"""
document = factories.DocumentFactory(link_reach="public", link_role="editor")
answer = '{"answer": "Salut"}'
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=answer))]
)
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
response = APIClient().post(url, {"text": "Hello", "language": "es"})
assert response.status_code == 200
assert response.json() == {"answer": "Salut"}
mock_create.assert_called_once_with(
model="llama",
response_format={"type": "json_object"},
messages=[
{
"role": "system",
"content": (
"Translate the markdown text to Spanish, preserving markdown formatting. "
'Return JSON: {"answer": "your translated markdown text in Spanish"}. '
"Do not provide any other information."
),
},
{"role": "user", "content": '{"markdown_input": "Hello"}'},
],
)
@pytest.mark.parametrize(
"reach, role",
[
("restricted", "reader"),
("restricted", "editor"),
("authenticated", "reader"),
("public", "reader"),
],
)
def test_api_documents_ai_translate_authenticated_forbidden(reach, role):
"""
Users who are not related to a document can't request AI translate if the
link reach and role don't allow it.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach, link_role=role)
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
response = client.post(url, {"text": "Hello", "language": "es"})
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
@pytest.mark.parametrize(
"reach, role",
[
("authenticated", "editor"),
("public", "editor"),
],
)
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_translate_authenticated_success(mock_create, reach, role):
"""
Autenticated who are not related to a document should be able to request AI translate
if the link reach and role permit it.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach, link_role=role)
answer = '{"answer": "Salut"}'
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=answer))]
)
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
response = client.post(url, {"text": "Hello", "language": "es-co"})
assert response.status_code == 200
assert response.json() == {"answer": "Salut"}
mock_create.assert_called_once_with(
model="llama",
response_format={"type": "json_object"},
messages=[
{
"role": "system",
"content": (
"Translate the markdown text to Colombian Spanish, "
"preserving markdown formatting. Return JSON: "
'{"answer": "your translated markdown text in Colombian Spanish"}. '
"Do not provide any other information."
),
},
{"role": "user", "content": '{"markdown_input": "Hello"}'},
],
)
@pytest.mark.parametrize("via", VIA)
def test_api_documents_ai_translate_reader(via, mock_user_teams):
"""
Users who are simple readers on a document should not be allowed to request AI translate.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_role="reader")
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role="reader")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="reader"
)
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
response = client.post(url, {"text": "Hello", "language": "es"})
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
@pytest.mark.parametrize("role", ["editor", "administrator", "owner"])
@pytest.mark.parametrize("via", VIA)
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_translate_success(mock_create, via, role, mock_user_teams):
"""
Editors, administrators and owners of a document should be able to request AI translate.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
)
answer = '{"answer": "Salut"}'
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=answer))]
)
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
response = client.post(url, {"text": "Hello", "language": "es-co"})
assert response.status_code == 200
assert response.json() == {"answer": "Salut"}
mock_create.assert_called_once_with(
model="llama",
response_format={"type": "json_object"},
messages=[
{
"role": "system",
"content": (
"Translate the markdown text to Colombian Spanish, "
"preserving markdown formatting. Return JSON: "
'{"answer": "your translated markdown text in Colombian Spanish"}. '
"Do not provide any other information."
),
},
{"role": "user", "content": '{"markdown_input": "Hello"}'},
],
)
def test_api_documents_ai_translate_empty_text():
"""The text should not be empty when requesting AI translate."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="public", link_role="editor")
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
response = client.post(url, {"text": " ", "language": "es"})
assert response.status_code == 400
assert response.json() == {"text": ["This field may not be blank."]}
def test_api_documents_ai_translate_invalid_action():
"""The action should valid when requesting AI translate."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="public", link_role="editor")
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
response = client.post(url, {"text": "Hello", "language": "invalid"})
assert response.status_code == 400
assert response.json() == {"language": ['"invalid" is not a valid choice.']}
@override_settings(AI_DOCUMENT_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10})
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_translate_throttling_document(mock_create):
"""
Throttling per document should be triggered on the AI translate endpoint.
For full throttle class test see: `test_api_utils_ai_document_rate_throttles`
"""
client = APIClient()
document = factories.DocumentFactory(link_reach="public", link_role="editor")
answer = '{"answer": "Salut"}'
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=answer))]
)
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
for _ in range(3):
user = factories.UserFactory()
client.force_login(user)
response = client.post(url, {"text": "Hello", "language": "es"})
assert response.status_code == 200
assert response.json() == {"answer": "Salut"}
user = factories.UserFactory()
client.force_login(user)
response = client.post(url, {"text": "Hello", "language": "es"})
assert response.status_code == 429
assert response.json() == {
"detail": "Request was throttled. Expected available in 60 seconds."
}
@override_settings(AI_USER_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10})
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_translate_throttling_user(mock_create):
"""
Throttling per user should be triggered on the AI translate endpoint.
For full throttle class test see: `test_api_utils_ai_user_rate_throttles`
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
answer = '{"answer": "Salut"}'
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=answer))]
)
for _ in range(3):
document = factories.DocumentFactory(link_reach="public", link_role="editor")
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
response = client.post(url, {"text": "Hello", "language": "es"})
assert response.status_code == 200
assert response.json() == {"answer": "Salut"}
document = factories.DocumentFactory(link_reach="public", link_role="editor")
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
response = client.post(url, {"text": "Hello", "language": "es"})
assert response.status_code == 429
assert response.json() == {
"detail": "Request was throttled. Expected available in 60 seconds."
}
@@ -5,7 +5,7 @@ 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.storage import default_storage
from django.core.files.uploadedfile import SimpleUploadedFile
import pytest
@@ -16,6 +16,12 @@ from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
PIXEL = (
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00"
b"\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\xf8\xff\xff?\x00\x05\xfe\x02\xfe"
b"\xa7V\xbd\xfa\x00\x00\x00\x00IEND\xaeB`\x82"
)
@pytest.mark.parametrize(
"reach, role",
@@ -33,7 +39,7 @@ def test_api_documents_attachment_upload_anonymous_forbidden(reach, role):
and role don't allow it.
"""
document = factories.DocumentFactory(link_reach=reach, link_role=role)
file = SimpleUploadedFile("test_file.jpg", b"Dummy content")
file = SimpleUploadedFile(name="test.png", content=PIXEL, content_type="image/png")
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
response = APIClient().post(url, {"file": file}, format="multipart")
@@ -50,14 +56,14 @@ def test_api_documents_attachment_upload_anonymous_success():
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")
file = SimpleUploadedFile(name="test.png", content=PIXEL, content_type="image/png")
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
response = APIClient().post(url, {"file": file}, format="multipart")
assert response.status_code == 201
pattern = re.compile(rf"^/media/{document.id!s}/attachments/(.*)\.jpg")
pattern = re.compile(rf"^/media/{document.id!s}/attachments/(.*)\.png")
match = pattern.search(response.json()["file"])
file_id = match.group(1)
@@ -79,13 +85,13 @@ 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()
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach, link_role=role)
file = SimpleUploadedFile("test_file.jpg", b"Dummy content")
file = SimpleUploadedFile(name="test.png", content=PIXEL, content_type="image/png")
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
response = client.post(url, {"file": file}, format="multipart")
@@ -114,14 +120,14 @@ def test_api_documents_attachment_upload_authenticated_success(reach, role):
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach, link_role=role)
file = SimpleUploadedFile("test_file.jpg", b"Dummy content")
file = SimpleUploadedFile(name="test.png", content=PIXEL, content_type="image/png")
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
response = client.post(url, {"file": file}, format="multipart")
assert response.status_code == 201
pattern = re.compile(rf"^/media/{document.id!s}/attachments/(.*)\.jpg")
pattern = re.compile(rf"^/media/{document.id!s}/attachments/(.*)\.png")
match = pattern.search(response.json()["file"])
file_id = match.group(1)
@@ -134,7 +140,7 @@ 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()
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
@@ -148,7 +154,7 @@ def test_api_documents_attachment_upload_reader(via, mock_user_teams):
document=document, team="lasuite", role="reader"
)
file = SimpleUploadedFile("test_file.jpg", b"Dummy content")
file = SimpleUploadedFile(name="test.png", content=PIXEL, content_type="image/png")
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
response = client.post(url, {"file": file}, format="multipart")
@@ -179,20 +185,28 @@ def test_api_documents_attachment_upload_success(via, role, mock_user_teams):
document=document, team="lasuite", role=role
)
file = SimpleUploadedFile("test_file.jpg", b"Dummy content")
file = SimpleUploadedFile(name="test.png", content=PIXEL, content_type="image/png")
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
response = client.post(url, {"file": file}, format="multipart")
assert response.status_code == 201
pattern = re.compile(rf"^/media/{document.id!s}/attachments/(.*)\.jpg")
match = pattern.search(response.json()["file"])
file_path = response.json()["file"]
pattern = re.compile(rf"^/media/{document.id!s}/attachments/(.*)\.png")
match = pattern.search(file_path)
file_id = match.group(1)
# Validate that file_id is a valid UUID
uuid.UUID(file_id)
# Now, check the metadata of the uploaded file
key = file_path.replace("/media", "")
file_head = default_storage.connection.meta.client.head_object(
Bucket=default_storage.bucket_name, Key=key
)
assert file_head["Metadata"] == {"owner": str(user.id)}
def test_api_documents_attachment_upload_invalid(client):
"""Attempt to upload without a file should return an explicit error."""
@@ -222,8 +236,9 @@ def test_api_documents_attachment_upload_size_limit_exceeded(settings):
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")
file = SimpleUploadedFile(
name="test.txt", content=b"a" * (1048576 + 1), content_type="text/plain"
)
response = client.post(url, {"file": file}, format="multipart")
@@ -231,10 +246,21 @@ def test_api_documents_attachment_upload_size_limit_exceeded(settings):
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"]
@pytest.mark.parametrize(
"name,content,extension",
[
("test.exe", b"text", "exe"),
("test", b"text", "txt"),
("test.aaaaaa", b"test", "txt"),
("test.txt", PIXEL, "txt"),
("test.py", b"#!/usr/bin/python", "py"),
],
)
def test_api_documents_attachment_upload_fix_extension(name, content, extension):
"""
A file with no extension or a wrong extension is accepted and the extension
is corrected in storage.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -242,14 +268,70 @@ def test_api_documents_attachment_upload_type_not_allowed(settings):
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")
file = SimpleUploadedFile(name=name, content=content)
response = client.post(url, {"file": file}, format="multipart")
assert response.status_code == 201
file_path = response.json()["file"]
pattern = re.compile(rf"^/media/{document.id!s}/attachments/(.*)\.{extension:s}")
match = pattern.search(file_path)
file_id = match.group(1)
# Validate that file_id is a valid UUID
uuid.UUID(file_id)
# Now, check the metadata of the uploaded file
key = file_path.replace("/media", "")
file_head = default_storage.connection.meta.client.head_object(
Bucket=default_storage.bucket_name, Key=key
)
assert file_head["Metadata"] == {"owner": str(user.id), "is_unsafe": "true"}
def test_api_documents_attachment_upload_empty_file():
"""An empty file should be rejected."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[(user, "owner")])
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
file = SimpleUploadedFile(name="test.png", content=b"")
response = client.post(url, {"file": file}, format="multipart")
assert response.status_code == 400
assert response.json() == {
"file": [
"File type 'text/plain' is not allowed. Allowed types are: image/jpeg, image/png"
]
}
assert response.json() == {"file": ["The submitted file is empty."]}
def test_api_documents_attachment_upload_unsafe():
"""A file with an unsafe mime type should be tagged as such."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[(user, "owner")])
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
file = SimpleUploadedFile(
name="script.exe", content=b"\x4d\x5a\x90\x00\x03\x00\x00\x00"
)
response = client.post(url, {"file": file}, format="multipart")
assert response.status_code == 201
file_path = response.json()["file"]
pattern = re.compile(rf"^/media/{document.id!s}/attachments/(.*)\.exe")
match = pattern.search(file_path)
file_id = match.group(1)
# Validate that file_id is a valid UUID
uuid.UUID(file_id)
# Now, check the metadata of the uploaded file
key = file_path.replace("/media", "")
file_head = default_storage.connection.meta.client.head_object(
Bucket=default_storage.bucket_name, Key=key
)
assert file_head["Metadata"] == {"owner": str(user.id), "is_unsafe": "true"}
@@ -47,6 +47,7 @@ def test_api_documents_create_authenticated_success():
assert response.status_code == 201
document = Document.objects.get()
assert document.title == "my document"
assert document.link_reach == "restricted"
assert document.accesses.filter(role="owner", user=user).exists()
@@ -30,7 +30,7 @@ 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()
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
@@ -42,7 +42,7 @@ def test_api_documents_delete_authenticated_unrelated(reach, role):
)
assert response.status_code == 403
assert models.Document.objects.count() == 1
assert models.Document.objects.count() == 2
@pytest.mark.parametrize("role", ["reader", "editor", "administrator"])
@@ -52,7 +52,7 @@ def test_api_documents_delete_authenticated_not_owner(via, role, mock_user_teams
Authenticated users should not be allowed to delete a document for which they are
only a reader, editor or administrator.
"""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
@@ -74,7 +74,7 @@ def test_api_documents_delete_authenticated_not_owner(via, role, mock_user_teams
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
assert models.Document.objects.count() == 1
assert models.Document.objects.count() == 2
@pytest.mark.parametrize("via", VIA)
@@ -0,0 +1,308 @@
"""Test favorite document API endpoint for users in impress's core app."""
import pytest
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
@pytest.mark.parametrize(
"reach",
[
"restricted",
"authenticated",
"public",
],
)
@pytest.mark.parametrize("method", ["post", "delete"])
def test_api_document_favorite_anonymous_user(method, reach):
"""Anonymous users should not be able to mark/unmark documents as favorites."""
document = factories.DocumentFactory(link_reach=reach)
response = getattr(APIClient(), method)(
f"/api/v1.0/documents/{document.id!s}/favorite/"
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
# Verify in database
assert models.DocumentFavorite.objects.exists() is False
@pytest.mark.parametrize(
"reach, has_role",
[
["restricted", True],
["authenticated", False],
["authenticated", True],
["public", False],
["public", True],
],
)
def test_api_document_favorite_authenticated_post_allowed(reach, has_role):
"""Authenticated users should be able to mark a document as favorite using POST."""
user = factories.UserFactory()
document = factories.DocumentFactory(link_reach=reach)
client = APIClient()
client.force_login(user)
if has_role:
models.DocumentAccess.objects.create(document=document, user=user)
# Mark as favorite
response = client.post(f"/api/v1.0/documents/{document.id!s}/favorite/")
assert response.status_code == 201
assert response.json() == {"detail": "Document marked as favorite"}
# Verify in database
assert models.DocumentFavorite.objects.filter(document=document, user=user).exists()
# Verify document format
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
assert response.json()["is_favorite"] is True
def test_api_document_favorite_authenticated_post_forbidden():
"""Authenticated users should be able to mark a document as favorite using POST."""
user = factories.UserFactory()
document = factories.DocumentFactory(link_reach="restricted")
client = APIClient()
client.force_login(user)
# Try marking as favorite
response = client.post(f"/api/v1.0/documents/{document.id!s}/favorite/")
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
# Verify in database
assert (
models.DocumentFavorite.objects.filter(document=document, user=user).exists()
is False
)
@pytest.mark.parametrize(
"reach, has_role",
[
["restricted", True],
["authenticated", False],
["authenticated", True],
["public", False],
["public", True],
],
)
def test_api_document_favorite_authenticated_post_already_favorited_allowed(
reach, has_role
):
"""POST should not create duplicate favorites if already marked."""
user = factories.UserFactory()
document = factories.DocumentFactory(link_reach=reach, favorited_by=[user])
client = APIClient()
client.force_login(user)
if has_role:
models.DocumentAccess.objects.create(document=document, user=user)
# Try to mark as favorite again
response = client.post(f"/api/v1.0/documents/{document.id!s}/favorite/")
assert response.status_code == 200
assert response.json() == {"detail": "Document already marked as favorite"}
# Verify in database
assert models.DocumentFavorite.objects.filter(document=document, user=user).exists()
# Verify document format
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
assert response.json()["is_favorite"] is True
def test_api_document_favorite_authenticated_post_already_favorited_forbidden():
"""POST should not create duplicate favorites if already marked."""
user = factories.UserFactory()
document = factories.DocumentFactory(link_reach="restricted", favorited_by=[user])
client = APIClient()
client.force_login(user)
# Try to mark as favorite again
response = client.post(f"/api/v1.0/documents/{document.id!s}/favorite/")
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
# Verify in database
assert models.DocumentFavorite.objects.filter(document=document, user=user).exists()
@pytest.mark.parametrize(
"reach, has_role",
[
["restricted", True],
["authenticated", False],
["authenticated", True],
["public", False],
["public", True],
],
)
def test_api_document_favorite_authenticated_delete_allowed(reach, has_role):
"""Authenticated users should be able to unmark a document as favorite using DELETE."""
user = factories.UserFactory()
document = factories.DocumentFactory(link_reach=reach, favorited_by=[user])
client = APIClient()
client.force_login(user)
if has_role:
models.DocumentAccess.objects.create(document=document, user=user)
# Unmark as favorite
response = client.delete(f"/api/v1.0/documents/{document.id!s}/favorite/")
assert response.status_code == 204
# Verify in database
assert (
models.DocumentFavorite.objects.filter(document=document, user=user).exists()
is False
)
# Verify document format
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
assert response.json()["is_favorite"] is False
def test_api_document_favorite_authenticated_delete_forbidden():
"""Authenticated users should be able to unmark a document as favorite using DELETE."""
user = factories.UserFactory()
document = factories.DocumentFactory(link_reach="restricted", favorited_by=[user])
client = APIClient()
client.force_login(user)
# Unmark as favorite
response = client.delete(f"/api/v1.0/documents/{document.id!s}/favorite/")
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
# Verify in database
assert (
models.DocumentFavorite.objects.filter(document=document, user=user).exists()
is True
)
@pytest.mark.parametrize(
"reach, has_role",
[
["restricted", True],
["authenticated", False],
["authenticated", True],
["public", False],
["public", True],
],
)
def test_api_document_favorite_authenticated_delete_not_favorited_allowed(
reach, has_role
):
"""DELETE should be idempotent if the document is not marked as favorite."""
user = factories.UserFactory()
document = factories.DocumentFactory(link_reach=reach)
client = APIClient()
client.force_login(user)
if has_role:
models.DocumentAccess.objects.create(document=document, user=user)
# Try to unmark as favorite when no favorite entry exists
response = client.delete(f"/api/v1.0/documents/{document.id!s}/favorite/")
assert response.status_code == 200
assert response.json() == {"detail": "Document was already not marked as favorite"}
# Verify in database
assert (
models.DocumentFavorite.objects.filter(document=document, user=user).exists()
is False
)
# Verify document format
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
assert response.json()["is_favorite"] is False
def test_api_document_favorite_authenticated_delete_not_favorited_forbidden():
"""DELETE should be idempotent if the document is not marked as favorite."""
user = factories.UserFactory()
document = factories.DocumentFactory(link_reach="restricted")
client = APIClient()
client.force_login(user)
# Try to unmark as favorite when no favorite entry exists
response = client.delete(f"/api/v1.0/documents/{document.id!s}/favorite/")
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
# Verify in database
assert (
models.DocumentFavorite.objects.filter(document=document, user=user).exists()
is False
)
@pytest.mark.parametrize(
"reach, has_role",
[
["restricted", True],
["authenticated", False],
["authenticated", True],
["public", False],
["public", True],
],
)
def test_api_document_favorite_authenticated_post_unmark_then_mark_again_allowed(
reach, has_role
):
"""A user should be able to mark, unmark, and mark a document again as favorite."""
user = factories.UserFactory()
document = factories.DocumentFactory(link_reach=reach)
client = APIClient()
client.force_login(user)
if has_role:
models.DocumentAccess.objects.create(document=document, user=user)
url = f"/api/v1.0/documents/{document.id!s}/favorite/"
# Mark as favorite
response = client.post(url)
assert response.status_code == 201
# Unmark as favorite
response = client.delete(url)
assert response.status_code == 204
# Mark as favorite again
response = client.post(url)
assert response.status_code == 201
assert response.json() == {"detail": "Document marked as favorite"}
# Verify in database
assert models.DocumentFavorite.objects.filter(document=document, user=user).exists()
# Verify document format
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
assert response.json()["is_favorite"] is True
@@ -42,7 +42,7 @@ def test_api_documents_link_configuration_update_authenticated_unrelated(reach,
Authenticated users should not be allowed to update the link configuration for
a document to which they are not related.
"""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
@@ -78,7 +78,7 @@ def test_api_documents_link_configuration_update_authenticated_related_forbidden
Users who are readers or editors of a document should not be allowed to update
the link configuration.
"""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
@@ -32,7 +32,44 @@ def test_api_documents_list_anonymous(reach, role):
assert len(results) == 0
def test_api_documents_list_authenticated_direct():
def test_api_documents_list_format():
"""Validate the format of documents as returned by the list view."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(
users=[user, *factories.UserFactory.create_batch(2)],
favorited_by=[user, *factories.UserFactory.create_batch(2)],
)
response = client.get("/api/v1.0/documents/")
assert response.status_code == 200
content = response.json()
results = content.pop("results")
assert content == {
"count": 1,
"next": None,
"previous": None,
}
assert len(results) == 1
assert results[0] == {
"id": str(document.id),
"abilities": document.get_abilities(user),
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"is_favorite": True,
"link_reach": document.link_reach,
"link_role": document.link_role,
"nb_accesses": 3,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}
def test_api_documents_list_authenticated_direct(django_assert_num_queries):
"""
Authenticated users should be able to list documents they are a direct
owner/administrator/member of or documents that have a link reach other
@@ -55,9 +92,8 @@ def test_api_documents_list_authenticated_direct():
expected_ids = {str(document.id) for document in documents}
response = client.get(
"/api/v1.0/documents/",
)
with django_assert_num_queries(3):
response = client.get("/api/v1.0/documents/")
assert response.status_code == 200
results = response.json()["results"]
@@ -66,7 +102,9 @@ def test_api_documents_list_authenticated_direct():
assert expected_ids == results_id
def test_api_documents_list_authenticated_via_team(mock_user_teams):
def test_api_documents_list_authenticated_via_team(
django_assert_num_queries, mock_user_teams
):
"""
Authenticated users should be able to list documents they are a
owner/administrator/member of via a team.
@@ -89,7 +127,8 @@ def test_api_documents_list_authenticated_via_team(mock_user_teams):
expected_ids = {str(document.id) for document in documents_team1 + documents_team2}
response = client.get("/api/v1.0/documents/")
with django_assert_num_queries(3):
response = client.get("/api/v1.0/documents/")
assert response.status_code == 200
results = response.json()["results"]
@@ -98,7 +137,9 @@ def test_api_documents_list_authenticated_via_team(mock_user_teams):
assert expected_ids == results_id
def test_api_documents_list_authenticated_link_reach_restricted():
def test_api_documents_list_authenticated_link_reach_restricted(
django_assert_num_queries,
):
"""
An authenticated user who has link traces to a document that is restricted should not
see it on the list view
@@ -115,9 +156,10 @@ def test_api_documents_list_authenticated_link_reach_restricted():
other_document = factories.DocumentFactory(link_reach="public")
models.LinkTrace.objects.create(document=other_document, user=user)
response = client.get(
"/api/v1.0/documents/",
)
with django_assert_num_queries(3):
response = client.get(
"/api/v1.0/documents/",
)
assert response.status_code == 200
results = response.json()["results"]
@@ -127,7 +169,9 @@ def test_api_documents_list_authenticated_link_reach_restricted():
assert results[0]["id"] == str(other_document.id)
def test_api_documents_list_authenticated_link_reach_public_or_authenticated():
def test_api_documents_list_authenticated_link_reach_public_or_authenticated(
django_assert_num_queries,
):
"""
An authenticated user who has link traces to a document with public or authenticated
link reach should see it on the list view.
@@ -144,9 +188,10 @@ def test_api_documents_list_authenticated_link_reach_public_or_authenticated():
]
expected_ids = {str(document.id) for document in documents}
response = client.get(
"/api/v1.0/documents/",
)
with django_assert_num_queries(3):
response = client.get(
"/api/v1.0/documents/",
)
assert response.status_code == 200
results = response.json()["results"]
@@ -254,6 +299,8 @@ def test_api_documents_list_ordering_by_fields():
for parameter in [
"created_at",
"-created_at",
"is_favorite",
"-is_favorite",
"updated_at",
"-updated_at",
"title",
@@ -272,3 +319,45 @@ def test_api_documents_list_ordering_by_fields():
compare = operator.ge if is_descending else operator.le
for i in range(4):
assert compare(results[i][field], results[i + 1][field])
def test_api_documents_list_favorites_no_extra_queries(django_assert_num_queries):
"""
Ensure that marking documents as favorite does not generate additional queries
when fetching the document list.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
special_documents = factories.DocumentFactory.create_batch(3, users=[user])
factories.DocumentFactory.create_batch(2, users=[user])
url = "/api/v1.0/documents/"
with django_assert_num_queries(3):
response = client.get(url)
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 5
assert all(result["is_favorite"] is False for result in results)
# Mark documents as favorite and check results again
for document in special_documents:
models.DocumentFavorite.objects.create(document=document, user=user)
with django_assert_num_queries(3):
response = client.get(url)
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 5
# Check if the "is_favorite" annotation is correctly set for the favorited documents
favorited_ids = {str(doc.id) for doc in special_documents}
for result in results:
if result["id"] in favorited_ids:
assert result["is_favorite"] is True
else:
assert result["is_favorite"] is False
@@ -21,10 +21,16 @@ def test_api_documents_retrieve_anonymous_public():
assert response.json() == {
"id": str(document.id),
"abilities": {
"accesses_manage": False,
"accesses_view": False,
"ai_transform": document.link_role == "editor",
"ai_translate": document.link_role == "editor",
"attachment_upload": document.link_role == "editor",
"destroy": False,
# Anonymous user can't favorite a document even with read access
"favorite": False,
"invite_owner": False,
"link_configuration": False,
"manage_accesses": False,
"partial_update": document.link_role == "editor",
"retrieve": True,
"update": document.link_role == "editor",
@@ -32,12 +38,13 @@ def test_api_documents_retrieve_anonymous_public():
"versions_list": False,
"versions_retrieve": False,
},
"accesses": [],
"nb_accesses": 0,
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"is_favorite": 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"),
}
@@ -75,10 +82,15 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
assert response.json() == {
"id": str(document.id),
"abilities": {
"accesses_manage": False,
"accesses_view": False,
"ai_transform": document.link_role == "editor",
"ai_translate": document.link_role == "editor",
"attachment_upload": document.link_role == "editor",
"link_configuration": False,
"destroy": False,
"manage_accesses": False,
"favorite": True,
"invite_owner": False,
"link_configuration": False,
"partial_update": document.link_role == "editor",
"retrieve": True,
"update": document.link_role == "editor",
@@ -86,12 +98,13 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
"versions_list": False,
"versions_retrieve": False,
},
"accesses": [],
"link_reach": reach,
"link_role": document.link_role,
"title": document.title,
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"is_favorite": False,
"link_reach": reach,
"link_role": document.link_role,
"nb_accesses": 0,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}
assert (
@@ -133,7 +146,7 @@ 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()
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
@@ -160,43 +173,25 @@ def test_api_documents_retrieve_authenticated_related_direct():
client.force_login(user)
document = factories.DocumentFactory()
access1 = factories.UserDocumentAccessFactory(document=document, user=user)
factories.UserDocumentAccessFactory(document=document, user=user)
access2 = factories.UserDocumentAccessFactory(document=document)
access1_user = serializers.UserSerializer(instance=user).data
access2_user = serializers.UserSerializer(instance=access2.user).data
serializers.UserSerializer(instance=user)
serializers.UserSerializer(instance=access2.user)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/",
)
assert response.status_code == 200
content = response.json()
assert sorted(content.pop("accesses"), key=lambda x: x["id"]) == sorted(
[
{
"id": str(access1.id),
"user": access1_user,
"team": "",
"role": access1.role,
"abilities": access1.get_abilities(user),
},
{
"id": str(access2.id),
"user": access2_user,
"team": "",
"role": access2.role,
"abilities": access2.get_abilities(user),
},
],
key=lambda x: x["id"],
)
assert response.json() == {
"id": str(document.id),
"title": document.title,
"content": document.content,
"abilities": document.get_abilities(user),
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"is_favorite": False,
"link_reach": document.link_reach,
"link_role": document.link_role,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"nb_accesses": 2,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}
@@ -208,7 +203,7 @@ def test_api_documents_retrieve_authenticated_related_team_none(mock_user_teams)
"""
mock_user_teams.return_value = []
user = factories.UserFactory()
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
@@ -249,7 +244,7 @@ def test_api_documents_retrieve_authenticated_related_team_members(
):
"""
Authenticated users should be allowed to retrieve a document to which they
are related via a team whatever the role and see all its accesses.
are related via a team whatever the role.
"""
mock_user_teams.return_value = teams
@@ -260,81 +255,33 @@ def test_api_documents_retrieve_authenticated_related_team_members(
document = factories.DocumentFactory(link_reach="restricted")
access_reader = factories.TeamDocumentAccessFactory(
factories.TeamDocumentAccessFactory(
document=document, team="readers", role="reader"
)
access_editor = factories.TeamDocumentAccessFactory(
factories.TeamDocumentAccessFactory(
document=document, team="editors", role="editor"
)
access_administrator = factories.TeamDocumentAccessFactory(
factories.TeamDocumentAccessFactory(
document=document, team="administrators", role="administrator"
)
access_owner = factories.TeamDocumentAccessFactory(
document=document, team="owners", role="owner"
)
other_access = factories.TeamDocumentAccessFactory(document=document)
factories.TeamDocumentAccessFactory(document=document, team="owners", role="owner")
factories.TeamDocumentAccessFactory(document=document)
factories.TeamDocumentAccessFactory()
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
# pylint: disable=R0801
assert response.status_code == 200
content = response.json()
expected_abilities = {
"destroy": False,
"retrieve": True,
"set_role_to": [],
"update": False,
"partial_update": False,
}
assert sorted(content.pop("accesses"), key=lambda x: x["id"]) == sorted(
[
{
"id": str(access_reader.id),
"user": None,
"team": "readers",
"role": access_reader.role,
"abilities": expected_abilities,
},
{
"id": str(access_editor.id),
"user": None,
"team": "editors",
"role": access_editor.role,
"abilities": expected_abilities,
},
{
"id": str(access_administrator.id),
"user": None,
"team": "administrators",
"role": access_administrator.role,
"abilities": expected_abilities,
},
{
"id": str(access_owner.id),
"user": None,
"team": "owners",
"role": access_owner.role,
"abilities": expected_abilities,
},
{
"id": str(other_access.id),
"user": None,
"team": other_access.team,
"role": other_access.role,
"abilities": expected_abilities,
},
],
key=lambda x: x["id"],
)
assert response.json() == {
"id": str(document.id),
"title": document.title,
"content": document.content,
"abilities": document.get_abilities(user),
"nb_accesses": 5,
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"is_favorite": False,
"link_reach": "restricted",
"link_role": document.link_role,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}
@@ -352,7 +299,7 @@ def test_api_documents_retrieve_authenticated_related_team_administrators(
):
"""
Authenticated users should be allowed to retrieve a document to which they
are related via a team whatever the role and see all its accesses.
are related via a team whatever the role.
"""
mock_user_teams.return_value = teams
@@ -363,98 +310,33 @@ def test_api_documents_retrieve_authenticated_related_team_administrators(
document = factories.DocumentFactory(link_reach="restricted")
access_reader = factories.TeamDocumentAccessFactory(
factories.TeamDocumentAccessFactory(
document=document, team="readers", role="reader"
)
access_editor = factories.TeamDocumentAccessFactory(
factories.TeamDocumentAccessFactory(
document=document, team="editors", role="editor"
)
access_administrator = factories.TeamDocumentAccessFactory(
factories.TeamDocumentAccessFactory(
document=document, team="administrators", role="administrator"
)
access_owner = factories.TeamDocumentAccessFactory(
document=document, team="owners", role="owner"
)
other_access = factories.TeamDocumentAccessFactory(document=document)
factories.TeamDocumentAccessFactory(document=document, team="owners", role="owner")
factories.TeamDocumentAccessFactory(document=document)
factories.TeamDocumentAccessFactory()
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
# pylint: disable=R0801
assert response.status_code == 200
content = response.json()
assert sorted(content.pop("accesses"), key=lambda x: x["id"]) == sorted(
[
{
"id": str(access_reader.id),
"user": None,
"team": "readers",
"role": "reader",
"abilities": {
"destroy": True,
"retrieve": True,
"set_role_to": ["administrator", "editor"],
"update": True,
"partial_update": True,
},
},
{
"id": str(access_editor.id),
"user": None,
"team": "editors",
"role": "editor",
"abilities": {
"destroy": True,
"retrieve": True,
"set_role_to": ["administrator", "reader"],
"update": True,
"partial_update": True,
},
},
{
"id": str(access_administrator.id),
"user": None,
"team": "administrators",
"role": "administrator",
"abilities": {
"destroy": True,
"retrieve": True,
"set_role_to": ["editor", "reader"],
"update": True,
"partial_update": True,
},
},
{
"id": str(access_owner.id),
"user": None,
"team": "owners",
"role": "owner",
"abilities": {
"destroy": False,
"retrieve": True,
"set_role_to": [],
"update": False,
"partial_update": False,
},
},
{
"id": str(other_access.id),
"user": None,
"team": other_access.team,
"role": other_access.role,
"abilities": other_access.get_abilities(user),
},
],
key=lambda x: x["id"],
)
assert response.json() == {
"id": str(document.id),
"title": document.title,
"content": document.content,
"abilities": document.get_abilities(user),
"nb_accesses": 5,
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"is_favorite": False,
"link_reach": "restricted",
"link_role": document.link_role,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}
@@ -473,7 +355,7 @@ def test_api_documents_retrieve_authenticated_related_team_owners(
):
"""
Authenticated users should be allowed to retrieve a restricted document to which
they are related via a team whatever the role and see all its accesses.
they are related via a team whatever the role.
"""
mock_user_teams.return_value = teams
@@ -484,100 +366,32 @@ def test_api_documents_retrieve_authenticated_related_team_owners(
document = factories.DocumentFactory(link_reach="restricted")
access_reader = factories.TeamDocumentAccessFactory(
factories.TeamDocumentAccessFactory(
document=document, team="readers", role="reader"
)
access_editor = factories.TeamDocumentAccessFactory(
factories.TeamDocumentAccessFactory(
document=document, team="editors", role="editor"
)
access_administrator = factories.TeamDocumentAccessFactory(
factories.TeamDocumentAccessFactory(
document=document, team="administrators", role="administrator"
)
access_owner = factories.TeamDocumentAccessFactory(
document=document, team="owners", role="owner"
)
other_access = factories.TeamDocumentAccessFactory(document=document)
factories.TeamDocumentAccessFactory(document=document, team="owners", role="owner")
factories.TeamDocumentAccessFactory(document=document)
factories.TeamDocumentAccessFactory()
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
# pylint: disable=R0801
assert response.status_code == 200
content = response.json()
assert sorted(content.pop("accesses"), key=lambda x: x["id"]) == sorted(
[
{
"id": str(access_reader.id),
"user": None,
"team": "readers",
"role": "reader",
"abilities": {
"destroy": True,
"retrieve": True,
"set_role_to": ["owner", "administrator", "editor"],
"update": True,
"partial_update": True,
},
},
{
"id": str(access_editor.id),
"user": None,
"team": "editors",
"role": "editor",
"abilities": {
"destroy": True,
"retrieve": True,
"set_role_to": ["owner", "administrator", "reader"],
"update": True,
"partial_update": True,
},
},
{
"id": str(access_administrator.id),
"user": None,
"team": "administrators",
"role": "administrator",
"abilities": {
"destroy": True,
"retrieve": True,
"set_role_to": ["owner", "editor", "reader"],
"update": True,
"partial_update": True,
},
},
{
"id": str(access_owner.id),
"user": None,
"team": "owners",
"role": "owner",
"abilities": {
# editable only if there is another owner role than the user's team...
"destroy": other_access.role == "owner",
"retrieve": True,
"set_role_to": ["administrator", "editor", "reader"]
if other_access.role == "owner"
else [],
"update": other_access.role == "owner",
"partial_update": other_access.role == "owner",
},
},
{
"id": str(other_access.id),
"user": None,
"team": other_access.team,
"role": other_access.role,
"abilities": other_access.get_abilities(user),
},
],
key=lambda x: x["id"],
)
assert response.json() == {
"id": str(document.id),
"title": document.title,
"content": document.content,
"abilities": document.get_abilities(user),
"nb_accesses": 5,
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"is_favorite": False,
"link_reach": "restricted",
"link_role": document.link_role,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}
@@ -142,7 +142,7 @@ def test_api_documents_retrieve_auth_authenticated_restricted():
"""
document = factories.DocumentFactory(link_reach="restricted")
user = factories.UserFactory()
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
@@ -66,14 +66,14 @@ 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()
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach, link_role=role)
old_document_values = serializers.DocumentSerializer(instance=document).data
old_document_values = serializers.DocumentSerializer(instance=document).data
new_document_values = serializers.DocumentSerializer(
instance=factories.DocumentFactory()
).data
@@ -111,14 +111,14 @@ def test_api_documents_update_anonymous_or_authenticated_unrelated(
client = APIClient()
if is_authenticated:
user = factories.UserFactory()
user = factories.UserFactory(with_owned_document=True)
client.force_login(user)
else:
user = AnonymousUser()
document = factories.DocumentFactory(link_reach=reach, link_role=role)
old_document_values = serializers.DocumentSerializer(instance=document).data
old_document_values = serializers.DocumentSerializer(instance=document).data
new_document_values = serializers.DocumentSerializer(
instance=factories.DocumentFactory()
).data
@@ -146,7 +146,7 @@ def test_api_documents_update_authenticated_reader(via, mock_user_teams):
Users who are reader of a document but not administrators should
not be allowed to update it.
"""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
@@ -187,7 +187,7 @@ 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()
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
@@ -216,7 +216,7 @@ def test_api_documents_update_authenticated_editor_administrator_or_owner(
document = models.Document.objects.get(pk=document.pk)
document_values = serializers.DocumentSerializer(instance=document).data
for key, value in document_values.items():
if key in ["id", "accesses", "created_at", "link_reach", "link_role"]:
if key in ["id", "created_at", "link_reach", "link_role", "nb_accesses"]:
assert value == old_document_values[key]
elif key == "updated_at":
assert value > old_document_values[key]
@@ -227,7 +227,7 @@ def test_api_documents_update_authenticated_editor_administrator_or_owner(
@pytest.mark.parametrize("via", VIA)
def test_api_documents_update_authenticated_owners(via, mock_user_teams):
"""Administrators of a document should be allowed to update it."""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
@@ -255,7 +255,7 @@ def test_api_documents_update_authenticated_owners(via, mock_user_teams):
document = models.Document.objects.get(pk=document.pk)
document_values = serializers.DocumentSerializer(instance=document).data
for key, value in document_values.items():
if key in ["id", "accesses", "created_at", "link_reach", "link_role"]:
if key in ["id", "created_at", "link_reach", "link_role", "nb_accesses"]:
assert value == old_document_values[key]
elif key == "updated_at":
assert value > old_document_values[key]
@@ -269,7 +269,7 @@ def test_api_documents_update_administrator_or_owner_of_another(via, mock_user_t
Being administrator or owner of a document should not grant authorization to update
another document.
"""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
@@ -32,7 +32,7 @@ def test_api_template_accesses_list_authenticated_unrelated():
Authenticated users should not be allowed to list template accesses for a template
to which they are not related.
"""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
@@ -62,7 +62,7 @@ def test_api_template_accesses_list_authenticated_related(via, mock_user_teams):
Authenticated users should be able to list template accesses for a template
to which they are directly related, whatever their role in the template.
"""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
@@ -146,7 +146,7 @@ def test_api_template_accesses_retrieve_authenticated_unrelated():
Authenticated users should not be allowed to retrieve a template access for
a template to which they are not related.
"""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
@@ -183,7 +183,7 @@ def test_api_template_accesses_retrieve_authenticated_related(via, mock_user_tea
A user who is related to a template should be allowed to retrieve the
associated template user accesses.
"""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
@@ -211,199 +211,6 @@ def test_api_template_accesses_retrieve_authenticated_related(via, mock_user_tea
}
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()
@@ -434,14 +241,14 @@ def test_api_template_accesses_update_authenticated_unrelated():
Authenticated users should not be allowed to update a template access for a template to which
they are not related.
"""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
access = factories.UserTemplateAccessFactory()
old_values = serializers.TemplateAccessSerializer(instance=access).data
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user": factories.UserFactory().id,
@@ -467,7 +274,7 @@ 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()
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
@@ -509,7 +316,7 @@ def test_api_template_accesses_update_administrator_except_owner(via, mock_user_
A user who is a direct administrator in a template should be allowed to update a user
access for this template, as long as they don't try to set the role to owner.
"""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
@@ -529,8 +336,8 @@ def test_api_template_accesses_update_administrator_except_owner(via, mock_user_
template=template,
role=random.choice(["administrator", "editor", "reader"]),
)
old_values = serializers.TemplateAccessSerializer(instance=access).data
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
@@ -566,7 +373,7 @@ def test_api_template_accesses_update_administrator_from_owner(via, mock_user_te
A user who is an administrator in a template, should not be allowed to update
the user access of an "owner" for this template.
"""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
@@ -586,8 +393,8 @@ def test_api_template_accesses_update_administrator_from_owner(via, mock_user_te
access = factories.UserTemplateAccessFactory(
template=template, user=other_user, role="owner"
)
old_values = serializers.TemplateAccessSerializer(instance=access).data
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
@@ -613,7 +420,7 @@ def test_api_template_accesses_update_administrator_to_owner(via, mock_user_team
A user who is an administrator in a template, should not be allowed to update
the user access of another user to grant template ownership.
"""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
@@ -635,8 +442,8 @@ def test_api_template_accesses_update_administrator_to_owner(via, mock_user_team
user=other_user,
role=random.choice(["administrator", "editor", "reader"]),
)
old_values = serializers.TemplateAccessSerializer(instance=access).data
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
@@ -667,7 +474,7 @@ def test_api_template_accesses_update_owner(via, mock_user_teams):
A user who is an owner in a template should be allowed to update
a user access for this template whatever the role.
"""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
@@ -685,8 +492,8 @@ def test_api_template_accesses_update_owner(via, mock_user_teams):
access = factories.UserTemplateAccessFactory(
template=template,
)
old_values = serializers.TemplateAccessSerializer(instance=access).data
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
@@ -723,22 +530,21 @@ def test_api_template_accesses_update_owner_self(via, mock_user_teams):
A user who is owner of a template should be allowed to update
their own user access provided there are other owners in the template.
"""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
access = None
if via == USER:
access = factories.UserTemplateAccessFactory(
template=template, user=user, role="owner"
)
elif via == TEAM:
if via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
access = factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="owner"
)
else:
access = factories.UserTemplateAccessFactory(
template=template, user=user, role="owner"
)
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_role = random.choice(["administrator", "editor", "reader"])
@@ -787,7 +593,7 @@ def test_api_template_accesses_delete_authenticated():
Authenticated users should not be allowed to delete a template access for a
template to which they are not related.
"""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
@@ -799,7 +605,7 @@ def test_api_template_accesses_delete_authenticated():
)
assert response.status_code == 403
assert models.TemplateAccess.objects.count() == 1
assert models.TemplateAccess.objects.count() == 2
@pytest.mark.parametrize("role", ["reader", "editor"])
@@ -809,7 +615,7 @@ def test_api_template_accesses_delete_editor_or_reader(via, role, mock_user_team
Authenticated users should not be allowed to delete a template access for a
template in which they are a simple editor or reader.
"""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
@@ -825,7 +631,7 @@ def test_api_template_accesses_delete_editor_or_reader(via, role, mock_user_team
access = factories.UserTemplateAccessFactory(template=template)
assert models.TemplateAccess.objects.count() == 2
assert models.TemplateAccess.objects.count() == 3
assert models.TemplateAccess.objects.filter(user=access.user).exists()
response = client.delete(
@@ -833,7 +639,7 @@ def test_api_template_accesses_delete_editor_or_reader(via, role, mock_user_team
)
assert response.status_code == 403
assert models.TemplateAccess.objects.count() == 2
assert models.TemplateAccess.objects.count() == 3
@pytest.mark.parametrize("via", VIA)
@@ -881,7 +687,7 @@ def test_api_template_accesses_delete_administrator_on_owners(via, mock_user_tea
Users who are administrators in a template should not be allowed to delete an ownership
access from the template.
"""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
@@ -899,7 +705,7 @@ def test_api_template_accesses_delete_administrator_on_owners(via, mock_user_tea
access = factories.UserTemplateAccessFactory(template=template, role="owner")
assert models.TemplateAccess.objects.count() == 2
assert models.TemplateAccess.objects.count() == 3
assert models.TemplateAccess.objects.filter(user=access.user).exists()
response = client.delete(
@@ -907,7 +713,7 @@ def test_api_template_accesses_delete_administrator_on_owners(via, mock_user_tea
)
assert response.status_code == 403
assert models.TemplateAccess.objects.count() == 2
assert models.TemplateAccess.objects.count() == 3
@pytest.mark.parametrize("via", VIA)
@@ -948,7 +754,7 @@ def test_api_template_accesses_delete_owners_last_owner(via, mock_user_teams):
"""
It should not be possible to delete the last owner access from a template
"""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
@@ -965,10 +771,10 @@ def test_api_template_accesses_delete_owners_last_owner(via, mock_user_teams):
template=template, team="lasuite", role="owner"
)
assert models.TemplateAccess.objects.count() == 1
assert models.TemplateAccess.objects.count() == 2
response = client.delete(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 403
assert models.TemplateAccess.objects.count() == 1
assert models.TemplateAccess.objects.count() == 2
@@ -0,0 +1,206 @@
"""
Test template accesses create API endpoint for users in impress's core app.
"""
import random
import pytest
from rest_framework.test import APIClient
from core import factories, models
from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
def test_api_template_accesses_create_anonymous():
"""Anonymous users should not be allowed to create template accesses."""
template = factories.TemplateFactory()
other_user = factories.UserFactory()
response = APIClient().post(
f"/api/v1.0/templates/{template.id!s}/accesses/",
{
"user": str(other_user.id),
"template": str(template.id),
"role": random.choice(models.RoleChoices.choices)[0],
},
format="json",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
assert models.TemplateAccess.objects.exists() is False
def test_api_template_accesses_create_authenticated_unrelated():
"""
Authenticated users should not be allowed to create template accesses for a template to
which they are not related.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
other_user = factories.UserFactory()
template = factories.TemplateFactory()
response = client.post(
f"/api/v1.0/templates/{template.id!s}/accesses/",
{
"user": str(other_user.id),
},
format="json",
)
assert response.status_code == 403
assert not models.TemplateAccess.objects.filter(user=other_user).exists()
@pytest.mark.parametrize("role", ["reader", "editor"])
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_create_authenticated_editor_or_reader(
via, role, mock_user_teams
):
"""Editors or readers of a template should not be allowed to create template accesses."""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role=role
)
other_user = factories.UserFactory()
for new_role in [role[0] for role in models.RoleChoices.choices]:
response = client.post(
f"/api/v1.0/templates/{template.id!s}/accesses/",
{
"user": str(other_user.id),
"role": new_role,
},
format="json",
)
assert response.status_code == 403
assert not models.TemplateAccess.objects.filter(user=other_user).exists()
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_create_authenticated_administrator(via, mock_user_teams):
"""
Administrators of a template should be able to create template accesses
except for the "owner" role.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(
template=template, user=user, role="administrator"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="administrator"
)
other_user = factories.UserFactory()
# It should not be allowed to create an owner access
response = client.post(
f"/api/v1.0/templates/{template.id!s}/accesses/",
{
"user": str(other_user.id),
"role": "owner",
},
format="json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "Only owners of a resource can assign other users as owners."
}
# It should be allowed to create a lower access
role = random.choice(
[role[0] for role in models.RoleChoices.choices if role[0] != "owner"]
)
response = client.post(
f"/api/v1.0/templates/{template.id!s}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == 201
assert models.TemplateAccess.objects.filter(user=other_user).count() == 1
new_template_access = models.TemplateAccess.objects.filter(user=other_user).get()
assert response.json() == {
"abilities": new_template_access.get_abilities(user),
"id": str(new_template_access.id),
"team": "",
"role": role,
"user": str(other_user.id),
}
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_create_authenticated_owner(via, mock_user_teams):
"""
Owners of a template should be able to create template accesses whatever the role.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role="owner")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="owner"
)
other_user = factories.UserFactory()
role = random.choice([role[0] for role in models.RoleChoices.choices])
response = client.post(
f"/api/v1.0/templates/{template.id!s}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == 201
assert models.TemplateAccess.objects.filter(user=other_user).count() == 1
new_template_access = models.TemplateAccess.objects.filter(user=other_user).get()
assert response.json() == {
"id": str(new_template_access.id),
"user": str(other_user.id),
"team": "",
"role": role,
"abilities": new_template_access.get_abilities(user),
}
@@ -22,7 +22,7 @@ def test_api_templates_retrieve_anonymous_public():
"abilities": {
"destroy": False,
"generate_document": True,
"manage_accesses": False,
"accesses_manage": False,
"partial_update": False,
"retrieve": True,
"update": False,
@@ -68,7 +68,7 @@ def test_api_templates_retrieve_authenticated_unrelated_public():
"abilities": {
"destroy": False,
"generate_document": True,
"manage_accesses": False,
"accesses_manage": False,
"partial_update": False,
"retrieve": True,
"update": False,
+42
View File
@@ -69,6 +69,48 @@ def test_api_users_list_query_email():
assert user_ids == [str(nicole.id), str(frank.id)]
def test_api_users_list_query_email_matching():
"""While filtering by email, results should be filtered and sorted by similarity"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
alice = factories.UserFactory(email="alice.johnson@example.gouv.fr")
factories.UserFactory(email="jane.smith@example.gouv.fr")
michael_wilson = factories.UserFactory(email="michael.wilson@example.gouv.fr")
factories.UserFactory(email="david.jones@example.gouv.fr")
michael_brown = factories.UserFactory(email="michael.brown@example.gouv.fr")
factories.UserFactory(email="sophia.taylor@example.gouv.fr")
response = client.get(
"/api/v1.0/users/?q=michael.johnson@example.gouv.f",
)
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(michael_wilson.id)]
response = client.get("/api/v1.0/users/?q=michael.johnson@example.gouv.fr")
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(michael_wilson.id), str(alice.id), str(michael_brown.id)]
response = client.get(
"/api/v1.0/users/?q=ajohnson@example.gouv.f",
)
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(alice.id)]
response = client.get(
"/api/v1.0/users/?q=michael.wilson@example.gouv.f",
)
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(michael_wilson.id)]
def test_api_users_list_query_email_exclude_doc_user():
"""
Authenticated users should be able to list users
@@ -0,0 +1,127 @@
"""
Test throttling on documents for the AI endpoint.
"""
from unittest.mock import patch
from django.core.cache import cache
from django.test import override_settings
import pytest
from rest_framework.response import Response
from rest_framework.test import APIRequestFactory
from rest_framework.views import APIView
from core.api.utils import AIDocumentRateThrottle
class DocumentAPIView(APIView):
"""A simple view to test the throttle"""
throttle_classes = [AIDocumentRateThrottle]
def get(self, request, *args, **kwargs):
"""Minimal get method for testing purposes."""
return Response({"message": "Success"})
@pytest.fixture(autouse=True)
def clear_cache():
"""Fixture to clear the cache before each test."""
cache.clear()
@override_settings(AI_DOCUMENT_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10})
@patch("time.time")
def test_api_utils_ai_document_rate_throttle_minute_limit(mock_time):
"""Test that minute limit is enforced."""
api_rf = APIRequestFactory()
mock_time.return_value = 1000000
# Simulate requests to the document API
for _i in range(3): # 3 first requests should be allowed
request = api_rf.get("/documents/1/")
response = DocumentAPIView.as_view()(request, pk=1)
assert response.status_code == 200
# Simulate passage of time
mock_time.return_value += 59
# 4th request should be throttled
request = api_rf.get("/documents/1/")
response = DocumentAPIView.as_view()(request, pk=1)
assert response.status_code == 429
# After the 60s backoff wait time has passed, we can make a request again
mock_time.return_value += 1
request = api_rf.get("/documents/1/")
response = DocumentAPIView.as_view()(request, pk=1)
assert response.status_code == 200
@override_settings(
AI_DOCUMENT_RATE_THROTTLE_RATES={"minute": 100000, "hour": 6, "day": 10}
)
@patch("time.time")
def test_ai_document_rate_throttle_hour_limit(mock_time):
"""Test that the hour limit is enforced without hitting the minute limit."""
api_rf = APIRequestFactory()
mock_time.return_value = 1000000
# Make requests to the document API, one per 21 seconds to avoid hitting the minute limit
for _i in range(6):
request = api_rf.get("/documents/1/")
response = DocumentAPIView.as_view()(request, pk=1)
assert response.status_code == 200
# Simulate passage of time
mock_time.return_value += 21
# Simulate passage of time
mock_time.return_value += 3600 - 6 * 21 - 1
# 7th request should be throttled
request = api_rf.get("/documents/1/")
response = DocumentAPIView.as_view()(request, pk=1)
assert response.status_code == 429
# After the 1h backoff wait time has passed, we can make a request again
mock_time.return_value += 1
request = api_rf.get("/documents/1/")
response = DocumentAPIView.as_view()(request, pk=1)
assert response.status_code == 200
@override_settings(AI_DOCUMENT_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10})
@patch("time.time")
def test_api_utils_ai_document_rate_throttle_day_limit(mock_time):
"""Test that day limit is enforced."""
api_rf = APIRequestFactory()
mock_time.return_value = 1000000
# Make requests to the document API, one per 10 minutes to avoid hitting
# the minute and hour limits
for _i in range(10): # 10 requests should be allowed
request = api_rf.get("/documents/1/")
response = DocumentAPIView.as_view()(request, pk=1)
assert response.status_code == 200
# Simulate passage of time
mock_time.return_value += 60 * 10
# Simulate passage of time
mock_time.return_value += 24 * 3600 - 10 * 60 * 10 - 1
# 11th request should be throttled
request = api_rf.get("/documents/1/")
response = DocumentAPIView.as_view()(request, pk=1)
assert response.status_code == 429
# After the 24h backoff wait time has passed we can make a request again
mock_time.return_value += 1
request = api_rf.get("/documents/1/")
response = DocumentAPIView.as_view()(request, pk=1)
assert response.status_code == 200
@@ -0,0 +1,146 @@
"""
Test throttling on users for the AI endpoint.
"""
from unittest.mock import patch
from uuid import uuid4
from django.core.cache import cache
from django.test import override_settings
import pytest
from rest_framework.response import Response
from rest_framework.test import APIRequestFactory
from rest_framework.views import APIView
from core.api.utils import AIUserRateThrottle
from core.factories import UserFactory
pytestmark = pytest.mark.django_db
class DocumentAPIView(APIView):
"""A simple view to test the throttle"""
throttle_classes = [AIUserRateThrottle]
def get(self, request, *args, **kwargs):
"""Minimal get method for testing purposes."""
return Response({"message": "Success"})
@pytest.fixture(autouse=True)
def clear_cache():
"""Fixture to clear the cache before each test."""
cache.clear()
@override_settings(AI_USER_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10})
@patch("time.time")
def test_api_utils_ai_user_rate_throttle_minute_limit(mock_time):
"""Test that minute limit is enforced."""
user = UserFactory()
api_rf = APIRequestFactory()
mock_time.return_value = 1000000
# Simulate requests to the document API
for _i in range(3): # 3 first requests should be allowed
document_id = str(uuid4())
request = api_rf.get(f"/documents/{document_id:s}/")
request.user = user
response = DocumentAPIView.as_view()(request, pk=document_id)
assert response.status_code == 200
# Simulate passage of time
mock_time.return_value += 59
# 4th request should be throttled
document_id = str(uuid4())
request = api_rf.get(f"/documents/{document_id:s}/")
request.user = user
response = DocumentAPIView.as_view()(request, pk=document_id)
assert response.status_code == 429
# After the 60s backoff wait time has passed, we can make a request again
mock_time.return_value += 1
document_id = str(uuid4())
request = api_rf.get(f"/documents/{document_id:s}/")
request.user = user
response = DocumentAPIView.as_view()(request, pk=document_id)
assert response.status_code == 200
@override_settings(AI_USER_RATE_THROTTLE_RATES={"minute": 100000, "hour": 6, "day": 10})
@patch("time.time")
def test_ai_user_rate_throttle_hour_limit(mock_time):
"""Test that the hour limit is enforced without hitting the minute limit."""
user = UserFactory()
api_rf = APIRequestFactory()
mock_time.return_value = 1000000
# Make requests to the document API, one per 21 seconds to avoid hitting the minute limit
for _i in range(6):
document_id = str(uuid4())
request = api_rf.get(f"/documents/{document_id:s}/")
request.user = user
response = DocumentAPIView.as_view()(request, pk=document_id)
assert response.status_code == 200
# Simulate passage of time
mock_time.return_value += 21
# Simulate passage of time
mock_time.return_value += 3600 - 6 * 21 - 1
# 7th request should be throttled
request = api_rf.get(f"/documents/{document_id:s}/")
request.user = user
response = DocumentAPIView.as_view()(request, pk=document_id)
assert response.status_code == 429
# After the 1h backoff wait time has passed, we can make a request again
mock_time.return_value += 1
request = api_rf.get(f"/documents/{document_id:s}/")
request.user = user
response = DocumentAPIView.as_view()(request, pk=document_id)
assert response.status_code == 200
@override_settings(AI_USER_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10})
@patch("time.time")
def test_api_utils_ai_user_rate_throttle_day_limit(mock_time):
"""Test that day limit is enforced."""
user = UserFactory()
api_rf = APIRequestFactory()
mock_time.return_value = 1000000
# Make requests to the document API, one per 10 minutes to avoid hitting
# the minute and hour limits
for _i in range(10): # 10 requests should be allowed
document_id = str(uuid4())
request = api_rf.get(f"/documents/{document_id:s}/")
request.user = user
response = DocumentAPIView.as_view()(request, pk=document_id)
assert response.status_code == 200
# Simulate passage of time
mock_time.return_value += 60 * 10
# Simulate passage of time
mock_time.return_value += 24 * 3600 - 10 * 60 * 10 - 1
# 11th request should be throttled
request = api_rf.get(f"/documents/{document_id:s}/")
request.user = user
response = DocumentAPIView.as_view()(request, pk=document_id)
assert response.status_code == 429
# After the 24h backoff wait time has passed we can make a request again
mock_time.return_value += 1
request = api_rf.get(f"/documents/{document_id:s}/")
request.user = user
response = DocumentAPIView.as_view()(request, pk=document_id)
assert response.status_code == 200
+66 -15
View File
@@ -83,10 +83,15 @@ def test_models_documents_get_abilities_forbidden(is_authenticated, reach, role)
user = factories.UserFactory() if is_authenticated else AnonymousUser()
abilities = document.get_abilities(user)
assert abilities == {
"accesses_manage": False,
"accesses_view": False,
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
"link_configuration": False,
"destroy": False,
"manage_accesses": False,
"favorite": False,
"invite_owner": False,
"link_configuration": False,
"partial_update": False,
"retrieve": False,
"update": False,
@@ -113,10 +118,15 @@ def test_models_documents_get_abilities_reader(is_authenticated, reach):
user = factories.UserFactory() if is_authenticated else AnonymousUser()
abilities = document.get_abilities(user)
assert abilities == {
"accesses_manage": False,
"accesses_view": False,
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
"destroy": False,
"favorite": is_authenticated,
"invite_owner": False,
"link_configuration": False,
"manage_accesses": False,
"partial_update": False,
"retrieve": True,
"update": False,
@@ -143,10 +153,15 @@ def test_models_documents_get_abilities_editor(is_authenticated, reach):
user = factories.UserFactory() if is_authenticated else AnonymousUser()
abilities = document.get_abilities(user)
assert abilities == {
"accesses_manage": False,
"accesses_view": False,
"ai_transform": True,
"ai_translate": True,
"attachment_upload": True,
"destroy": False,
"favorite": is_authenticated,
"invite_owner": False,
"link_configuration": False,
"manage_accesses": False,
"partial_update": True,
"retrieve": True,
"update": True,
@@ -162,10 +177,15 @@ def test_models_documents_get_abilities_owner():
access = factories.UserDocumentAccessFactory(role="owner", user=user)
abilities = access.document.get_abilities(access.user)
assert abilities == {
"accesses_manage": True,
"accesses_view": True,
"ai_transform": True,
"ai_translate": True,
"attachment_upload": True,
"destroy": True,
"favorite": True,
"invite_owner": True,
"link_configuration": True,
"manage_accesses": True,
"partial_update": True,
"retrieve": True,
"update": True,
@@ -180,10 +200,15 @@ def test_models_documents_get_abilities_administrator():
access = factories.UserDocumentAccessFactory(role="administrator")
abilities = access.document.get_abilities(access.user)
assert abilities == {
"accesses_manage": True,
"accesses_view": True,
"ai_transform": True,
"ai_translate": True,
"attachment_upload": True,
"destroy": False,
"favorite": True,
"invite_owner": False,
"link_configuration": True,
"manage_accesses": True,
"partial_update": True,
"retrieve": True,
"update": True,
@@ -201,10 +226,15 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
abilities = access.document.get_abilities(access.user)
assert abilities == {
"accesses_manage": False,
"accesses_view": True,
"ai_transform": True,
"ai_translate": True,
"attachment_upload": True,
"destroy": False,
"favorite": True,
"invite_owner": False,
"link_configuration": False,
"manage_accesses": False,
"partial_update": True,
"retrieve": True,
"update": True,
@@ -224,10 +254,15 @@ def test_models_documents_get_abilities_reader_user(django_assert_num_queries):
abilities = access.document.get_abilities(access.user)
assert abilities == {
"accesses_manage": False,
"accesses_view": True,
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
"destroy": False,
"favorite": True,
"invite_owner": False,
"link_configuration": False,
"manage_accesses": False,
"partial_update": False,
"retrieve": True,
"update": False,
@@ -248,10 +283,15 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
abilities = access.document.get_abilities(access.user)
assert abilities == {
"accesses_manage": False,
"accesses_view": True,
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
"destroy": False,
"favorite": True,
"invite_owner": False,
"link_configuration": False,
"manage_accesses": False,
"partial_update": False,
"retrieve": True,
"update": False,
@@ -363,8 +403,9 @@ def test_models_documents__email_invitation__success():
# pylint: disable-next=no-member
assert len(mail.outbox) == 0
sender = factories.UserFactory(full_name="Test Sender", email="sender@example.com")
document.email_invitation(
"en", "guest@example.com", models.RoleChoices.EDITOR, "sender@example.com"
"en", "guest@example.com", models.RoleChoices.EDITOR, sender
)
# pylint: disable-next=no-member
@@ -377,8 +418,8 @@ def test_models_documents__email_invitation__success():
email_content = " ".join(email.body.split())
assert (
f"sender@example.com invited you as an editor on the following document : {document.title}"
in email_content
f'Test Sender (sender@example.com) invited you with the role "editor" '
f"on the following document : {document.title}" in email_content
)
assert f"docs/{document.id}/" in email_content
@@ -392,8 +433,14 @@ def test_models_documents__email_invitation__success_fr():
# pylint: disable-next=no-member
assert len(mail.outbox) == 0
sender = factories.UserFactory(
full_name="Test Sender2", email="sender2@example.com"
)
document.email_invitation(
"fr-fr", "guest2@example.com", models.RoleChoices.OWNER, "sender2@example.com"
"fr-fr",
"guest2@example.com",
models.RoleChoices.OWNER,
sender,
)
# pylint: disable-next=no-member
@@ -406,7 +453,7 @@ def test_models_documents__email_invitation__success_fr():
email_content = " ".join(email.body.split())
assert (
f"sender2@example.com vous a invité en tant que propriétaire "
f'Test Sender2 (sender2@example.com) vous a invité avec le rôle "propriétaire" '
f"sur le document suivant : {document.title}" in email_content
)
assert f"docs/{document.id}/" in email_content
@@ -424,8 +471,12 @@ def test_models_documents__email_invitation__failed(mock_logger, _mock_send_mail
# pylint: disable-next=no-member
assert len(mail.outbox) == 0
sender = factories.UserFactory()
document.email_invitation(
"en", "guest3@example.com", models.RoleChoices.ADMIN, "sender3@example.com"
"en",
"guest3@example.com",
models.RoleChoices.ADMIN,
sender,
)
# No email has been sent
@@ -2,10 +2,12 @@
Unit tests for the Invitation model
"""
import time
from datetime import timedelta
from unittest import mock
from django.contrib.auth.models import AnonymousUser
from django.core import exceptions
from django.utils import timezone
import pytest
from faker import Faker
@@ -60,7 +62,7 @@ def test_models_invitations_role_among_choices():
factories.InvitationFactory(role="boss")
def test_models_invitations__is_expired(settings):
def test_models_invitations_is_expired():
"""
The 'is_expired' property should return False until validity duration
is exceeded and True afterwards.
@@ -68,13 +70,16 @@ def test_models_invitations__is_expired(settings):
expired_invitation = factories.InvitationFactory()
assert expired_invitation.is_expired is False
settings.INVITATION_VALIDITY_DURATION = 1
time.sleep(1)
not_late = timezone.now() + timedelta(seconds=604799)
with mock.patch("django.utils.timezone.now", return_value=not_late):
assert expired_invitation.is_expired is False
assert expired_invitation.is_expired is True
too_late = timezone.now() + timedelta(seconds=604800) # 7 days
with mock.patch("django.utils.timezone.now", return_value=too_late):
assert expired_invitation.is_expired is True
def test_models_invitation__new_user__convert_invitations_to_accesses():
def test_models_invitationd_new_userd_convert_invitations_to_accesses():
"""
Upon creating a new user, invitations linked to the email
should be converted to accesses and then deleted.
@@ -109,7 +114,7 @@ def test_models_invitation__new_user__convert_invitations_to_accesses():
).exists() # the other invitation remains
def test_models_invitation__new_user__filter_expired_invitations():
def test_models_invitationd_new_user_filter_expired_invitations():
"""
Upon creating a new identity, valid invitations should be converted into accesses
and expired invitations should remain unchanged.
@@ -140,7 +145,7 @@ def test_models_invitation__new_user__filter_expired_invitations():
@pytest.mark.parametrize("num_invitations, num_queries", [(0, 3), (1, 6), (20, 6)])
def test_models_invitation__new_user__user_creation_constant_num_queries(
def test_models_invitationd_new_userd_user_creation_constant_num_queries(
django_assert_num_queries, num_invitations, num_queries
):
"""
@@ -235,7 +240,7 @@ def test_models_document_invitations_get_abilities_reader(via, mock_user_teams):
assert abilities == {
"destroy": False,
"retrieve": True,
"retrieve": False,
"partial_update": False,
"update": False,
}
@@ -260,7 +265,7 @@ def test_models_document_invitations_get_abilities_editor(via, mock_user_teams):
assert abilities == {
"destroy": False,
"retrieve": True,
"retrieve": False,
"partial_update": False,
"update": False,
}
@@ -62,7 +62,7 @@ def test_models_templates_get_abilities_anonymous_public():
"destroy": False,
"retrieve": True,
"update": False,
"manage_accesses": False,
"accesses_manage": False,
"partial_update": False,
"generate_document": True,
}
@@ -76,7 +76,7 @@ def test_models_templates_get_abilities_anonymous_not_public():
"destroy": False,
"retrieve": False,
"update": False,
"manage_accesses": False,
"accesses_manage": False,
"partial_update": False,
"generate_document": False,
}
@@ -90,7 +90,7 @@ def test_models_templates_get_abilities_authenticated_public():
"destroy": False,
"retrieve": True,
"update": False,
"manage_accesses": False,
"accesses_manage": False,
"partial_update": False,
"generate_document": True,
}
@@ -104,7 +104,7 @@ def test_models_templates_get_abilities_authenticated_not_public():
"destroy": False,
"retrieve": False,
"update": False,
"manage_accesses": False,
"accesses_manage": False,
"partial_update": False,
"generate_document": False,
}
@@ -119,7 +119,7 @@ def test_models_templates_get_abilities_owner():
"destroy": True,
"retrieve": True,
"update": True,
"manage_accesses": True,
"accesses_manage": True,
"partial_update": True,
"generate_document": True,
}
@@ -133,7 +133,7 @@ def test_models_templates_get_abilities_administrator():
"destroy": False,
"retrieve": True,
"update": True,
"manage_accesses": True,
"accesses_manage": True,
"partial_update": True,
"generate_document": True,
}
@@ -150,7 +150,7 @@ def test_models_templates_get_abilities_editor_user(django_assert_num_queries):
"destroy": False,
"retrieve": True,
"update": True,
"manage_accesses": False,
"accesses_manage": False,
"partial_update": True,
"generate_document": True,
}
@@ -167,7 +167,7 @@ def test_models_templates_get_abilities_reader_user(django_assert_num_queries):
"destroy": False,
"retrieve": True,
"update": False,
"manage_accesses": False,
"accesses_manage": False,
"partial_update": False,
"generate_document": True,
}
@@ -185,7 +185,7 @@ def test_models_templates_get_abilities_preset_role(django_assert_num_queries):
"destroy": False,
"retrieve": True,
"update": False,
"manage_accesses": False,
"accesses_manage": False,
"partial_update": False,
"generate_document": True,
}
@@ -0,0 +1,104 @@
"""
Test ai API endpoints in the impress core app.
"""
import json
from unittest.mock import MagicMock, patch
from django.core.exceptions import ImproperlyConfigured
from django.test.utils import override_settings
import pytest
from openai import OpenAIError
from core.services.ai_services import AIService
pytestmark = pytest.mark.django_db
@pytest.mark.parametrize(
"setting_name, setting_value",
[
("AI_BASE_URL", None),
("AI_API_KEY", None),
("AI_MODEL", None),
],
)
def test_api_ai_setting_missing(setting_name, setting_value):
"""Setting should be set"""
with override_settings(**{setting_name: setting_value}):
with pytest.raises(
ImproperlyConfigured,
match="AI configuration not set",
):
AIService()
@override_settings(
AI_BASE_URL="http://example.com", AI_API_KEY="test-key", AI_MODEL="test-model"
)
@patch("openai.resources.chat.completions.Completions.create")
def test_api_ai__client_error(mock_create):
"""Fail when the client raises an error"""
mock_create.side_effect = OpenAIError("Mocked client error")
with pytest.raises(
OpenAIError,
match="Mocked client error",
):
AIService().transform("hello", "prompt")
@override_settings(
AI_BASE_URL="http://example.com", AI_API_KEY="test-key", AI_MODEL="test-model"
)
@patch("openai.resources.chat.completions.Completions.create")
def test_api_ai__client_invalid_response(mock_create):
"""Fail when the client response is invalid"""
answer = {"no_answer": "This is an invalid response"}
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=json.dumps(answer)))]
)
with pytest.raises(
RuntimeError,
match="AI response does not contain an answer",
):
AIService().transform("hello", "prompt")
@override_settings(
AI_BASE_URL="http://example.com", AI_API_KEY="test-key", AI_MODEL="test-model"
)
@patch("openai.resources.chat.completions.Completions.create")
def test_api_ai__success(mock_create):
"""The AI request should work as expect when called with valid arguments."""
answer = '{"answer": "Salut"}'
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=answer))]
)
response = AIService().transform("hello", "prompt")
assert response == {"answer": "Salut"}
@override_settings(
AI_BASE_URL="http://example.com", AI_API_KEY="test-key", AI_MODEL="test-model"
)
@patch("openai.resources.chat.completions.Completions.create")
def test_api_ai__success_sanitize(mock_create):
"""The AI response should be sanitized"""
answer = '{"answer": "Salut\\n \tle \nmonde"}'
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=answer))]
)
response = AIService().transform("hello", "prompt")
assert response == {"answer": "Salut\n \tle \nmonde"}
+84 -7
View File
@@ -144,14 +144,75 @@ class Base(Configuration):
environ_name="DOCUMENT_IMAGE_MAX_SIZE",
environ_prefix=None,
)
DOCUMENT_IMAGE_ALLOWED_MIME_TYPES = [
"image/bmp",
"image/gif",
"image/jpeg",
"image/png",
DOCUMENT_UNSAFE_MIME_TYPES = [
# Executable Files
"application/x-msdownload",
"application/x-bat",
"application/x-dosexec",
"application/x-sh",
"application/x-ms-dos-executable",
"application/x-msi",
"application/java-archive",
"application/octet-stream",
# Dynamic Web Pages
"application/x-httpd-php",
"application/x-asp",
"application/x-aspx",
"application/jsp",
"application/xhtml+xml",
"application/x-python-code",
"application/x-perl",
"text/html",
"text/javascript",
"text/x-php",
# System Files
"application/x-msdownload",
"application/x-sys",
"application/x-drv",
"application/cpl",
"application/x-apple-diskimage",
# Script Files
"application/javascript",
"application/x-vbscript",
"application/x-powershell",
"application/x-shellscript",
# Compressed/Archive Files
"application/zip",
"application/x-tar",
"application/gzip",
"application/x-bzip2",
"application/x-7z-compressed",
"application/x-rar",
"application/x-rar-compressed",
"application/x-compress",
"application/x-lzma",
# Macros in Documents
"application/vnd.ms-word",
"application/vnd.ms-excel",
"application/vnd.ms-powerpoint",
"application/vnd.ms-word.document.macroenabled.12",
"application/vnd.ms-excel.sheet.macroenabled.12",
"application/vnd.ms-powerpoint.presentation.macroenabled.12",
# Disk Images & Virtual Disk Files
"application/x-iso9660-image",
"application/x-vmdk",
"application/x-apple-diskimage",
"application/x-dmg",
# Other Dangerous MIME Types
"application/x-ms-application",
"application/x-msdownload",
"application/x-shockwave-flash",
"application/x-silverlight-app",
"application/x-java-vm",
"application/x-bittorrent",
"application/hta",
"application/x-csh",
"application/x-ksh",
"application/x-ms-regedit",
"application/x-msdownload",
"application/xml",
"image/svg+xml",
"image/tiff",
"image/webp",
]
# Document versions
@@ -162,6 +223,7 @@ class Base(Configuration):
# Languages
LANGUAGE_CODE = values.Value("en-us")
LANGUAGE_COOKIE_NAME = "docs_language" # cookie & language is set from frontend
DRF_NESTED_MULTIPART_PARSER = {
# output of parser is converted to querydict
@@ -175,6 +237,7 @@ class Base(Configuration):
(
("en-us", _("English")),
("fr-fr", _("French")),
("de-de", _("German")),
)
)
@@ -393,6 +456,20 @@ class Base(Configuration):
ALLOW_LOGOUT_GET_METHOD = values.BooleanValue(
default=True, environ_name="ALLOW_LOGOUT_GET_METHOD", environ_prefix=None
)
AI_API_KEY = values.Value(None, environ_name="AI_API_KEY", environ_prefix=None)
AI_BASE_URL = values.Value(None, environ_name="AI_BASE_URL", environ_prefix=None)
AI_MODEL = values.Value(None, environ_name="AI_MODEL", environ_prefix=None)
AI_DOCUMENT_RATE_THROTTLE_RATES = {
"minute": 5,
"hour": 100,
"day": 500,
}
AI_USER_RATE_THROTTLE_RATES = {
"minute": 3,
"hour": 50,
"day": 200,
}
USER_OIDC_FIELDS_TO_FULLNAME = values.ListValue(
default=["first_name", "last_name"],
Binary file not shown.
@@ -0,0 +1,349 @@
msgid ""
msgstr ""
"Project-Id-Version: lasuite-people\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-09-25 10:15+0000\n"
"PO-Revision-Date: 2024-09-25 10:21\n"
"Last-Translator: \n"
"Language-Team: German\n"
"Language: de_DE\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
"X-Crowdin-Project: lasuite-people\n"
"X-Crowdin-Project-ID: 637934\n"
"X-Crowdin-Language: de\n"
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 8\n"
#: core/admin.py:32
msgid "Personal info"
msgstr "Persönliche Angaben"
#: core/admin.py:34
msgid "Permissions"
msgstr "Berechtigungen"
#: core/admin.py:46
msgid "Important dates"
msgstr "Wichtige Termine"
#: core/api/serializers.py:253
msgid "Body"
msgstr ""
#: core/api/serializers.py:256
msgid "Body type"
msgstr ""
#: core/api/serializers.py:262
msgid "Format"
msgstr ""
#: core/authentication/backends.py:56
msgid "Invalid response format or token verification failed"
msgstr ""
#: core/authentication/backends.py:81
msgid "User info contained no recognizable user identification"
msgstr ""
#: core/authentication/backends.py:101
msgid "Claims contained no recognizable user identification"
msgstr ""
#: core/models.py:62 core/models.py:69
msgid "Reader"
msgstr "Leser"
#: core/models.py:63 core/models.py:70
msgid "Editor"
msgstr "Bearbeiter"
#: core/models.py:71
msgid "Administrator"
msgstr "Administrator"
#: core/models.py:72
msgid "Owner"
msgstr "Eigentümer"
#: core/models.py:80
msgid "Restricted"
msgstr "Eingeschränkt"
#: core/models.py:84
msgid "Authenticated"
msgstr "Authentifiziert"
#: core/models.py:86
msgid "Public"
msgstr "Öffentlich"
#: core/models.py:98
msgid "id"
msgstr ""
#: core/models.py:99
msgid "primary key for the record as UUID"
msgstr ""
#: core/models.py:105
msgid "created on"
msgstr ""
#: core/models.py:106
msgid "date and time at which a record was created"
msgstr ""
#: core/models.py:111
msgid "updated on"
msgstr ""
#: core/models.py:112
msgid "date and time at which a record was last updated"
msgstr ""
#: core/models.py:132
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters."
msgstr ""
#: core/models.py:138
msgid "sub"
msgstr ""
#: core/models.py:140
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
msgstr ""
#: core/models.py:148
msgid "identity email address"
msgstr ""
#: core/models.py:153
msgid "admin email address"
msgstr ""
#: core/models.py:160
msgid "language"
msgstr ""
#: core/models.py:161
msgid "The language in which the user wants to see the interface."
msgstr ""
#: core/models.py:167
msgid "The timezone in which the user wants to see times."
msgstr ""
#: core/models.py:170
msgid "device"
msgstr ""
#: core/models.py:172
msgid "Whether the user is a device or a real user."
msgstr ""
#: core/models.py:175
msgid "staff status"
msgstr ""
#: core/models.py:177
msgid "Whether the user can log into this admin site."
msgstr ""
#: core/models.py:180
msgid "active"
msgstr ""
#: core/models.py:183
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: core/models.py:195
msgid "user"
msgstr ""
#: core/models.py:196
msgid "users"
msgstr ""
#: core/models.py:328 core/models.py:644
msgid "title"
msgstr ""
#: core/models.py:343
msgid "Document"
msgstr ""
#: core/models.py:344
msgid "Documents"
msgstr ""
#: core/models.py:347
msgid "Untitled Document"
msgstr ""
#: core/models.py:537
#, python-format
msgid "%(username)s shared a document with you: %(document)s"
msgstr "%(username)s hat ein Dokument mit Ihnen geteilt: %(document)s"
#: core/models.py:580
msgid "Document/user link trace"
msgstr ""
#: core/models.py:581
msgid "Document/user link traces"
msgstr ""
#: core/models.py:587
msgid "A link trace already exists for this document/user."
msgstr ""
#: core/models.py:608
msgid "Document/user relation"
msgstr ""
#: core/models.py:609
msgid "Document/user relations"
msgstr ""
#: core/models.py:615
msgid "This user is already in this document."
msgstr ""
#: core/models.py:621
msgid "This team is already in this document."
msgstr ""
#: core/models.py:627 core/models.py:816
msgid "Either user or team must be set, not both."
msgstr ""
#: core/models.py:645
msgid "description"
msgstr ""
#: core/models.py:646
msgid "code"
msgstr ""
#: core/models.py:647
msgid "css"
msgstr ""
#: core/models.py:649
msgid "public"
msgstr ""
#: core/models.py:651
msgid "Whether this template is public for anyone to use."
msgstr ""
#: core/models.py:657
msgid "Template"
msgstr ""
#: core/models.py:658
msgid "Templates"
msgstr ""
#: core/models.py:797
msgid "Template/user relation"
msgstr ""
#: core/models.py:798
msgid "Template/user relations"
msgstr ""
#: core/models.py:804
msgid "This user is already in this template."
msgstr ""
#: core/models.py:810
msgid "This team is already in this template."
msgstr ""
#: core/models.py:833
msgid "email address"
msgstr ""
#: core/models.py:850
msgid "Document invitation"
msgstr ""
#: core/models.py:851
msgid "Document invitations"
msgstr ""
#: core/models.py:868
msgid "This email is already associated to a registered user."
msgstr ""
#: core/templates/mail/html/invitation.html:160
#: core/templates/mail/html/invitation2.html:160
#: core/templates/mail/text/invitation.txt:3
#: core/templates/mail/text/invitation2.txt:3
msgid "La Suite Numérique"
msgstr ""
#: core/templates/mail/html/invitation.html:190
#: core/templates/mail/text/invitation.txt:6
#, python-format
msgid " %(username)s shared a document with you ! "
msgstr " %(username)s hat ein Dokument mit Ihnen geteilt! "
#: core/templates/mail/html/invitation.html:197
#: core/templates/mail/text/invitation.txt:8
#, python-format
msgid " %(username)s invited you as an %(role)s on the following document : "
msgstr " %(username)s hat Sie als %(role)s zum folgenden Dokument eingeladen: "
#: core/templates/mail/html/invitation.html:206
#: core/templates/mail/html/invitation2.html:211
#: core/templates/mail/text/invitation.txt:10
#: core/templates/mail/text/invitation2.txt:11
msgid "Open"
msgstr "Öffnen"
#: core/templates/mail/html/invitation.html:223
#: core/templates/mail/text/invitation.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborate on your documents as a team. "
msgstr " Docs, Ihr neues unverzichtbares Werkzeug zum Organisieren, Teilen und Zusammenarbeiten an Dokumenten im Team. "
#: core/templates/mail/html/invitation.html:230
#: core/templates/mail/html/invitation2.html:235
#: core/templates/mail/text/invitation.txt:16
#: core/templates/mail/text/invitation2.txt:17
msgid "Brought to you by La Suite Numérique"
msgstr "Bereitgestellt von La Suite Numérique"
#: core/templates/mail/html/invitation2.html:190
#, python-format
msgid "%(username)s shared a document with you"
msgstr "%(username)s hat ein Dokument mit Ihnen geteilt"
#: core/templates/mail/html/invitation2.html:197
#: core/templates/mail/text/invitation2.txt:8
#, python-format
msgid "%(username)s invited you as an %(role)s on the following document :"
msgstr "%(username)s hat Sie als %(role)s zum folgenden Dokument eingeladen:"
#: core/templates/mail/html/invitation2.html:228
#: core/templates/mail/text/invitation2.txt:15
msgid "Docs, your new essential tool for organizing, sharing and collaborate on your document as a team."
msgstr "Docs, Ihr neues unverzichtbares Werkzeug zum Organisieren, Teilen und gemeinsamen Arbeiten an Dokumenten im Team."
#: impress/settings.py:177
msgid "English"
msgstr ""
#: impress/settings.py:178
msgid "French"
msgstr ""
#: impress/settings.py:176
msgid "German"
msgstr ""
Binary file not shown.
+96 -84
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-people\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-09-25 10:15+0000\n"
"PO-Revision-Date: 2024-09-25 10:17\n"
"POT-Creation-Date: 2024-10-15 07:19+0000\n"
"PO-Revision-Date: 2024-10-15 07:23\n"
"Last-Translator: \n"
"Language-Team: English\n"
"Language: en_US\n"
@@ -17,15 +17,15 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 8\n"
#: core/admin.py:32
#: core/admin.py:33
msgid "Personal info"
msgstr ""
#: core/admin.py:34
#: core/admin.py:46
msgid "Permissions"
msgstr ""
#: core/admin.py:46
#: core/admin.py:58
msgid "Important dates"
msgstr ""
@@ -41,7 +41,7 @@ msgstr ""
msgid "Format"
msgstr ""
#: core/authentication/backends.py:56
#: core/authentication/backends.py:57
msgid "Invalid response format or token verification failed"
msgstr ""
@@ -49,10 +49,6 @@ msgstr ""
msgid "User info contained no recognizable user identification"
msgstr ""
#: core/authentication/backends.py:101
msgid "Claims contained no recognizable user identification"
msgstr ""
#: core/models.py:62 core/models.py:69
msgid "Reader"
msgstr ""
@@ -117,230 +113,246 @@ msgstr ""
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
msgstr ""
#: core/models.py:148
#: core/models.py:149
msgid "full name"
msgstr ""
#: core/models.py:150
msgid "short name"
msgstr ""
#: core/models.py:152
msgid "identity email address"
msgstr ""
#: core/models.py:153
#: core/models.py:157
msgid "admin email address"
msgstr ""
#: core/models.py:160
#: core/models.py:164
msgid "language"
msgstr ""
#: core/models.py:161
#: core/models.py:165
msgid "The language in which the user wants to see the interface."
msgstr ""
#: core/models.py:167
#: core/models.py:171
msgid "The timezone in which the user wants to see times."
msgstr ""
#: core/models.py:170
#: core/models.py:174
msgid "device"
msgstr ""
#: core/models.py:172
#: core/models.py:176
msgid "Whether the user is a device or a real user."
msgstr ""
#: core/models.py:175
#: core/models.py:179
msgid "staff status"
msgstr ""
#: core/models.py:177
#: core/models.py:181
msgid "Whether the user can log into this admin site."
msgstr ""
#: core/models.py:180
#: core/models.py:184
msgid "active"
msgstr ""
#: core/models.py:183
#: core/models.py:187
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: core/models.py:195
#: core/models.py:199
msgid "user"
msgstr ""
#: core/models.py:196
#: core/models.py:200
msgid "users"
msgstr ""
#: core/models.py:328 core/models.py:644
#: core/models.py:332 core/models.py:638
msgid "title"
msgstr ""
#: core/models.py:343
#: core/models.py:347
msgid "Document"
msgstr ""
#: core/models.py:344
#: core/models.py:348
msgid "Documents"
msgstr ""
#: core/models.py:347
#: core/models.py:351
msgid "Untitled Document"
msgstr ""
#: core/models.py:537
#: core/models.py:530
#, python-format
msgid "%(username)s shared a document with you: %(document)s"
msgid "%(sender_name)s shared a document with you: %(document)s"
msgstr ""
#: core/models.py:580
#: core/models.py:574
msgid "Document/user link trace"
msgstr ""
#: core/models.py:581
#: core/models.py:575
msgid "Document/user link traces"
msgstr ""
#: core/models.py:587
#: core/models.py:581
msgid "A link trace already exists for this document/user."
msgstr ""
#: core/models.py:608
#: core/models.py:602
msgid "Document/user relation"
msgstr ""
#: core/models.py:609
#: core/models.py:603
msgid "Document/user relations"
msgstr ""
#: core/models.py:615
#: core/models.py:609
msgid "This user is already in this document."
msgstr ""
#: core/models.py:621
#: core/models.py:615
msgid "This team is already in this document."
msgstr ""
#: core/models.py:627 core/models.py:816
#: core/models.py:621 core/models.py:810
msgid "Either user or team must be set, not both."
msgstr ""
#: core/models.py:645
#: core/models.py:639
msgid "description"
msgstr ""
#: core/models.py:646
#: core/models.py:640
msgid "code"
msgstr ""
#: core/models.py:647
#: core/models.py:641
msgid "css"
msgstr ""
#: core/models.py:649
#: core/models.py:643
msgid "public"
msgstr ""
#: core/models.py:651
#: core/models.py:645
msgid "Whether this template is public for anyone to use."
msgstr ""
#: core/models.py:657
#: core/models.py:651
msgid "Template"
msgstr ""
#: core/models.py:658
#: core/models.py:652
msgid "Templates"
msgstr ""
#: core/models.py:797
#: core/models.py:791
msgid "Template/user relation"
msgstr ""
#: core/models.py:798
#: core/models.py:792
msgid "Template/user relations"
msgstr ""
#: core/models.py:804
#: core/models.py:798
msgid "This user is already in this template."
msgstr ""
#: core/models.py:810
#: core/models.py:804
msgid "This team is already in this template."
msgstr ""
#: core/models.py:833
#: core/models.py:827
msgid "email address"
msgstr ""
#: core/models.py:850
#: core/models.py:844
msgid "Document invitation"
msgstr ""
#: core/models.py:851
#: core/models.py:845
msgid "Document invitations"
msgstr ""
#: core/models.py:868
#: core/models.py:862
msgid "This email is already associated to a registered user."
msgstr ""
#: core/templates/mail/html/invitation.html:160
#: core/templates/mail/html/invitation2.html:160
#: core/templates/mail/html/hello.html:159 core/templates/mail/text/hello.txt:3
msgid "Company logo"
msgstr ""
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
#, python-format
msgid "Hello %(name)s"
msgstr ""
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
msgid "Hello"
msgstr ""
#: core/templates/mail/html/hello.html:189 core/templates/mail/text/hello.txt:6
msgid "Thank you very much for your visit!"
msgstr ""
#: core/templates/mail/html/hello.html:221
#, python-format
msgid "This mail has been sent to %(email)s by <a href=\"%(href)s\">%(name)s</a>"
msgstr ""
#: core/templates/mail/html/invitation.html:159
#: core/templates/mail/text/invitation.txt:3
#: core/templates/mail/text/invitation2.txt:3
msgid "La Suite Numérique"
msgstr ""
#: core/templates/mail/html/invitation.html:190
#: core/templates/mail/html/invitation.html:189
#: core/templates/mail/text/invitation.txt:6
#, python-format
msgid " %(username)s shared a document with you ! "
msgid " %(sender_name)s shared a document with you ! "
msgstr ""
#: core/templates/mail/html/invitation.html:197
#: core/templates/mail/html/invitation.html:196
#: core/templates/mail/text/invitation.txt:8
#, python-format
msgid " %(username)s invited you as an %(role)s on the following document : "
msgid " %(sender_name_email)s invited you with the role \"%(role)s\" on the following document : "
msgstr ""
#: core/templates/mail/html/invitation.html:206
#: core/templates/mail/html/invitation2.html:211
#: core/templates/mail/html/invitation.html:205
#: core/templates/mail/text/invitation.txt:10
#: core/templates/mail/text/invitation2.txt:11
msgid "Open"
msgstr ""
#: core/templates/mail/html/invitation.html:223
#: core/templates/mail/html/invitation.html:222
#: core/templates/mail/text/invitation.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborate on your documents as a team. "
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr ""
#: core/templates/mail/html/invitation.html:230
#: core/templates/mail/html/invitation2.html:235
#: core/templates/mail/html/invitation.html:229
#: core/templates/mail/text/invitation.txt:16
#: core/templates/mail/text/invitation2.txt:17
msgid "Brought to you by La Suite Numérique"
msgstr ""
#: core/templates/mail/html/invitation2.html:190
#: core/templates/mail/text/hello.txt:8
#, python-format
msgid "%(username)s shared a document with you"
msgstr ""
#: core/templates/mail/html/invitation2.html:197
#: core/templates/mail/text/invitation2.txt:8
#, python-format
msgid "%(username)s invited you as an %(role)s on the following document :"
msgstr ""
#: core/templates/mail/html/invitation2.html:228
#: core/templates/mail/text/invitation2.txt:15
msgid "Docs, your new essential tool for organizing, sharing and collaborate on your document as a team."
msgstr ""
#: impress/settings.py:176
msgid "English"
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
msgstr ""
#: impress/settings.py:177
msgid "English"
msgstr ""
#: impress/settings.py:178
msgid "French"
msgstr ""
#: impress/settings.py:176
msgid "German"
msgstr ""
Binary file not shown.
+99 -87
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-people\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-09-25 10:15+0000\n"
"PO-Revision-Date: 2024-09-25 10:21\n"
"POT-Creation-Date: 2024-10-15 07:19+0000\n"
"PO-Revision-Date: 2024-10-15 07:23\n"
"Last-Translator: \n"
"Language-Team: French\n"
"Language: fr_FR\n"
@@ -17,15 +17,15 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 8\n"
#: core/admin.py:32
#: core/admin.py:33
msgid "Personal info"
msgstr "Infos Personnelles"
#: core/admin.py:34
#: core/admin.py:46
msgid "Permissions"
msgstr "Permissions"
#: core/admin.py:46
#: core/admin.py:58
msgid "Important dates"
msgstr "Dates importantes"
@@ -41,7 +41,7 @@ msgstr ""
msgid "Format"
msgstr ""
#: core/authentication/backends.py:56
#: core/authentication/backends.py:57
msgid "Invalid response format or token verification failed"
msgstr ""
@@ -49,10 +49,6 @@ msgstr ""
msgid "User info contained no recognizable user identification"
msgstr ""
#: core/authentication/backends.py:101
msgid "Claims contained no recognizable user identification"
msgstr ""
#: core/models.py:62 core/models.py:69
msgid "Reader"
msgstr "Lecteur"
@@ -117,230 +113,246 @@ msgstr ""
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
msgstr ""
#: core/models.py:148
#: core/models.py:149
msgid "full name"
msgstr ""
#: core/models.py:150
msgid "short name"
msgstr ""
#: core/models.py:152
msgid "identity email address"
msgstr ""
#: core/models.py:153
#: core/models.py:157
msgid "admin email address"
msgstr ""
#: core/models.py:160
#: core/models.py:164
msgid "language"
msgstr ""
#: core/models.py:161
#: core/models.py:165
msgid "The language in which the user wants to see the interface."
msgstr ""
#: core/models.py:167
#: core/models.py:171
msgid "The timezone in which the user wants to see times."
msgstr ""
#: core/models.py:170
#: core/models.py:174
msgid "device"
msgstr ""
#: core/models.py:172
#: core/models.py:176
msgid "Whether the user is a device or a real user."
msgstr ""
#: core/models.py:175
#: core/models.py:179
msgid "staff status"
msgstr ""
#: core/models.py:177
#: core/models.py:181
msgid "Whether the user can log into this admin site."
msgstr ""
#: core/models.py:180
#: core/models.py:184
msgid "active"
msgstr ""
#: core/models.py:183
#: core/models.py:187
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: core/models.py:195
#: core/models.py:199
msgid "user"
msgstr ""
#: core/models.py:196
#: core/models.py:200
msgid "users"
msgstr ""
#: core/models.py:328 core/models.py:644
#: core/models.py:332 core/models.py:638
msgid "title"
msgstr ""
#: core/models.py:343
#: core/models.py:347
msgid "Document"
msgstr ""
#: core/models.py:344
#: core/models.py:348
msgid "Documents"
msgstr ""
#: core/models.py:347
#: core/models.py:351
msgid "Untitled Document"
msgstr ""
#: core/models.py:537
#: core/models.py:530
#, python-format
msgid "%(username)s shared a document with you: %(document)s"
msgstr "%(username)s a partagé un document avec vous: %(document)s"
msgid "%(sender_name)s shared a document with you: %(document)s"
msgstr "%(sender_name)s a partagé un document avec vous: %(document)s"
#: core/models.py:580
#: core/models.py:574
msgid "Document/user link trace"
msgstr ""
#: core/models.py:581
#: core/models.py:575
msgid "Document/user link traces"
msgstr ""
#: core/models.py:587
#: core/models.py:581
msgid "A link trace already exists for this document/user."
msgstr ""
#: core/models.py:608
#: core/models.py:602
msgid "Document/user relation"
msgstr ""
#: core/models.py:609
#: core/models.py:603
msgid "Document/user relations"
msgstr ""
#: core/models.py:615
#: core/models.py:609
msgid "This user is already in this document."
msgstr ""
#: core/models.py:621
#: core/models.py:615
msgid "This team is already in this document."
msgstr ""
#: core/models.py:627 core/models.py:816
#: core/models.py:621 core/models.py:810
msgid "Either user or team must be set, not both."
msgstr ""
#: core/models.py:645
#: core/models.py:639
msgid "description"
msgstr ""
#: core/models.py:646
#: core/models.py:640
msgid "code"
msgstr ""
#: core/models.py:647
#: core/models.py:641
msgid "css"
msgstr ""
#: core/models.py:649
#: core/models.py:643
msgid "public"
msgstr ""
#: core/models.py:651
#: core/models.py:645
msgid "Whether this template is public for anyone to use."
msgstr ""
#: core/models.py:657
#: core/models.py:651
msgid "Template"
msgstr ""
#: core/models.py:658
#: core/models.py:652
msgid "Templates"
msgstr ""
#: core/models.py:797
#: core/models.py:791
msgid "Template/user relation"
msgstr ""
#: core/models.py:798
#: core/models.py:792
msgid "Template/user relations"
msgstr ""
#: core/models.py:804
#: core/models.py:798
msgid "This user is already in this template."
msgstr ""
#: core/models.py:810
#: core/models.py:804
msgid "This team is already in this template."
msgstr ""
#: core/models.py:833
#: core/models.py:827
msgid "email address"
msgstr ""
#: core/models.py:850
#: core/models.py:844
msgid "Document invitation"
msgstr ""
#: core/models.py:851
#: core/models.py:845
msgid "Document invitations"
msgstr ""
#: core/models.py:868
#: core/models.py:862
msgid "This email is already associated to a registered user."
msgstr ""
#: core/templates/mail/html/invitation.html:160
#: core/templates/mail/html/invitation2.html:160
#: core/templates/mail/html/hello.html:159 core/templates/mail/text/hello.txt:3
msgid "Company logo"
msgstr ""
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
#, python-format
msgid "Hello %(name)s"
msgstr ""
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
msgid "Hello"
msgstr ""
#: core/templates/mail/html/hello.html:189 core/templates/mail/text/hello.txt:6
msgid "Thank you very much for your visit!"
msgstr ""
#: core/templates/mail/html/hello.html:221
#, python-format
msgid "This mail has been sent to %(email)s by <a href=\"%(href)s\">%(name)s</a>"
msgstr ""
#: core/templates/mail/html/invitation.html:159
#: core/templates/mail/text/invitation.txt:3
#: core/templates/mail/text/invitation2.txt:3
msgid "La Suite Numérique"
msgstr ""
#: core/templates/mail/html/invitation.html:190
#: core/templates/mail/html/invitation.html:189
#: core/templates/mail/text/invitation.txt:6
#, python-format
msgid " %(username)s shared a document with you ! "
msgstr " %(username)s a partagé un document avec vous ! "
msgid " %(sender_name)s shared a document with you ! "
msgstr " %(sender_name)s a partagé un document avec vous ! "
#: core/templates/mail/html/invitation.html:197
#: core/templates/mail/html/invitation.html:196
#: core/templates/mail/text/invitation.txt:8
#, python-format
msgid " %(username)s invited you as an %(role)s on the following document : "
msgstr " %(username)s vous a invité en tant que %(role)s sur le document suivant : "
msgid " %(sender_name_email)s invited you with the role \"%(role)s\" on the following document : "
msgstr " %(sender_name_email)s vous a invité avec le rôle \"%(role)s\" sur le document suivant : "
#: core/templates/mail/html/invitation.html:206
#: core/templates/mail/html/invitation2.html:211
#: core/templates/mail/html/invitation.html:205
#: core/templates/mail/text/invitation.txt:10
#: core/templates/mail/text/invitation2.txt:11
msgid "Open"
msgstr "Ouvrir"
#: core/templates/mail/html/invitation.html:223
#: core/templates/mail/html/invitation.html:222
#: core/templates/mail/text/invitation.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborate on your documents as a team. "
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr " Docs, votre nouvel outil incontournable pour organiser, partager et collaborer sur vos documents en équipe. "
#: core/templates/mail/html/invitation.html:230
#: core/templates/mail/html/invitation2.html:235
#: core/templates/mail/html/invitation.html:229
#: core/templates/mail/text/invitation.txt:16
#: core/templates/mail/text/invitation2.txt:17
msgid "Brought to you by La Suite Numérique"
msgstr "Proposé par La Suite Numérique"
#: core/templates/mail/html/invitation2.html:190
#: core/templates/mail/text/hello.txt:8
#, python-format
msgid "%(username)s shared a document with you"
msgstr "%(username)s a partagé un document avec vous"
#: core/templates/mail/html/invitation2.html:197
#: core/templates/mail/text/invitation2.txt:8
#, python-format
msgid "%(username)s invited you as an %(role)s on the following document :"
msgstr "%(username)s vous a invité en tant que %(role)s sur le document suivant :"
#: core/templates/mail/html/invitation2.html:228
#: core/templates/mail/text/invitation2.txt:15
msgid "Docs, your new essential tool for organizing, sharing and collaborate on your document as a team."
msgstr "Docs, votre nouvel outil essentiel pour organiser, partager et collaborer sur votre document en équipe."
#: impress/settings.py:176
msgid "English"
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
msgstr ""
#: impress/settings.py:177
msgid "English"
msgstr ""
#: impress/settings.py:178
msgid "French"
msgstr ""
#: impress/settings.py:176
msgid "German"
msgstr ""
+13 -11
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "impress"
version = "1.5.1"
version = "1.7.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -25,34 +25,35 @@ license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"boto3==1.35.34",
"boto3==1.35.44",
"Brotli==1.1.0",
"celery[redis]==5.4.0",
"django-configurations==2.5.1",
"django-cors-headers==4.4.0",
"django-cors-headers==4.5.0",
"django-countries==7.6.1",
"django-parler==2.3",
"redis==5.1.1",
"django-redis==5.4.0",
"django-storages[s3]==1.14.4",
"django-timezone-field>=5.1",
"django==5.1.1",
"django==5.1.2",
"djangorestframework==3.15.2",
"drf_spectacular==0.27.2",
"dockerflow==2024.4.2",
"easy_thumbnails==2.10",
"factory_boy==3.3.1",
"freezegun==1.5.1",
"gunicorn==23.0.0",
"jsonschema==4.23.0",
"markdown==3.7",
"nested-multipart-parser==1.5.0",
"openai==1.52.0",
"psycopg[binary]==3.2.3",
"PyJWT==2.9.0",
"pypandoc==1.13",
"pypandoc==1.14",
"python-frontmatter==1.1.0",
"python-magic==0.4.27",
"requests==2.32.3",
"sentry-sdk==2.15.0",
"sentry-sdk==2.17.0",
"url-normalize==1.4.3",
"WeasyPrint>=60.2",
"whitenoise==6.7.0",
@@ -69,10 +70,11 @@ dependencies = [
dev = [
"django-extensions==3.2.3",
"drf-spectacular-sidecar==2024.7.1",
"freezegun==1.5.1",
"ipdb==0.13.13",
"ipython==8.28.0",
"pyfakefs==5.6.0",
"pylint-django==2.5.5",
"pyfakefs==5.7.1",
"pylint-django==2.6.1",
"pylint==3.3.1",
"pytest-cov==5.0.0",
"pytest-django==4.9.0",
@@ -80,8 +82,8 @@ dev = [
"pytest-icdiff==0.9",
"pytest-xdist==3.6.1",
"responses==0.25.3",
"ruff==0.6.9",
"types-requests==2.32.0.20240914",
"ruff==0.7.0",
"types-requests==2.32.0.20241016",
]
[tool.setuptools]
+13 -7
View File
@@ -1,4 +1,4 @@
FROM node:20-alpine as frontend-deps-y-provider
FROM node:20-alpine AS frontend-deps-y-provider
WORKDIR /home/frontend/
@@ -15,7 +15,7 @@ COPY ./src/frontend/ .
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
# ---- y-provider ----
FROM frontend-deps-y-provider as y-provider
FROM frontend-deps-y-provider AS y-provider
WORKDIR /home/frontend/servers/y-provider
RUN yarn build
@@ -28,7 +28,7 @@ ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
CMD ["yarn", "start"]
FROM node:20-alpine as frontend-deps
FROM node:20-alpine AS frontend-deps
WORKDIR /home/frontend/
@@ -43,11 +43,11 @@ COPY .dockerignore ./.dockerignore
COPY ./src/frontend/ .
### ---- Front-end builder image ----
FROM frontend-deps as impress
FROM frontend-deps AS impress
WORKDIR /home/frontend/apps/impress
FROM frontend-deps as impress-dev
FROM frontend-deps AS impress-dev
WORKDIR /home/frontend/apps/impress
@@ -57,7 +57,7 @@ CMD [ "yarn", "dev"]
# Tilt will rebuild impress target so, we dissociate impress and impress-builder
# to avoid rebuilding the app at every changes.
FROM impress as impress-builder
FROM impress AS impress-builder
WORKDIR /home/frontend/apps/impress
@@ -70,10 +70,16 @@ ENV NEXT_PUBLIC_Y_PROVIDER_URL=${Y_PROVIDER_URL}
ARG API_ORIGIN
ENV NEXT_PUBLIC_API_ORIGIN=${API_ORIGIN}
ARG MEDIA_URL
ENV NEXT_PUBLIC_MEDIA_URL=${MEDIA_URL}
ARG SW_DEACTIVATED
ENV NEXT_PUBLIC_SW_DEACTIVATED=${SW_DEACTIVATED}
RUN yarn build
# ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:1.26-alpine as frontend-production
FROM nginxinc/nginx-unprivileged:1.26-alpine AS frontend-production
# Un-privileged user running the application
ARG DOCKER_USER
@@ -4,17 +4,17 @@ export const keyCloakSignIn = async (page: Page, browserName: string) => {
const login = `user-e2e-${browserName}`;
const password = `password-e2e-${browserName}`;
await expect(
page.locator('.login-pf-page-header').getByText('impress'),
).toBeVisible();
if (await page.getByLabel('Restart login').isVisible()) {
await page.getByRole('textbox', { name: 'password' }).fill(password);
await page.click('input[type="submit"]', { force: true });
} else {
await page.getByRole('textbox', { name: 'username' }).fill(login);
await page.getByRole('textbox', { name: 'password' }).fill(password);
await page.click('input[type="submit"]', { force: true });
await page.getByLabel('Restart login').click();
}
await page.getByRole('textbox', { name: 'username' }).fill(login);
await page.getByRole('textbox', { name: 'password' }).fill(password);
await page.click('input[type="submit"]', { force: true });
};
export const randomName = (name: string, browserName: string, length: number) =>
@@ -27,7 +27,6 @@ export const createDoc = async (
docName: string,
browserName: string,
length: number,
isPublic: boolean = false,
) => {
const randomDocs = randomName(docName, browserName, length);
@@ -44,21 +43,6 @@ export const createDoc = async (
await page.getByRole('heading', { name: 'Untitled document' }).click();
await page.keyboard.type(randomDocs[i]);
await page.getByText('Created at ').click();
if (isPublic) {
await page.getByRole('button', { name: 'Share' }).click();
await page.getByText('Doc private').click();
await page.locator('.c__modal__backdrop').click({
position: { x: 0, y: 0 },
});
await expect(
page
.getByLabel('It is the card information about the document.')
.getByText('Public'),
).toBeVisible();
}
}
return randomDocs;
@@ -160,7 +144,7 @@ export const mockedDocument = async (page: Page, json: object) => {
versions_destroy: false,
versions_list: true,
versions_retrieve: true,
manage_accesses: false, // Means not admin
accesses_manage: false, // Means not admin
update: false,
partial_update: false, // Means not editor
retrieve: true,
@@ -9,6 +9,78 @@ test.beforeEach(async ({ page }) => {
});
test.describe('Doc Editor', () => {
test('it check translations of the slash menu when changing language', async ({
page,
browserName,
}) => {
await createDoc(page, 'doc-toolbar', browserName, 1);
const header = page.locator('header').first();
const editor = page.locator('.ProseMirror');
// Trigger slash menu to show english menu
await editor.click();
await editor.fill('/');
await expect(page.getByText('Headings', { exact: true })).toBeVisible();
await header.click();
await expect(page.getByText('Headings', { exact: true })).toBeHidden();
// Reset menu
await editor.click();
await editor.fill('');
// Change language to French
await header.click();
await header.getByRole('combobox').getByText('English').click();
await header.getByRole('option', { name: 'Français' }).click();
await expect(
header.getByRole('combobox').getByText('Français'),
).toBeVisible();
// Trigger slash menu to show french menu
await editor.click();
await editor.fill('/');
await expect(page.getByText('Titres', { exact: true })).toBeVisible();
await header.click();
await expect(page.getByText('Titres', { exact: true })).toBeHidden();
});
test('it checks default toolbar buttons are displayed', async ({
page,
browserName,
}) => {
await createDoc(page, 'doc-toolbar', browserName, 1);
const editor = page.locator('.ProseMirror');
await editor.click();
await editor.fill('test content');
await editor.getByText('test content').dblclick();
const toolbar = page.locator('.bn-formatting-toolbar');
await expect(toolbar.locator('button[data-test="bold"]')).toBeVisible();
await expect(toolbar.locator('button[data-test="italic"]')).toBeVisible();
await expect(
toolbar.locator('button[data-test="underline"]'),
).toBeVisible();
await expect(toolbar.locator('button[data-test="strike"]')).toBeVisible();
await expect(
toolbar.locator('button[data-test="alignTextLeft"]'),
).toBeVisible();
await expect(
toolbar.locator('button[data-test="alignTextCenter"]'),
).toBeVisible();
await expect(
toolbar.locator('button[data-test="alignTextRight"]'),
).toBeVisible();
await expect(toolbar.locator('button[data-test="colors"]')).toBeVisible();
await expect(
toolbar.locator('button[data-test="unnestBlock"]'),
).toBeVisible();
await expect(
toolbar.locator('button[data-test="createLink"]'),
).toBeVisible();
});
test('checks the Doc is connected to the provider server', async ({
page,
browserName,
@@ -59,9 +131,10 @@ test.describe('Doc Editor', () => {
test('it renders correctly when we switch from one doc to another', async ({
page,
browserName,
}) => {
// Check the first doc
const firstDoc = await goToGridDoc(page);
const [firstDoc] = await createDoc(page, 'doc-switch-1', browserName, 1);
await expect(page.locator('h2').getByText(firstDoc)).toBeVisible();
const editor = page.locator('.ProseMirror');
@@ -70,9 +143,7 @@ test.describe('Doc Editor', () => {
await expect(editor.getByText('Hello World Doc 1')).toBeVisible();
// Check the second doc
const secondDoc = await goToGridDoc(page, {
nthRow: 2,
});
const [secondDoc] = await createDoc(page, 'doc-switch-2', browserName, 1);
await expect(page.locator('h2').getByText(secondDoc)).toBeVisible();
await expect(editor.getByText('Hello World Doc 1')).toBeHidden();
await editor.click();
@@ -88,9 +159,12 @@ test.describe('Doc Editor', () => {
await expect(editor.getByText('Hello World Doc 1')).toBeVisible();
});
test('it saves the doc when we change pages', async ({ page }) => {
test('it saves the doc when we change pages', async ({
page,
browserName,
}) => {
// Check the first doc
const doc = await goToGridDoc(page);
const [doc] = await createDoc(page, 'doc-saves-change', browserName, 1);
await expect(page.locator('h2').getByText(doc)).toBeVisible();
const editor = page.locator('.ProseMirror');
@@ -141,7 +215,7 @@ test.describe('Doc Editor', () => {
versions_destroy: false,
versions_list: true,
versions_retrieve: true,
manage_accesses: false, // Means not admin
accesses_manage: false, // Means not admin
update: false,
partial_update: false, // Means not editor
retrieve: true,
@@ -181,4 +255,57 @@ test.describe('Doc Editor', () => {
/http:\/\/localhost:8083\/media\/.*\/attachments\/.*.png/,
);
});
test('it checks the AI buttons', async ({ page, browserName }) => {
await page.route(/.*\/ai-translate\//, async (route) => {
const request = route.request();
if (request.method().includes('POST')) {
await route.fulfill({
json: {
answer: 'Bonjour le monde',
},
});
} else {
await route.continue();
}
});
await createDoc(page, 'doc-ai', browserName, 1);
await page.locator('.bn-block-outer').last().fill('Hello World');
const editor = page.locator('.ProseMirror');
await editor.getByText('Hello').dblclick();
await page.getByRole('button', { name: 'AI' }).click();
await expect(
page.getByRole('menuitem', { name: 'Use as prompt' }),
).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'Rephrase' }),
).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'Summarize' }),
).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Correct' })).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'Language' }),
).toBeVisible();
await page.getByRole('menuitem', { name: 'Language' }).hover();
await expect(
page.getByRole('menuitem', { name: 'English', exact: true }),
).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'French', exact: true }),
).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'German', exact: true }),
).toBeVisible();
await page.getByRole('menuitem', { name: 'English', exact: true }).click();
await expect(editor.getByText('Bonjour le monde')).toBeVisible();
});
});
@@ -52,6 +52,7 @@ test.describe('Documents Grid', () => {
orderDefault: '',
orderDesc: '&ordering=-title',
orderAsc: '&ordering=title',
defaultColumn: false,
},
{
nameColumn: 'Created at',
@@ -60,6 +61,7 @@ test.describe('Documents Grid', () => {
orderDefault: '',
orderDesc: '&ordering=-created_at',
orderAsc: '&ordering=created_at',
defaultColumn: false,
},
{
nameColumn: 'Updated at',
@@ -68,6 +70,7 @@ test.describe('Documents Grid', () => {
orderDefault: '&ordering=-updated_at',
orderDesc: '&ordering=updated_at',
orderAsc: '',
defaultColumn: true,
},
].forEach(
({
@@ -77,6 +80,7 @@ test.describe('Documents Grid', () => {
orderDefault,
orderDesc,
orderAsc,
defaultColumn,
}) => {
test(`checks datagrid ordering ${ordering}`, async ({ page }) => {
const responsePromise = page.waitForResponse(
@@ -98,20 +102,19 @@ test.describe('Documents Grid', () => {
);
// Checks the initial state
const datagrid = page
.getByLabel('Datagrid of the documents page 1')
.getByRole('table');
const thead = datagrid.locator('thead');
const datagrid = page.getByLabel('Datagrid of the documents page 1');
const datagridTable = datagrid.getByRole('table');
const thead = datagridTable.locator('thead');
const response = await responsePromise;
expect(response.ok()).toBeTruthy();
const docNameRow1 = datagrid
const docNameRow1 = datagridTable
.getByRole('row')
.nth(1)
.getByRole('cell')
.nth(cellNumber);
const docNameRow2 = datagrid
const docNameRow2 = datagridTable
.getByRole('row')
.nth(2)
.getByRole('cell')
@@ -144,13 +147,21 @@ test.describe('Documents Grid', () => {
await expect(docNameRow2).toHaveText(/.*/);
const textDocNameRow1Asc = await docNameRow1.textContent();
const textDocNameRow2Asc = await docNameRow2.textContent();
const compare = (comp1: string, comp2: string) => {
const comparisonResult = comp1.localeCompare(comp2, 'en', {
caseFirst: 'false',
ignorePunctuation: true,
});
// eslint-disable-next-line playwright/no-conditional-in-test
return defaultColumn ? comparisonResult >= 0 : comparisonResult <= 0;
};
expect(
textDocNameRow1Asc &&
textDocNameRow2Asc &&
textDocNameRow1Asc.localeCompare(textDocNameRow2Asc, 'en', {
caseFirst: 'false',
ignorePunctuation: true,
}) <= 0,
compare(textDocNameRow1Asc, textDocNameRow2Asc),
).toBeTruthy();
// Ordering Desc
@@ -171,10 +182,7 @@ test.describe('Documents Grid', () => {
expect(
textDocNameRow1Desc &&
textDocNameRow2Desc &&
textDocNameRow1Desc.localeCompare(textDocNameRow2Desc, 'en', {
caseFirst: 'false',
ignorePunctuation: true,
}) >= 0,
compare(textDocNameRow2Desc, textDocNameRow1Desc),
).toBeTruthy();
});
},
@@ -295,7 +303,7 @@ test.describe('Documents Grid mobile', () => {
attachment_upload: true,
destroy: true,
link_configuration: true,
manage_accesses: true,
accesses_manage: true,
partial_update: true,
retrieve: true,
update: true,
@@ -21,6 +21,7 @@ test.describe('Doc Header', () => {
role: 'owner',
user: {
email: 'super@owner.com',
full_name: 'Super Owner',
},
},
{
@@ -44,7 +45,7 @@ test.describe('Doc Header', () => {
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
manage_accesses: true,
accesses_manage: true,
update: true,
partial_update: true,
retrieve: true,
@@ -65,7 +66,7 @@ test.describe('Doc Header', () => {
card.getByText('Created at 09/01/2021, 11:00 AM'),
).toBeVisible();
await expect(
card.getByText('Owners: super@owner.com / super2@owner.com'),
card.getByText('Owners: Super Owner / super2@owner.com'),
).toBeVisible();
await expect(card.getByText('Your role: Owner')).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
@@ -176,12 +177,13 @@ test.describe('Doc Header', () => {
test('it checks the options available if administrator', async ({ page }) => {
await mockedDocument(page, {
abilities: {
accesses_manage: true, // Means admin
accesses_view: true,
destroy: false, // Means not owner
link_configuration: true,
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
manage_accesses: true, // Means admin
update: true,
partial_update: true,
retrieve: true,
@@ -211,7 +213,11 @@ test.describe('Doc Header', () => {
const shareModal = page.getByLabel('Share modal');
await expect(shareModal.getByLabel('Doc private')).toBeEnabled();
await expect(
shareModal.getByRole('combobox', {
name: 'Visibility',
}),
).not.toHaveAttribute('disabled');
await expect(shareModal.getByText('Search by email')).toBeVisible();
const invitationCard = shareModal.getByLabel('List invitation card');
@@ -242,12 +248,13 @@ test.describe('Doc Header', () => {
test('it checks the options available if editor', async ({ page }) => {
await mockedDocument(page, {
abilities: {
accesses_manage: false, // Means not admin
accesses_view: true,
destroy: false, // Means not owner
link_configuration: false,
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
manage_accesses: false, // Means not admin
update: true,
partial_update: true, // Means editor
retrieve: true,
@@ -284,7 +291,11 @@ test.describe('Doc Header', () => {
const shareModal = page.getByLabel('Share modal');
await expect(shareModal.getByLabel('Doc private')).toBeDisabled();
await expect(
shareModal.getByRole('combobox', {
name: 'Visibility',
}),
).toHaveAttribute('disabled');
await expect(shareModal.getByText('Search by email')).toBeHidden();
const invitationCard = shareModal.getByLabel('List invitation card');
@@ -315,12 +326,13 @@ test.describe('Doc Header', () => {
test('it checks the options available if reader', async ({ page }) => {
await mockedDocument(page, {
abilities: {
accesses_manage: false, // Means not admin
accesses_view: true,
destroy: false, // Means not owner
link_configuration: false,
versions_destroy: false,
versions_list: true,
versions_retrieve: true,
manage_accesses: false, // Means not admin
update: false,
partial_update: false, // Means not editor
retrieve: true,
@@ -357,7 +369,11 @@ test.describe('Doc Header', () => {
const shareModal = page.getByLabel('Share modal');
await expect(shareModal.getByLabel('Doc private')).toBeDisabled();
await expect(
shareModal.getByRole('combobox', {
name: 'Visibility',
}),
).toHaveAttribute('disabled');
await expect(shareModal.getByText('Search by email')).toBeHidden();
const invitationCard = shareModal.getByLabel('List invitation card');
@@ -384,6 +400,81 @@ test.describe('Doc Header', () => {
}),
).toBeHidden();
});
test('It checks the copy as Markdown button', async ({
page,
browserName,
}) => {
// eslint-disable-next-line playwright/no-skipped-test
test.skip(
browserName === 'webkit',
'navigator.clipboard is not working with webkit and playwright',
);
// create page and navigate to it
await page
.getByRole('button', {
name: 'Create a new document',
})
.click();
// Add dummy content to the doc
const editor = page.locator('.ProseMirror');
const docFirstBlock = editor.locator('.bn-block-content').first();
await docFirstBlock.click();
await page.keyboard.type('# Hello World', { delay: 100 });
const docFirstBlockContent = docFirstBlock.locator('h1');
await expect(docFirstBlockContent).toHaveText('Hello World');
// Copy content to clipboard
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Copy as Markdown' }).click();
await expect(page.getByText('Copied to clipboard')).toBeVisible();
// Test that clipboard is in Markdown format
const handle = await page.evaluateHandle(() =>
navigator.clipboard.readText(),
);
const clipboardContent = await handle.jsonValue();
expect(clipboardContent.trim()).toBe('# Hello World');
});
test('It checks the copy as HTML button', async ({ page, browserName }) => {
// eslint-disable-next-line playwright/no-skipped-test
test.skip(
browserName === 'webkit',
'navigator.clipboard is not working with webkit and playwright',
);
// create page and navigate to it
await page
.getByRole('button', {
name: 'Create a new document',
})
.click();
// Add dummy content to the doc
const editor = page.locator('.ProseMirror');
const docFirstBlock = editor.locator('.bn-block-content').first();
await docFirstBlock.click();
await page.keyboard.type('# Hello World', { delay: 100 });
const docFirstBlockContent = docFirstBlock.locator('h1');
await expect(docFirstBlockContent).toHaveText('Hello World');
// Copy content to clipboard
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Copy as HTML' }).click();
await expect(page.getByText('Copied to clipboard')).toBeVisible();
// Test that clipboard is in HTML format
const handle = await page.evaluateHandle(() =>
navigator.clipboard.readText(),
);
const clipboardContent = await handle.jsonValue();
expect(clipboardContent.trim()).toBe(
`<h1 data-level="1">Hello World</h1><p></p>`,
);
});
});
test.describe('Documents Header mobile', () => {
@@ -401,7 +492,7 @@ test.describe('Documents Header mobile', () => {
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
manage_accesses: true,
accesses_manage: true,
update: true,
partial_update: true,
retrieve: true,
@@ -25,6 +25,7 @@ test.describe('Document list members', () => {
user: {
id: `fc092149-cafa-4ffa-a29d-e4b18af751-${pageId}-${i}`,
email: `impress@impress.world-page-${pageId}-${i}`,
full_name: `Impress World Page ${pageId}-${i}`,
},
team: '',
role: 'editor',
@@ -58,9 +59,11 @@ test.describe('Document list members', () => {
await waitForElementCount(list.locator('li'), 21, 10000);
expect(await list.locator('li').count()).toBeGreaterThan(20);
await expect(list.getByText(`Impress World Page 1-16`)).toBeVisible();
await expect(
list.getByText(`impress@impress.world-page-1-16`),
).toBeVisible();
await expect(list.getByText(`Impress World Page 2-15`)).toBeVisible();
await expect(
list.getByText(`impress@impress.world-page-2-15`),
).toBeVisible();
@@ -164,14 +167,22 @@ test.describe('Document list members', () => {
const shareModal = page.getByLabel('Share modal');
// Admin still have the right to share
await expect(shareModal.getByLabel('Doc private')).toBeEnabled();
await expect(
shareModal.getByRole('combobox', {
name: 'Visibility',
}),
).not.toHaveAttribute('disabled');
await SelectRoleCurrentUser.click();
await page.getByRole('option', { name: 'Reader' }).click();
await expect(page.getByText('The role has been updated')).toBeVisible();
// Reader does not have the right to share
await expect(shareModal.getByLabel('Doc private')).toBeDisabled();
await expect(
shareModal.getByRole('combobox', {
name: 'Visibility',
}),
).toHaveAttribute('disabled');
});
test('it checks the delete members', async ({ page, browserName }) => {
@@ -7,6 +7,22 @@ test.describe('Doc Routing', () => {
await page.goto('/');
});
test('Check the presence of the meta tag noindex', async ({ page }) => {
const buttonCreateHomepage = page.getByRole('button', {
name: 'Create a new document',
});
await expect(buttonCreateHomepage).toBeVisible();
await buttonCreateHomepage.click();
await expect(
page.getByRole('button', {
name: 'Share',
}),
).toBeVisible();
const metaDescription = page.locator('meta[name="robots"]');
await expect(metaDescription).toHaveAttribute('content', 'noindex');
});
test('checks alias docs url with homepage', async ({ page }) => {
await expect(page).toHaveURL('/');
@@ -2,40 +2,13 @@ import { expect, test } from '@playwright/test';
import { createDoc, keyCloakSignIn } from './common';
const browsersName = ['chromium', 'webkit', 'firefox'];
test.describe('Doc Visibility', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('Make a public doc', async ({ page, browserName }) => {
const [docTitle] = await createDoc(
page,
'My new doc',
browserName,
1,
true,
);
const header = page.locator('header').first();
await header.locator('h2').getByText('Docs').click();
const datagrid = page
.getByLabel('Datagrid of the documents page 1')
.getByRole('table');
await expect(datagrid.getByLabel('Loading data')).toBeHidden({
timeout: 10000,
});
await expect(datagrid.getByText(docTitle)).toBeVisible();
const row = datagrid.getByRole('row').filter({
hasText: docTitle,
});
await expect(row.getByRole('cell').nth(0)).toHaveText('Public');
});
test('It checks the copy link button', async ({ page, browserName }) => {
// eslint-disable-next-line playwright/no-skipped-test
test.skip(
@@ -57,12 +30,48 @@ test.describe('Doc Visibility', () => {
expect(clipboardContent).toMatch(page.url());
});
test('It checks the link role options', async ({ page, browserName }) => {
await createDoc(page, 'Doc role options', browserName, 1);
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByRole('combobox', {
name: 'Visibility',
});
await expect(selectVisibility.getByText('Restricted')).toBeVisible();
await expect(page.getByLabel('Read only')).toBeHidden();
await expect(page.getByLabel('Can read and edit')).toBeHidden();
await selectVisibility.click();
await page
.getByRole('option', {
name: 'Authenticated',
})
.click();
await expect(page.getByLabel('Read only')).toBeVisible();
await expect(page.getByLabel('Can read and edit')).toBeVisible();
await selectVisibility.click();
await page
.getByRole('option', {
name: 'Public',
})
.click();
await expect(page.getByLabel('Read only')).toBeVisible();
await expect(page.getByLabel('Can read and edit')).toBeVisible();
});
});
test.describe('Doc Visibility: Not loggued', () => {
test.describe('Doc Visibility: Restricted', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('A public doc is accessible even when not authentified.', async ({
test('A doc is not accessible when not authentified.', async ({
page,
browserName,
}) => {
@@ -71,14 +80,157 @@ test.describe('Doc Visibility: Not loggued', () => {
const [docTitle] = await createDoc(
page,
'My new doc',
'Restricted no auth',
browserName,
1,
true,
);
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
const urlDoc = page.url();
await page
.getByRole('button', {
name: 'Logout',
})
.click();
await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible();
await page.goto(urlDoc);
await expect(page.getByRole('textbox', { name: 'password' })).toBeVisible();
});
test('A doc is not accessible when authentified but not member.', async ({
page,
browserName,
}) => {
await page.goto('/');
await keyCloakSignIn(page, browserName);
const [docTitle] = await createDoc(page, 'Restricted auth', browserName, 1);
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
const urlDoc = page.url();
await page
.getByRole('button', {
name: 'Logout',
})
.click();
const otherBrowser = browsersName.find((b) => b !== browserName);
await keyCloakSignIn(page, otherBrowser!);
await page.goto(urlDoc);
await expect(
page.getByText('The document visiblitity has been updated.'),
page.getByText('You do not have permission to perform this action.'),
).toBeVisible();
});
test('A doc is accessible when member.', async ({ page, browserName }) => {
test.slow();
await page.goto('/');
await keyCloakSignIn(page, browserName);
const [docTitle] = await createDoc(page, 'Restricted auth', browserName, 1);
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
const inputSearch = page.getByLabel(/Find a member to add to the document/);
const otherBrowser = browsersName.find((b) => b !== browserName);
const username = `user@${otherBrowser}.e2e`;
await inputSearch.fill(username);
await page.getByRole('option', { name: username }).click();
// Choose a role
await page.getByRole('combobox', { name: /Choose a role/ }).click();
await page.getByRole('option', { name: 'Administrator' }).click();
await page.getByRole('button', { name: 'Validate' }).click();
await expect(
page.getByText(`User ${username} added to the document.`),
).toBeVisible();
await page.locator('.c__modal__backdrop').click({
position: { x: 0, y: 0 },
});
const urlDoc = page.url();
await page
.getByRole('button', {
name: 'Logout',
})
.click();
await keyCloakSignIn(page, otherBrowser!);
await page.goto(urlDoc);
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
});
});
test.describe('Doc Visibility: Public', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('It checks a public doc in read only mode', async ({
page,
browserName,
}) => {
await page.goto('/');
await keyCloakSignIn(page, browserName);
const [docTitle] = await createDoc(
page,
'Public read only',
browserName,
1,
);
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
await page
.getByRole('combobox', {
name: 'Visibility',
})
.click();
await page
.getByRole('option', {
name: 'Public',
})
.click();
await expect(
page.getByText('The document visibility has been updated.'),
).toBeVisible();
await page.getByLabel('Read only').click();
await expect(
page.getByText('The document visibility has been updated.').first(),
).toBeVisible();
await page.locator('.c__modal__backdrop').click({
position: { x: 0, y: 0 },
});
await expect(
page
.getByLabel('It is the card information about the document.')
.getByText('Public', { exact: true }),
).toBeVisible();
const urlDoc = page.url();
@@ -95,19 +247,54 @@ test.describe('Doc Visibility: Not loggued', () => {
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeHidden();
await expect(
page.getByText('Read only, you cannot edit this document'),
).toBeVisible();
});
test('A private doc redirect to the OIDC when not authentified.', async ({
test('It checks a public doc in editable mode', async ({
page,
browserName,
}) => {
test.slow();
await page.goto('/');
await keyCloakSignIn(page, browserName);
const [docTitle] = await createDoc(page, 'My private doc', browserName, 1);
const [docTitle] = await createDoc(page, 'Public editable', browserName, 1);
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
await page
.getByRole('combobox', {
name: 'Visibility',
})
.click();
await page
.getByRole('option', {
name: 'Public',
})
.click();
await expect(
page.getByText('The document visibility has been updated.'),
).toBeVisible();
await page.getByLabel('Can read and edit').click();
await expect(
page.getByText('The document visibility has been updated.').first(),
).toBeVisible();
await page.locator('.c__modal__backdrop').click({
position: { x: 0, y: 0 },
});
await expect(
page
.getByLabel('It is the card information about the document.')
.getByText('Public', { exact: true }),
).toBeVisible();
const urlDoc = page.url();
@@ -117,10 +304,214 @@ test.describe('Doc Visibility: Not loggued', () => {
})
.click();
await expect(page.getByRole('textbox', { name: 'password' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible();
await page.goto(urlDoc);
await expect(page.getByRole('textbox', { name: 'password' })).toBeVisible();
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeHidden();
await expect(
page.getByText('Read only, you cannot edit this document'),
).toBeHidden();
});
});
test.describe('Doc Visibility: Authenticated', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('A doc is not accessible when unauthentified.', async ({
page,
browserName,
}) => {
await page.goto('/');
await keyCloakSignIn(page, browserName);
const [docTitle] = await createDoc(
page,
'Authenticated unauthentified',
browserName,
1,
);
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
await page
.getByRole('combobox', {
name: 'Visibility',
})
.click();
await page
.getByRole('option', {
name: 'Authenticated',
})
.click();
await expect(
page.getByText('The document visibility has been updated.'),
).toBeVisible();
await page.locator('.c__modal__backdrop').click({
position: { x: 0, y: 0 },
});
const urlDoc = page.url();
await page
.getByRole('button', {
name: 'Logout',
})
.click();
await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible();
await page.goto(urlDoc);
await expect(page.locator('h2').getByText(docTitle)).toBeHidden();
await expect(page.getByRole('textbox', { name: 'password' })).toBeVisible();
});
test('It checks a authenticated doc in read only mode', async ({
page,
browserName,
}) => {
await page.goto('/');
await keyCloakSignIn(page, browserName);
const [docTitle] = await createDoc(
page,
'Authenticated read only',
browserName,
1,
);
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
await page
.getByRole('combobox', {
name: 'Visibility',
})
.click();
await page
.getByRole('option', {
name: 'Authenticated',
})
.click();
await expect(
page.getByText('The document visibility has been updated.'),
).toBeVisible();
await page.locator('.c__modal__backdrop').click({
position: { x: 0, y: 0 },
});
const urlDoc = page.url();
await page
.getByRole('button', {
name: 'Logout',
})
.click();
const otherBrowser = browsersName.find((b) => b !== browserName);
await keyCloakSignIn(page, otherBrowser!);
await page.goto(urlDoc);
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
await expect(
page.getByText('Read only, you cannot edit this document'),
).toBeVisible();
const shareModal = page.getByLabel('Share modal');
await expect(
shareModal.getByRole('combobox', {
name: 'Visibility',
}),
).toHaveAttribute('disabled');
await expect(shareModal.getByText('Search by email')).toBeHidden();
await expect(shareModal.getByLabel('List members card')).toBeHidden();
});
test('It checks a authenticated doc in editable mode', async ({
page,
browserName,
}) => {
await page.goto('/');
await keyCloakSignIn(page, browserName);
const [docTitle] = await createDoc(
page,
'Authenticated editable',
browserName,
1,
);
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
await page
.getByRole('combobox', {
name: 'Visibility',
})
.click();
await page
.getByRole('option', {
name: 'Authenticated',
})
.click();
await expect(
page.getByText('The document visibility has been updated.'),
).toBeVisible();
await page.locator('.c__modal__backdrop').click({
position: { x: 0, y: 0 },
});
const urlDoc = page.url();
await page.getByRole('button', { name: 'Share' }).click();
await page.getByLabel('Can read and edit').click();
await expect(
page.getByText('The document visibility has been updated.').first(),
).toBeVisible();
await page.locator('.c__modal__backdrop').click({
position: { x: 0, y: 0 },
});
await page
.getByRole('button', {
name: 'Logout',
})
.click();
const otherBrowser = browsersName.find((b) => b !== browserName);
await keyCloakSignIn(page, otherBrowser!);
await page.goto(urlDoc);
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
await expect(
page.getByText('Read only, you cannot edit this document'),
).toBeHidden();
const shareModal = page.getByLabel('Share modal');
await expect(
shareModal.getByRole('combobox', {
name: 'Visibility',
}),
).toHaveAttribute('disabled');
await expect(shareModal.getByText('Search by email')).toBeHidden();
await expect(shareModal.getByLabel('List members card')).toBeHidden();
});
});
@@ -24,5 +24,51 @@ test.describe('Language', () => {
name: 'Créer un nouveau document',
}),
).toBeVisible();
await header.getByRole('combobox').getByText('Français').click();
await header.getByRole('option', { name: 'Deutsch' }).click();
await expect(
header.getByRole('combobox').getByText('Deutsch'),
).toBeVisible();
await expect(
page.getByRole('button', {
name: 'Neues Dokument erstellen',
}),
).toBeVisible();
});
test('checks that backend uses the same language as the frontend', async ({
page,
}) => {
// Helper function to intercept and assert 404 response
const check404Response = async (expectedDetail: string) => {
const expectedBackendResponse = page.waitForResponse(
(response) =>
response.url().includes('/api') &&
response.url().includes('non-existent-doc-uuid') &&
response.status() === 404,
);
// Trigger the specific 404 XHR response by navigating to a non-existent document
await page.goto('/docs/non-existent-doc-uuid');
// Assert that the intercepted error message is in the expected language
const interceptedBackendResponse = await expectedBackendResponse;
expect(await interceptedBackendResponse.json()).toStrictEqual({
detail: expectedDetail,
});
};
// Check for English 404 response
await check404Response('Not found.');
// Switch language to French
const header = page.locator('header').first();
await header.getByRole('combobox').getByText('English').click();
await header.getByRole('option', { name: 'Français' }).click();
// Check for French 404 response
await check404Response('Pas trouvé.');
});
});
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "app-e2e",
"version": "1.5.1",
"version": "1.7.0",
"private": true,
"scripts": {
"lint": "eslint . --ext .ts",
@@ -12,7 +12,7 @@
"test:ui::chromium": "yarn test:ui --project=chromium"
},
"devDependencies": {
"@playwright/test": "1.47.2",
"@playwright/test": "1.48.2",
"@types/node": "*",
"@types/pdf-parse": "1.1.4",
"eslint-config-impress": "*",
+2 -1
View File
@@ -1,4 +1,5 @@
NEXT_PUBLIC_API_ORIGIN=
NEXT_PUBLIC_Y_PROVIDER_URL=
NEXT_PUBLIC_MEDIA_URL=
NEXT_PUBLIC_THEME=dsfr
NEXT_PUBLIC_THEME=dsfr
NEXT_PUBLIC_SW_DEACTIVATED=
+5
View File
@@ -358,6 +358,8 @@ const config = {
},
'forms-field': {
color: 'var(--c--theme--colors--primary-text)',
'footer-font-size': 'var(--c--theme--font--sizes--t)',
'footer-color': 'var(--c--theme--colors--greyscale-text)',
},
'forms-input': {
'border-radius': '4px',
@@ -372,6 +374,9 @@ const config = {
big: 'var(--c--theme--colors--primary-text)',
},
},
'forms-radio': {
'accent-color': 'var(--c--theme--colors--primary-600)',
},
'forms-select': {
'item-font-size': '14px',
'border-radius': '4px',
+18 -17
View File
@@ -1,6 +1,6 @@
{
"name": "app-impress",
"version": "1.5.1",
"version": "1.7.0",
"private": true,
"scripts": {
"dev": "next dev",
@@ -19,36 +19,37 @@
"@blocknote/mantine": "*",
"@blocknote/react": "*",
"@gouvfr-lasuite/integration": "1.0.2",
"@hocuspocus/provider": "2.13.6",
"@hocuspocus/provider": "2.13.7",
"@openfun/cunningham-react": "2.9.4",
"@tanstack/react-query": "5.56.2",
"i18next": "23.15.1",
"@tanstack/react-query": "5.59.20",
"i18next": "23.16.5",
"i18next-browser-languagedetector": "8.0.0",
"idb": "8.0.0",
"lodash": "4.17.21",
"luxon": "3.5.0",
"next": "14.2.13",
"next": "15.0.3",
"react": "*",
"react-aria-components": "1.3.3",
"react-aria-components": "1.4.1",
"react-dom": "*",
"react-i18next": "15.0.2",
"react-select": "5.8.1",
"react-i18next": "15.1.1",
"react-select": "5.8.3",
"styled-components": "6.1.13",
"y-protocols": "1.0.6",
"yjs": "*",
"zustand": "4.5.5"
"zustand": "5.0.1"
},
"devDependencies": {
"@svgr/webpack": "8.1.0",
"@tanstack/react-query-devtools": "5.58.0",
"@tanstack/react-query-devtools": "5.59.20",
"@testing-library/dom": "10.4.0",
"@testing-library/jest-dom": "6.5.0",
"@testing-library/jest-dom": "6.6.3",
"@testing-library/react": "16.0.1",
"@testing-library/user-event": "14.5.2",
"@types/jest": "29.5.13",
"@types/lodash": "4.17.9",
"@types/jest": "29.5.14",
"@types/lodash": "4.17.13",
"@types/luxon": "3.4.2",
"@types/node": "*",
"@types/react": "18.3.10",
"@types/react": "18.3.12",
"@types/react-dom": "*",
"cross-env": "*",
"dotenv": "16.4.5",
@@ -58,11 +59,11 @@
"jest-environment-jsdom": "29.7.0",
"node-fetch": "2.7.0",
"prettier": "3.3.3",
"stylelint": "16.9.0",
"stylelint": "16.10.0",
"stylelint-config-standard": "36.0.1",
"stylelint-prettier": "5.0.2",
"typescript": "*",
"webpack": "5.95.0",
"workbox-webpack-plugin": "7.1.0"
"webpack": "5.96.1",
"workbox-webpack-plugin": "7.3.0"
}
}
@@ -5,7 +5,7 @@ import { AppWrapper } from '@/tests/utils';
import Page from '../pages';
jest.mock('next/navigation', () => ({
jest.mock('next/router', () => ({
useRouter() {
return {
push: jest.fn(),
@@ -18,3 +18,7 @@ export class APIError<T = unknown> extends Error implements IAPIError<T> {
this.data = data;
}
}
export const isAPIError = (error: unknown): error is APIError => {
return error instanceof APIError;
};
@@ -1,6 +1,6 @@
import { ComponentPropsWithRef, ReactHTML } from 'react';
import styled from 'styled-components';
import { CSSProperties } from 'styled-components/dist/types';
import { CSSProperties, RuleSet } from 'styled-components/dist/types';
import {
MarginPadding,
@@ -15,11 +15,11 @@ export interface BoxProps {
$align?: CSSProperties['alignItems'];
$background?: CSSProperties['background'];
$color?: CSSProperties['color'];
$css?: string;
$css?: string | RuleSet<object>;
$direction?: CSSProperties['flexDirection'];
$display?: CSSProperties['display'];
$effect?: 'show' | 'hide';
$flex?: boolean;
$flex?: CSSProperties['flex'];
$gap?: CSSProperties['gap'];
$hasTransition?: boolean | 'slow';
$height?: CSSProperties['height'];
@@ -50,7 +50,7 @@ export const Box = styled('div')<BoxProps>`
${({ $color }) => $color && `color: ${$color};`}
${({ $direction }) => $direction && `flex-direction: ${$direction};`}
${({ $display }) => $display && `display: ${$display};`}
${({ $flex }) => $flex === false && `display: block;`}
${({ $flex }) => $flex && `flex: ${$flex};`}
${({ $gap }) => $gap && `gap: ${$gap};`}
${({ $height }) => $height && `height: ${$height};`}
${({ $hasTransition }) =>
@@ -73,7 +73,7 @@ export const Box = styled('div')<BoxProps>`
${({ $transition }) => $transition && `transition: ${$transition};`}
${({ $width }) => $width && `width: ${$width};`}
${({ $wrap }) => $wrap && `flex-wrap: ${$wrap};`}
${({ $css }) => $css && `${$css};`}
${({ $css }) => $css && (typeof $css === 'string' ? `${$css};` : $css)}
${({ $zIndex }) => $zIndex && `z-index: ${$zIndex};`}
${({ $effect }) => {
let effect;
@@ -1,4 +1,5 @@
import { ComponentPropsWithRef, forwardRef } from 'react';
import { css } from 'styled-components';
import { Box, BoxType } from './Box';
@@ -26,7 +27,7 @@ const BoxButton = forwardRef<HTMLDivElement, BoxType>(
$background="none"
$margin="none"
$padding="none"
$css={`
$css={css`
cursor: pointer;
border: none;
outline: none;
@@ -1,4 +1,5 @@
import { PropsWithChildren } from 'react';
import { css } from 'styled-components';
import { useCunninghamTheme } from '@/cunningham';
@@ -15,7 +16,7 @@ export const Card = ({
<Box
$background="white"
$radius="4px"
$css={`
$css={css`
box-shadow: 2px 2px 5px ${colorsTokens()['greyscale-300']};
border: 1px solid ${colorsTokens()['card-border']};
${$css}
@@ -1,4 +1,9 @@
import { CSSProperties, ComponentPropsWithRef, ReactHTML } from 'react';
import {
CSSProperties,
ComponentPropsWithRef,
ReactHTML,
forwardRef,
} from 'react';
import styled from 'styled-components';
import { tokens } from '@/cunningham';
@@ -55,18 +60,21 @@ export const TextStyled = styled(Box)<TextProps>`
`white-space: nowrap; overflow: hidden; text-overflow: ellipsis;`}
`;
export const Text = ({
className,
$isMaterialIcon,
...props
}: ComponentPropsWithRef<typeof TextStyled>) => {
return (
<TextStyled
as="span"
$theme="greyscale"
$variation="text"
className={`${className || ''}${$isMaterialIcon ? ' material-icons' : ''}`}
{...props}
/>
);
};
const Text = forwardRef<HTMLElement, ComponentPropsWithRef<typeof TextStyled>>(
({ className, $isMaterialIcon, ...props }, ref) => {
return (
<TextStyled
ref={ref}
as="span"
$theme="greyscale"
$variation="text"
className={`${className || ''}${$isMaterialIcon ? ' material-icons' : ''}`}
{...props}
/>
);
},
);
Text.displayName = 'Text';
export { Text };
@@ -8,4 +8,6 @@
export interface User {
id: string;
email: string;
full_name: string;
short_name: string;
}
@@ -16,6 +16,12 @@
line-height: initial;
}
.c__field .c__field__footer {
padding: 2px 0 0;
font-size: var(--c--components--forms-field--footer-font-size);
color: var(--c--components--forms-field--footer-color);
}
.labelled-box label {
color: var(--c--theme--colors--primary-text);
}
@@ -328,6 +334,10 @@ input:-webkit-autofill:focus {
cursor: not-allowed;
}
.c__checkbox.c__checkbox--disabled .c__checkbox__label {
color: var(--c--theme--colors--greyscale-400);
}
/**
* Button
*/
@@ -532,3 +542,10 @@ input:-webkit-autofill:focus {
.c__toast__container {
z-index: 10000;
}
/**
* Tooltip
*/
.c__tooltip {
padding: 4px 6px;
}
@@ -477,6 +477,12 @@
--c--components--forms-datepicker--border-radius: 0;
--c--components--forms-fileuploader--border-radius: 0;
--c--components--forms-field--color: var(--c--theme--colors--primary-text);
--c--components--forms-field--footer-font-size: var(
--c--theme--font--sizes--t
);
--c--components--forms-field--footer-color: var(
--c--theme--colors--greyscale-text
);
--c--components--forms-input--border-radius: 4px;
--c--components--forms-input--background-color: #fff;
--c--components--forms-input--border-color: var(
@@ -492,6 +498,9 @@
--c--components--forms-labelledbox--label-color--big: var(
--c--theme--colors--primary-text
);
--c--components--forms-radio--accent-color: var(
--c--theme--colors--primary-600
);
--c--components--forms-select--item-font-size: 14px;
--c--components--forms-select--border-radius: 4px;
--c--components--forms-select--border-radius-hover: 4px;
@@ -479,7 +479,11 @@ export const tokens = {
},
'forms-datepicker': { 'border-radius': '0' },
'forms-fileuploader': { 'border-radius': '0' },
'forms-field': { color: 'var(--c--theme--colors--primary-text)' },
'forms-field': {
color: 'var(--c--theme--colors--primary-text)',
'footer-font-size': 'var(--c--theme--font--sizes--t)',
'footer-color': 'var(--c--theme--colors--greyscale-text)',
},
'forms-input': {
'border-radius': '4px',
'background-color': '#ffffff',
@@ -491,6 +495,9 @@ export const tokens = {
'forms-labelledbox': {
'label-color': { big: 'var(--c--theme--colors--primary-text)' },
},
'forms-radio': {
'accent-color': 'var(--c--theme--colors--primary-600)',
},
'forms-select': {
'item-font-size': '14px',
'border-radius': '4px',
@@ -0,0 +1,3 @@
export * from './useCreateDocUpload';
export * from './useDocAITransform';
export * from './useDocAITranslate';
@@ -0,0 +1,46 @@
import { useMutation } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
export type AITransformActions =
| 'correct'
| 'prompt'
| 'rephrase'
| 'summarize';
export type DocAITransform = {
docId: string;
text: string;
action: AITransformActions;
};
export type DocAITransformResponse = {
answer: string;
};
export const docAITransform = async ({
docId,
...params
}: DocAITransform): Promise<DocAITransformResponse> => {
const response = await fetchAPI(`documents/${docId}/ai-transform/`, {
method: 'POST',
body: JSON.stringify({
...params,
}),
});
if (!response.ok) {
throw new APIError(
'Failed to request ai transform',
await errorCauses(response),
);
}
return response.json() as Promise<DocAITransformResponse>;
};
export function useDocAITransform() {
return useMutation<DocAITransformResponse, APIError, DocAITransform>({
mutationFn: docAITransform,
});
}
@@ -0,0 +1,40 @@
import { useMutation } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
export type DocAITranslate = {
docId: string;
text: string;
language: string;
};
export type DocAITranslateResponse = {
answer: string;
};
export const docAITranslate = async ({
docId,
...params
}: DocAITranslate): Promise<DocAITranslateResponse> => {
const response = await fetchAPI(`documents/${docId}/ai-translate/`, {
method: 'POST',
body: JSON.stringify({
...params,
}),
});
if (!response.ok) {
throw new APIError(
'Failed to request ai translate',
await errorCauses(response),
);
}
return response.json() as Promise<DocAITranslateResponse>;
};
export function useDocAITranslate() {
return useMutation<DocAITranslateResponse, APIError, DocAITranslate>({
mutationFn: docAITranslate,
});
}
@@ -0,0 +1,339 @@
import {
ComponentProps,
useBlockNoteEditor,
useComponentsContext,
useSelectedBlocks,
} from '@blocknote/react';
import {
Loader,
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import { PropsWithChildren, ReactNode, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { isAPIError } from '@/api';
import { Box, Text } from '@/components';
import { useDocOptions, useDocStore } from '@/features/docs/doc-management/';
import {
AITransformActions,
useDocAITransform,
useDocAITranslate,
} from '../api/';
type LanguageTranslate = {
value: string;
display_name: string;
};
const sortByPopularLanguages = (
languages: LanguageTranslate[],
popularLanguages: string[],
) => {
languages.sort((a, b) => {
const indexA = popularLanguages.indexOf(a.value);
const indexB = popularLanguages.indexOf(b.value);
// If both languages are in the popular list, sort based on their order in popularLanguages
if (indexA !== -1 && indexB !== -1) {
return indexA - indexB;
}
// If only a is in the popular list, it should come first
if (indexA !== -1) {
return -1;
}
// If only b is in the popular list, it should come first
if (indexB !== -1) {
return 1;
}
// If neither a nor b is in the popular list, maintain their relative order
return 0;
});
};
export function AIGroupButton() {
const editor = useBlockNoteEditor();
const Components = useComponentsContext();
const selectedBlocks = useSelectedBlocks(editor);
const { t } = useTranslation();
const { currentDoc } = useDocStore();
const { data: docOptions } = useDocOptions();
const languages = useMemo(() => {
const languages = docOptions?.actions.POST.language.choices;
if (!languages) {
return;
}
sortByPopularLanguages(languages, [
'fr',
'en',
'de',
'es',
'it',
'pt',
'nl',
'pl',
]);
return languages;
}, [docOptions?.actions.POST.language.choices]);
const show = useMemo(() => {
return !!selectedBlocks.find((block) => block.content !== undefined);
}, [selectedBlocks]);
if (!show || !editor.isEditable || !Components || !currentDoc || !languages) {
return null;
}
return (
<Components.Generic.Menu.Root>
<Components.Generic.Menu.Trigger>
<Components.FormattingToolbar.Button
className="bn-button bn-menu-item"
data-test="ai-actions"
label="AI"
mainTooltip={t('AI Actions')}
icon={
<Text $isMaterialIcon $size="l">
auto_awesome
</Text>
}
/>
</Components.Generic.Menu.Trigger>
<Components.Generic.Menu.Dropdown
className="bn-menu-dropdown bn-drag-handle-menu"
sub={true}
>
<AIMenuItemTransform
action="prompt"
docId={currentDoc.id}
icon={
<Text $isMaterialIcon $size="s">
text_fields
</Text>
}
>
{t('Use as prompt')}
</AIMenuItemTransform>
<AIMenuItemTransform
action="rephrase"
docId={currentDoc.id}
icon={
<Text $isMaterialIcon $size="s">
refresh
</Text>
}
>
{t('Rephrase')}
</AIMenuItemTransform>
<AIMenuItemTransform
action="summarize"
docId={currentDoc.id}
icon={
<Text $isMaterialIcon $size="s">
summarize
</Text>
}
>
{t('Summarize')}
</AIMenuItemTransform>
<AIMenuItemTransform
action="correct"
docId={currentDoc.id}
icon={
<Text $isMaterialIcon $size="s">
check
</Text>
}
>
{t('Correct')}
</AIMenuItemTransform>
<Components.Generic.Menu.Root position="right" sub={true}>
<Components.Generic.Menu.Trigger sub={false}>
<Components.Generic.Menu.Item
className="bn-menu-item"
subTrigger={true}
>
<Box $direction="row" $gap="0.6rem">
<Text $isMaterialIcon $size="s">
translate
</Text>
{t('Language')}
</Box>
</Components.Generic.Menu.Item>
</Components.Generic.Menu.Trigger>
<Components.Generic.Menu.Dropdown
sub={true}
className="bn-menu-dropdown"
>
{languages.map((language) => (
<AIMenuItemTranslate
key={language.value}
language={language.value}
docId={currentDoc.id}
>
{language.display_name}
</AIMenuItemTranslate>
))}
</Components.Generic.Menu.Dropdown>
</Components.Generic.Menu.Root>
</Components.Generic.Menu.Dropdown>
</Components.Generic.Menu.Root>
);
}
/**
* Item is derived from Mantime, some props seem lacking or incorrect.
*/
type ItemDefault = ComponentProps['Generic']['Menu']['Item'];
type ItemProps = Omit<ItemDefault, 'onClick'> & {
rightSection?: ReactNode;
closeMenuOnClick?: boolean;
onClick: (e: React.MouseEvent) => void;
};
interface AIMenuItemTransform {
action: AITransformActions;
docId: string;
icon?: ReactNode;
}
const AIMenuItemTransform = ({
docId,
action,
children,
icon,
}: PropsWithChildren<AIMenuItemTransform>) => {
const { mutateAsync: requestAI, isPending } = useDocAITransform();
const requestAIAction = async (markdown: string) => {
const responseAI = await requestAI({
text: markdown,
action,
docId,
});
return responseAI.answer;
};
return (
<AIMenuItem icon={icon} requestAI={requestAIAction} isPending={isPending}>
{children}
</AIMenuItem>
);
};
interface AIMenuItemTranslate {
language: string;
docId: string;
icon?: ReactNode;
}
const AIMenuItemTranslate = ({
children,
docId,
icon,
language,
}: PropsWithChildren<AIMenuItemTranslate>) => {
const { mutateAsync: requestAI, isPending } = useDocAITranslate();
const requestAITranslate = async (markdown: string) => {
const responseAI = await requestAI({
text: markdown,
language,
docId,
});
return responseAI.answer;
};
return (
<AIMenuItem
icon={icon}
requestAI={requestAITranslate}
isPending={isPending}
>
{children}
</AIMenuItem>
);
};
interface AIMenuItemProps {
requestAI: (markdown: string) => Promise<string>;
isPending: boolean;
icon?: ReactNode;
}
const AIMenuItem = ({
requestAI,
isPending,
children,
icon,
}: PropsWithChildren<AIMenuItemProps>) => {
const Components = useComponentsContext();
const editor = useBlockNoteEditor();
const handleAIError = useHandleAIError();
const handleAIAction = async () => {
const selectedBlocks = editor.getSelection()?.blocks;
if (!selectedBlocks || selectedBlocks.length === 0) {
return;
}
const markdown = await editor.blocksToMarkdownLossy(selectedBlocks);
try {
const responseAI = await requestAI(markdown);
if (!responseAI) {
return;
}
const blockMarkdown = await editor.tryParseMarkdownToBlocks(responseAI);
editor.replaceBlocks(selectedBlocks, blockMarkdown);
} catch (error) {
handleAIError(error);
}
};
if (!Components) {
return null;
}
const Item = Components.Generic.Menu.Item as React.FC<ItemProps>;
return (
<Item
closeMenuOnClick={false}
icon={icon}
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
void handleAIAction();
}}
rightSection={isPending ? <Loader size="small" /> : undefined}
>
{children}
</Item>
);
};
const useHandleAIError = () => {
const { toast } = useToastProvider();
const { t } = useTranslation();
return (error: unknown) => {
if (isAPIError(error) && error.status === 429) {
toast(t('Too many requests. Please wait 60 seconds.'), VariantType.ERROR);
return;
}
toast(t('AI seems busy! Please try again.'), VariantType.ERROR);
};
};
@@ -1,19 +1,21 @@
import { BlockNoteEditor as BlockNoteEditorCore } from '@blocknote/core';
import { Dictionary, locales } from '@blocknote/core';
import '@blocknote/core/fonts/inter.css';
import { BlockNoteView } from '@blocknote/mantine';
import '@blocknote/mantine/style.css';
import { useCreateBlockNote } from '@blocknote/react';
import { HocuspocusProvider } from '@hocuspocus/provider';
import React, { useCallback, useEffect, useMemo } from 'react';
import { t } from 'i18next';
import React, { useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, TextErrors } from '@/components';
import { mediaUrl } from '@/core';
import { useAuthStore } from '@/core/auth';
import { Doc } from '@/features/docs/doc-management';
import { Version } from '@/features/docs/doc-versioning/';
import { useCreateDocAttachment } from '../api/useCreateDocUpload';
import useSaveDoc from '../hook/useSaveDoc';
import { useDocStore, useHeadingStore } from '../stores';
import { useEditorStore, useHeadingStore } from '../stores';
import { randomColor } from '../utils';
import { BlockNoteToolbar } from './BlockNoteToolbar';
@@ -24,7 +26,13 @@ const cssEditor = (readonly: boolean) => `
};
& .bn-editor {
padding-right: 30px;
${readonly && `padding-left: 30px;`}
${
readonly &&
`
padding-left: 30px;
pointer-events: none;
`
}
};
& .collaboration-cursor__caret.ProseMirror-widget{
word-wrap: initial;
@@ -34,6 +42,11 @@ const cssEditor = (readonly: boolean) => `
padding: 2px;
border-radius: 4px;
}
/* @TODO: A fix is made in v0.19.1 - This code can be removed after the upgrade */
.bn-block-content[data-content-type=codeBlock]>div>select option{
color: black;
background-color: white;
}
@media screen and (width <= 560px) {
& .bn-editor {
padding-left: 40px;
@@ -66,53 +79,30 @@ const cssEditor = (readonly: boolean) => `
`;
interface BlockNoteEditorProps {
doc: Doc;
version?: Version;
}
export const BlockNoteEditor = ({ doc, version }: BlockNoteEditorProps) => {
const { createProvider, docsStore } = useDocStore();
const storeId = version?.id || doc.id;
const initialContent = version?.content || doc.content;
const provider = docsStore?.[storeId]?.provider;
useEffect(() => {
if (!provider || provider.document.guid !== storeId) {
createProvider(storeId, initialContent);
}
}, [createProvider, initialContent, provider, storeId]);
if (!provider) {
return null;
}
return <BlockNoteContent doc={doc} provider={provider} storeId={storeId} />;
};
interface BlockNoteContentProps {
doc: Doc;
provider: HocuspocusProvider;
storeId: string;
}
export const BlockNoteContent = ({
export const BlockNoteEditor = ({
doc,
provider,
storeId,
}: BlockNoteContentProps) => {
}: BlockNoteEditorProps) => {
const isVersion = doc.id !== storeId;
const { userData } = useAuthStore();
const { setStore, docsStore } = useDocStore();
const { setEditor } = useEditorStore();
const readOnly = !doc.abilities.partial_update || isVersion;
useSaveDoc(doc.id, provider.document, !readOnly);
const storedEditor = docsStore?.[storeId]?.editor;
const {
mutateAsync: createDocAttachment,
isError: isErrorAttachment,
error: errorAttachment,
} = useCreateDocAttachment();
const { setHeadings, resetHeadings } = useHeadingStore();
const { i18n } = useTranslation();
const lang = i18n.language;
const uploadFile = useCallback(
async (file: File) => {
@@ -129,27 +119,29 @@ export const BlockNoteContent = ({
[createDocAttachment, doc.id],
);
const editor = useMemo(() => {
if (storedEditor) {
return storedEditor;
}
return BlockNoteEditorCore.create({
const editor = useCreateBlockNote(
{
collaboration: {
provider,
fragment: provider.document.getXmlFragment('document-store'),
user: {
name: userData?.email || 'Anonymous',
name: userData?.full_name || userData?.email || t('Anonymous'),
color: randomColor(),
},
},
dictionary: locales[lang as keyof typeof locales] as Dictionary,
uploadFile,
});
}, [provider, storedEditor, uploadFile, userData?.email]);
},
[lang, provider, uploadFile, userData?.email, userData?.full_name],
);
useEffect(() => {
setStore(storeId, { editor });
}, [setStore, storeId, editor]);
setEditor(editor);
return () => {
setEditor(undefined);
};
}, [setEditor, editor]);
useEffect(() => {
setHeadings(editor);
@@ -1,55 +1,26 @@
import '@blocknote/mantine/style.css';
import {
BasicTextStyleButton,
BlockTypeSelect,
ColorStyleButton,
CreateLinkButton,
FormattingToolbar,
FormattingToolbarController,
NestBlockButton,
TextAlignButton,
UnnestBlockButton,
getFormattingToolbarItems,
} from '@blocknote/react';
import React from 'react';
import { AIGroupButton } from './AIButton';
import { MarkdownButton } from './MarkdownButton';
export const BlockNoteToolbar = () => {
return (
<FormattingToolbarController
formattingToolbar={() => (
formattingToolbar={({ blockTypeSelectItems }) => (
<FormattingToolbar>
<BlockTypeSelect key="blockTypeSelect" />
{getFormattingToolbarItems(blockTypeSelectItems)}
{/* Extra button to do some AI powered actions */}
<AIGroupButton key="AIButton" />
{/* Extra button to convert from markdown to json */}
<MarkdownButton key="customButton" />
<BasicTextStyleButton basicTextStyle="bold" key="boldStyleButton" />
<BasicTextStyleButton
basicTextStyle="italic"
key="italicStyleButton"
/>
<BasicTextStyleButton
basicTextStyle="underline"
key="underlineStyleButton"
/>
<BasicTextStyleButton
basicTextStyle="strike"
key="strikeStyleButton"
/>
{/* Extra button to toggle code styles */}
<BasicTextStyleButton key="codeStyleButton" basicTextStyle="code" />
<TextAlignButton textAlignment="left" key="textAlignLeftButton" />
<TextAlignButton textAlignment="center" key="textAlignCenterButton" />
<TextAlignButton textAlignment="right" key="textAlignRightButton" />
<ColorStyleButton key="colorStyleButton" />
<NestBlockButton key="nestBlockButton" />
<UnnestBlockButton key="unnestBlockButton" />
<CreateLinkButton key="createLinkButton" />
</FormattingToolbar>
)}
/>
@@ -1,13 +1,12 @@
import { Alert, Loader, VariantType } from '@openfun/cunningham-react';
import { useRouter as useNavigate } from 'next/navigation';
import { useRouter } from 'next/router';
import React from 'react';
import React, { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Card, Text, TextErrors } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { DocHeader } from '@/features/docs/doc-header';
import { Doc } from '@/features/docs/doc-management';
import { Doc, useDocStore } from '@/features/docs/doc-management';
import { Versions, useDocVersion } from '@/features/docs/doc-versioning/';
import { useResponsiveStore } from '@/stores';
@@ -32,6 +31,13 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
const { colorsTokens } = useCunninghamTheme();
const { providers } = useDocStore();
const provider = providers?.[doc.id];
if (!provider) {
return null;
}
return (
<>
<DocHeader doc={doc} versionId={versionId as Versions['version_id']} />
@@ -66,7 +72,7 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
{isVersion ? (
<DocVersionEditor doc={doc} versionId={versionId} />
) : (
<BlockNoteEditor doc={doc} />
<BlockNoteEditor doc={doc} storeId={doc.id} provider={provider} />
)}
{!isMobile && <IconOpenPanelEditor headings={headings} />}
</Card>
@@ -91,12 +97,24 @@ export const DocVersionEditor = ({ doc, versionId }: DocVersionEditorProps) => {
docId: doc.id,
versionId,
});
const { createProvider, providers } = useDocStore();
const navigate = useNavigate();
const { replace } = useRouter();
useEffect(() => {
if (!version?.id) {
return;
}
const provider = providers?.[version.id];
if (!provider || provider.document.guid !== version.id) {
createProvider(version.id, version.content);
}
}, [createProvider, providers, version]);
if (isError && error) {
if (error.status === 404) {
navigate.replace(`/404`);
void replace(`/404`);
return null;
}
@@ -124,5 +142,11 @@ export const DocVersionEditor = ({ doc, versionId }: DocVersionEditorProps) => {
);
}
return <BlockNoteEditor doc={doc} version={version} />;
const provider = providers?.[version.id];
if (!provider) {
return null;
}
return <BlockNoteEditor doc={doc} storeId={version.id} provider={provider} />;
};
@@ -6,6 +6,7 @@ import {
} from '@blocknote/react';
import { forEach, isArray } from 'lodash';
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
type Block = {
type: string;
@@ -42,6 +43,7 @@ export function MarkdownButton() {
const editor = useBlockNoteEditor();
const Components = useComponentsContext();
const selectedBlocks = useSelectedBlocks(editor);
const { t } = useTranslation();
const handleConvertMarkdown = () => {
const blocks = editor.getSelection()?.blocks;
@@ -75,7 +77,7 @@ export function MarkdownButton() {
return (
<Components.FormattingToolbar.Button
mainTooltip="Convert Markdown"
mainTooltip={t('Convert Markdown')}
onClick={handleConvertMarkdown}
>
M
@@ -127,9 +127,7 @@ export const PanelEditor = ({
</BoxButton>
)}
</Box>
{isPanelTableContentOpen && (
<TableContent doc={doc} headings={headings} />
)}
{isPanelTableContentOpen && <TableContent headings={headings} />}
{!isPanelTableContentOpen && doc.abilities.versions_list && (
<VersionList doc={doc} />
)}
@@ -1,3 +1,3 @@
export * from './useDocStore';
export * from './useEditorStore';
export * from './useHeadingStore';
export * from './usePanelEditorStore';
@@ -0,0 +1,14 @@
import { BlockNoteEditor } from '@blocknote/core';
import { create } from 'zustand';
export interface UseEditorstore {
editor?: BlockNoteEditor;
setEditor: (editor: BlockNoteEditor | undefined) => void;
}
export const useEditorStore = create<UseEditorstore>((set) => ({
editor: undefined,
setEditor: (editor) => {
set({ editor });
},
}));
@@ -1,5 +1,3 @@
import * as Y from 'yjs';
export const randomColor = () => {
const randomInt = (min: number, max: number) => {
return Math.floor(Math.random() * (max - min + 1)) + min;
@@ -28,20 +26,3 @@ function hslToHex(h: number, s: number, l: number) {
export const toBase64 = (
str: WithImplicitCoercion<ArrayBuffer | SharedArrayBuffer>,
) => Buffer.from(str).toString('base64');
type BasicBlock = {
type: string;
content: string;
};
export const blocksToYDoc = (blocks: BasicBlock[], doc: Y.Doc) => {
const xmlFragment = doc.getXmlFragment('document-store');
blocks.forEach((block) => {
const xmlElement = new Y.XmlElement(block.type);
if (block.content) {
xmlElement.insert(0, [new Y.XmlText(block.content)]);
}
xmlFragment.push([xmlElement]);
});
};
@@ -1,5 +1,6 @@
import React, { Fragment } from 'react';
import { Fragment } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, Card, StyledLink, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
@@ -46,7 +47,11 @@ export const DocHeader = ({ doc, versionId }: DocHeaderProps) => {
$theme="primary"
$variation="600"
$size="2rem"
$css={`&:hover {background-color: ${colorsTokens()['primary-100']}; };`}
$css={css`
&:hover {
background-color: ${colorsTokens()['primary-100']};
}
`}
$hasTransition
$radius="5px"
$padding="tiny"
@@ -104,7 +109,7 @@ export const DocHeader = ({ doc, versionId }: DocHeaderProps) => {
)
.map((access, index, accesses) => (
<Fragment key={`access-${index}`}>
{access.user.email}{' '}
{access.user.full_name || access.user.email}{' '}
{index < accesses.length - 1 ? ' / ' : ''}
</Fragment>
))}
@@ -7,7 +7,6 @@ import {
} from '@openfun/cunningham-react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { createGlobalStyle } from 'styled-components';
import { Box, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
@@ -19,15 +18,9 @@ import {
useTrans,
useUpdateDoc,
} from '@/features/docs/doc-management';
import { useResponsiveStore } from '@/stores';
import { useBroadcastStore, useResponsiveStore } from '@/stores';
import { isFirefox } from '@/utils/userAgent';
const DocTitleStyle = createGlobalStyle`
.c__tooltip {
padding: 4px 6px;
}
`;
interface DocTitleProps {
doc: Doc;
}
@@ -61,6 +54,7 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
const headingText = headings?.[0]?.contentText;
const debounceRef = useRef<NodeJS.Timeout>();
const { isMobile } = useResponsiveStore();
const { broadcast } = useBroadcastStore();
const { mutate: updateDoc } = useUpdateDoc({
listInvalideQueries: [KEY_DOC, KEY_LIST_DOC],
@@ -68,6 +62,9 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
if (data.title !== untitledDocument) {
toast(t('Document title updated successfully'), VariantType.SUCCESS);
}
// Broadcast to every user connected to the document
broadcast(`${KEY_DOC}-${data.id}`);
},
});
@@ -126,7 +123,6 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
return (
<>
<DocTitleStyle />
<Tooltip content={t('Rename')} placement="top">
<Box
as="h2"
@@ -1,19 +1,25 @@
import { Button } from '@openfun/cunningham-react';
import {
Button,
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, DropButton, IconOptions } from '@/components';
import { useAuthStore } from '@/core';
import { usePanelEditorStore } from '@/features/docs/doc-editor/';
import {
useEditorStore,
usePanelEditorStore,
} from '@/features/docs/doc-editor/';
import {
Doc,
ModalRemoveDoc,
ModalShare,
} from '@/features/docs/doc-management';
import { ModalVersion, Versions } from '@/features/docs/doc-versioning';
import { useResponsiveStore } from '@/stores';
import { ModalVersion, Versions } from '../../doc-versioning';
import { ModalPDF } from './ModalExport';
interface DocToolBoxProps {
@@ -31,6 +37,31 @@ export const DocToolBox = ({ doc, versionId }: DocToolBoxProps) => {
const [isModalVersionOpen, setIsModalVersionOpen] = useState(false);
const { isSmallMobile } = useResponsiveStore();
const { authenticated } = useAuthStore();
const { editor } = useEditorStore();
const { toast } = useToastProvider();
const copyCurrentEditorToClipboard = async (
asFormat: 'html' | 'markdown',
) => {
if (!editor) {
toast(t('Editor unavailable'), VariantType.ERROR, { duration: 3000 });
return;
}
try {
const editorContentFormatted =
asFormat === 'html'
? await editor.blocksToHTMLLossy()
: await editor.blocksToMarkdownLossy();
await navigator.clipboard.writeText(editorContentFormatted);
toast(t('Copied to clipboard'), VariantType.SUCCESS, { duration: 3000 });
} catch (error) {
console.error(error);
toast(t('Failed to copy to clipboard'), VariantType.ERROR, {
duration: 3000,
});
}
};
return (
<Box
@@ -125,6 +156,28 @@ export const DocToolBox = ({ doc, versionId }: DocToolBoxProps) => {
{t('Delete document')}
</Button>
)}
<Button
onClick={() => {
setIsDropOpen(false);
void copyCurrentEditorToClipboard('markdown');
}}
color="primary-text"
icon={<span className="material-icons">content_copy</span>}
size="small"
>
{t('Copy as {{format}}', { format: 'Markdown' })}
</Button>
<Button
onClick={() => {
setIsDropOpen(false);
void copyCurrentEditorToClipboard('html');
}}
color="primary-text"
icon={<span className="material-icons">content_copy</span>}
size="small"
>
{t('Copy as {{format}}', { format: 'HTML' })}
</Button>
</Box>
</DropButton>
</Box>
@@ -14,7 +14,7 @@ import { t } from 'i18next';
import { useEffect, useMemo, useState } from 'react';
import { Box, Text } from '@/components';
import { useDocStore } from '@/features/docs/doc-editor/';
import { useEditorStore } from '@/features/docs/doc-editor';
import { Doc } from '@/features/docs/doc-management';
import { useExport } from '../api/useExport';
@@ -31,7 +31,7 @@ export const ModalPDF = ({ onClose, doc }: ModalPDFProps) => {
ordering: TemplatesOrdering.BY_CREATED_ON_DESC,
});
const { toast } = useToastProvider();
const { docsStore } = useDocStore();
const { editor } = useEditorStore();
const {
mutate: createExport,
data: documentGenerated,
@@ -104,8 +104,6 @@ export const ModalPDF = ({ onClose, doc }: ModalPDFProps) => {
return;
}
const editor = docsStore[doc.id].editor;
if (!editor) {
toast(t('No editor found'), VariantType.ERROR);
return;
@@ -5,7 +5,7 @@ export interface Template {
abilities: {
destroy: boolean;
generate_document: boolean;
manage_accesses: boolean;
accesses_manage: boolean;
retrieve: boolean;
update: boolean;
partial_update: boolean;
@@ -1,5 +1,6 @@
export * from './useCreateDoc';
export * from './useDoc';
export * from './useDocOptions';
export * from './useDocs';
export * from './useUpdateDoc';
export * from './useUpdateDocLink';
@@ -0,0 +1,47 @@
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
type DocOptionsResponse = {
actions: {
POST: {
language: {
choices: {
value: string;
display_name: string;
}[];
};
};
};
};
export const docOptions = async (): Promise<DocOptionsResponse> => {
const response = await fetchAPI(`documents/`, {
method: 'OPTIONS',
});
if (!response.ok) {
throw new APIError(
'Failed to get the doc options',
await errorCauses(response),
);
}
return response.json() as Promise<DocOptionsResponse>;
};
export const KEY_DOC_OPTIONS = 'doc-options';
export function useDocOptions(
queryConfig?: UseQueryOptions<
DocOptionsResponse,
APIError,
DocOptionsResponse
>,
) {
return useQuery<DocOptionsResponse, APIError, DocOptionsResponse>({
queryKey: [KEY_DOC_OPTIONS],
queryFn: docOptions,
...queryConfig,
});
}
@@ -1,7 +1,8 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { Doc } from '@/features/docs';
import { Doc, KEY_DOC } from '@/features/docs/doc-management';
import { useBroadcastStore } from '@/stores';
export type UpdateDocLinkParams = Pick<Doc, 'id'> &
Partial<Pick<Doc, 'link_role' | 'link_reach'>>;
@@ -37,14 +38,20 @@ export function useUpdateDocLink({
listInvalideQueries,
}: UpdateDocLinkProps = {}) {
const queryClient = useQueryClient();
const { broadcast } = useBroadcastStore();
return useMutation<Doc, APIError, UpdateDocLinkParams>({
mutationFn: updateDocLink,
onSuccess: (data) => {
onSuccess: (data, variable) => {
listInvalideQueries?.forEach((queryKey) => {
void queryClient.resetQueries({
queryKey: [queryKey],
});
});
// Broadcast to every user connected to the document
broadcast(`${KEY_DOC}-${variable.id}`);
onSuccess?.(data);
},
});

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