Compare commits

..

79 Commits

Author SHA1 Message Date
Anthony LC
8ad708aac6 test-other-kind-cursor 2025-02-07 21:46:31 +01:00
renovate[bot]
a63afffbd6 ⬆️(dependencies) update js dependencies 2025-02-07 18:29:24 +01:00
Anthony LC
ebe3efc8f7 💄(frontend) update the favicon
Update the favicon with a better one.
2025-02-07 18:18:22 +01:00
Anthony LC
66fbf27913 🩹(frontend) fix PageLayout
The page layout was rendered behind the header,
which caused the top of the mention legales pages
to be hidden.
This commit fixes this issue.
2025-02-07 18:18:22 +01:00
Anthony LC
20e4a4e42a 🥚(frontend) easter egg in dev tools
Add a easter egg in the browser dev tools.
2025-02-07 18:18:22 +01:00
Anthony LC
1aa4844eeb 🎨(frontend) add dsfr proconnect homepage
If we are with the DSFR theme, we need to add the
proconnect button to the homepage.
We add an option in the cunningham theme to
display the proconnect section instead of the
opensource section.
2025-02-07 18:18:22 +01:00
Nathan Panchout
4bb9c092cb (frontend) add white label homepage
Add white label homepage with new assets and
components. When the user is not logged in,
the homepage will be displayed.
2025-02-07 18:18:22 +01:00
Anthony LC
c493eb8924 ♻️(frontend) use a hook instead of a store for auth
We will use a hook instead of a store for the auth
feature. The hook will be powered by ReactQuery,
it will provide us fine-grained control over the
auth state and will be easier to use.
2025-02-07 18:18:22 +01:00
Anthony LC
40fdf97520 🚚(frontend) move auth to its own feature
We will move auth to its own feature to make it
easier to manage and to make it more modular.
2025-02-07 18:18:22 +01:00
Anthony LC
91b10e75dd 💄(email) fix line height email title
The line height of the email title was not
the correct size. We let the title managing
its own line height.
2025-02-07 17:05:49 +01:00
Anthony LC
7a6da10e1c 🐛(i18n) add back the missing email translations
We changed the way we upload the translations to
Crowdin, some translations were missing for the
email templates. We add them back and improve
the tests to make sure we don't forget them again.
2025-02-07 17:05:49 +01:00
Anthony LC
004e8ec645 🌐(CI) build mails when crowdin_upload workflow
When we were executing the crowdin_upload workflow,
we were not building the mail template to dispatch it
to the backend. It resulted in the mail not being
totally translated. This commit fixes that issue
by adding the build mail step to the crowdin_upload.
To do so, we added it to the dependencies workflow.
"dependencies" workflow is callable by other
workflows that need a specific job.
2025-02-07 17:05:49 +01:00
virgile-deville
a1bca9c436 📝(doc) add security.md and conduct.md policies
We need a safe way for people to report vulnerabilities.
People now can go on SECURITY.md and follow our policy.

We want to have a policy for expected behaviour.
People can check out CODE_OF_CONDUCT.md.
2025-02-02 14:53:29 +01:00
Sylvain Zimmer
d02fa1ddd4 ✏️(readme) fix a few typos
Found a few errors after proof-reading the README
2025-01-30 19:00:56 +01:00
Anthony LC
1fd66d3081 🔖(minor) release 2.1.0
Added:
- (backend) add soft delete and restore API endpoints to documents
- (backend) allow organizing documents in a tree structure
- (backend) add "excerpt" field to document list serializer
- (backend) add github actions to manage Crowdin workflow
- 📈Integrate Posthog
- 🏷️(backend) add content-type to uploaded file
- (frontend) export pdf docx front side7

Changed:
- 💄(frontend) add abilities on doc row
- 💄(frontend) improve DocsGridItem responsive padding
- 🔧(backend) Bump maximum page size to 200
- 📝(doc) Improve Read me

Fixed:
- 🐛Fix invitations

Removed:
- 🔥(backend) remove "content" field from list serializer
2025-01-30 12:50:44 +01:00
AntoLC
bae8c4c563 🌐(i18n) update translated strings
Update translated files with new translations
2025-01-30 11:37:05 +01:00
Anthony LC
0dae35dab1 ✏️(frontend) replace Word / Open Office by Docx
Replace the naming of the select export
options from Word / Open Office to Docx.
2025-01-30 11:37:05 +01:00
Anthony LC
929a50b573 ⬇️(frontend) downgrade cunningham to 2.9.4
The last version of Cunningham has problems.
Better to downgrade to the previous version.
We add cunningham library to renovate.json
to prevent future upgrade with Renovate.
An issue will be open to upgrade to the
last version manually.
2025-01-29 17:45:14 +01:00
Anthony LC
83dfd26d1c ⬇️(frontend) downgrade to react 18
We still have conflict with React 19, better to
downgrade to react 18 for the moment.
We add the react 18 libs to renovate.json
to prevent future upgrade with Renovate.
An issue will be open to upgrade to React 19
manually.
2025-01-29 17:45:14 +01:00
renovate[bot]
addc6a331f ⬆️(dependencies) update js dependencies 2025-01-29 17:45:14 +01:00
Samuel Paccoud - DINUM
5c5763a0ef 🗃️(backend) make document/template creation atomic
When creating a new document/template via the API, we add the
logged-in user as owner of the created object. This should be
done atomically with the object creation to make sure we don't
end-up with an orphan object that the creator can't access
anymore.
2025-01-29 14:39:47 +01:00
Samuel Paccoud - DINUM
5042f4ca47 ♻️(backend) generalize use of SerializerPerActionMixin in viewsets
We improve the serializer and generalize its use for clarity.
2025-01-29 14:39:47 +01:00
Samuel Paccoud - DINUM
8c247c8777 🔧(backend) bump maximum page size to 200
The new UI with infinite scroll calls for longer pages which we
are able to produce thanks to the many optimizations on the list
view.
2025-01-29 14:39:47 +01:00
Samuel Paccoud - DINUM
239342fbbd (backend) add API endpoint action to restore a soft deleted document
Only owners can see and restore deleted documents. They can only do
it during the grace period before the document is considered hard
deleted and hidden from everybody on the API.
2025-01-29 14:39:47 +01:00
Samuel Paccoud - DINUM
8ccfdb3c6a (backend) add soft delete to documents and refactor db queryset
Now that we have introduced a document tree structure, it is not
possible to allow deleting documents anymore as it impacts the whole
subtree below the deleted document and the consequences are too big.

We introduce soft delete in order to give a second thought to the
document's owner (who is the only one to be allowed to delete a
document). After a document is soft deleted, the owner can still
see it in the trashbin (/api/v1.0/documents/trashbin).
After a grace period (30 days be default) the document disappears
from the trashbin and can't be restored anymore. Note that even
then it is still kept in database. Cleaning the database to erase
deleted documents after the grace period can be done as a maintenance
script.
2025-01-29 14:39:47 +01:00
Samuel Paccoud - DINUM
4de03d292a (backend) add API endpoint to move a document in the document tree
Only administrators or owners of a document can move it to a target
document for which they are also administrator or owner.

We allow different moving modes:
- first-child: move the document as the first child of the target
- last-child: move the document as the last child of the target
- first-sibling: move the document as the first sibling of the target
- last-sibling: move the document as the last sibling of the target
- left: move the document as sibling ordered just before the target
- right: move the document as sibling ordered just after the target

The whole subtree below the document that is being moved, moves as
well and remains below the document after it is moved.
2025-01-29 14:39:47 +01:00
Samuel Paccoud - DINUM
2e8a399668 (backend) override defaults.NB_OBJECTS for the demo tests
Otherwise when you increase the number of objects to test how the
application scales, the demo test will take too long...
2025-01-29 14:39:47 +01:00
Samuel Paccoud - DINUM
7b39b3f7f6 (backend) improve test error
This test was missing the status code check. Without this check
the error that follows does not make sense because the content
returned is not at all what we expect in the following assert
statement.
2025-01-29 14:39:47 +01:00
Samuel Paccoud - DINUM
0003f9d0de (backend) add user roles as field in the document API representation
user roles were already computed as an annotation on the query for
performance as we must look at all the document's ancestors to determine
the roles that apply recursively. We can easily expose them as readonly
via the serializer.
2025-01-29 14:39:47 +01:00
Samuel Paccoud - DINUM
8117866ce7 ♻️(backend) remove content from list serializer and introduce excerpt
Including the content field in the list view is not efficient as we need
to query the object storage to retrieve it. We want to display an excerpt
of the content on the list view so we should store it in database. We
let the frontend compute it and save it for us in the new "excerpt" field
because we are not supposed to have access to the content (E2EE feature coming)
2025-01-29 14:39:47 +01:00
Samuel Paccoud - DINUM
1d0386d9b5 (backend) add API endpoint to create children for a document
We add a POST method to the existing children endpoint.
2025-01-29 14:39:47 +01:00
Samuel Paccoud - DINUM
7ff4bc457f (backend) add API endpoint to list a document's children
This endpoint is nested under a document's detail endpoint.
2025-01-29 14:39:47 +01:00
Samuel Paccoud - DINUM
4333b46901 (backend) add depth, path and numchild to serialized document
This information is useful for the frontend to display the document
tree structure and is cheap to expose.
2025-01-29 14:39:47 +01:00
Samuel Paccoud - DINUM
d073a9c9b3 (backend) retrieve & update a document taking into account ancestors
A document should inherit the access rights a user has on any of its
ancestors.
2025-01-29 14:39:47 +01:00
Samuel Paccoud - DINUM
48662ceecb (backend) list only the first visible parent document for a user
Now that we have a tree structure, we should only include parents of
a visible subtree in list results.
2025-01-29 14:39:47 +01:00
Samuel Paccoud - DINUM
9a12452c26 🚚(backend) split test files to make place for tests on tree structure
The test_api_documents_list file was getting too long. We can extract
tests on filters and ordering.
2025-01-29 14:39:47 +01:00
Samuel Paccoud - DINUM
276b4f7c1b (backend) add django-treebeard to allow tree structure on documents
We choose to use Django-treebeard for its quality, performance and
stability. Adding tree structure to documents is as simple as
inheriting from the MP_Node class.
2025-01-29 14:39:47 +01:00
Anthony LC
0189078917 (CI) fix test-e2e-other-browser job
A recent update to the impress-frontend workflow
caused the test-e2e-other-browser job to fail.
This commit fixes the issue.
2025-01-29 14:23:46 +01:00
Anthony LC
6569f61fc4 (e2e) fix flakiness
Some flakiness appeared in the e2e tests. It
started to impact many pull requests. Time to
fix them.
2025-01-29 14:23:46 +01:00
Nathan Panchout
7880391648 ️(frontend) rollback test changes
Changes were pushed by mistake. We go back. We reorder the tests
correctly and return the environment variables to their original state.
2025-01-29 12:16:10 +01:00
Nathan Panchout
9b95a9c551 🚸(frontend) prevent duplicate invite user row in DocShareModal
Modify the DocShareModal to avoid showing the invite user row when the
email already exists in the search results, preventing redundant invite
options
2025-01-29 12:16:10 +01:00
Manuel Raynaud
3b151cf580 🍱(readme) update europe <3 opensource logo name
The name used for the logo europe<3opensource was causing trouble on
windows systems. We have to rename it with a more "normal" name.
2025-01-29 12:16:10 +01:00
virgile-deville
8b0f4db650 📝(doc) improve readme.md after v2 update
We want to serve as an example of our open source doc best practices.
We want people to find out.
- Which libraries we support
- How they can contribute translations and code
2025-01-28 20:26:40 +01:00
Nathan Panchout
a39990d90f 🚸(frontend) simplify invite user row logic in DocShareModal
Refactor the endActions logic to show invite user row when searching by
email, removing the unnecessary length check for users
2025-01-28 18:15:18 +01:00
Samuel Paccoud - DINUM
609ff91894 🚸(backend) on user search match emails by Levenstein distance
When the query looks like an email (includes @) we search by
Levenstein distance because we are just trying to prevent typing
errors, not searching anymore.

It is important to still propose results with a short Levenstein
distance because it is frequent to forget a double letter in
someone's name for example "Pacoud" or even "pacou" instead of
"Paccoud" and we want to prevent duplicates or failing on
invitation.

We consider the query string to be an email as soon as it contains
a "@" character. Trying harder to identify a string that is really
an email would lead to weird behaviors like toto@example.gouv looking
like and email but if we continue typing toto@example.gouv.f not
looking like an email... before toto@example.gouv.fr finally looking
like an email. The result would be jumping from one type of search
to the other. As soon as there is a "@" in the query, we can be
sure that the user is not looking for a name anymore and we can
switch to matching by Levenstein distance.
2025-01-28 18:15:18 +01:00
Manuel Raynaud
265a24fe7e 🚨(back) update code linting with ruff
Apply new rules provided with ruff version v0.9.3
2025-01-28 16:23:07 +01:00
renovate[bot]
9bc2b4877f ⬆️(dependencies) update python dependencies 2025-01-28 16:23:07 +01:00
Nathan Panchout
b93b43abe8 💄(frontend) improve DocsGridItem responsive padding
- Adjusted padding and alignment for desktop and mobile views
- Conditionally applied CSS styles based on screen size
2025-01-28 13:59:22 +01:00
Anthony LC
dd8bb18f69 🔊(changelog) add some changelog entries
Add some changelog entries that can be useful to
display in the release notes.
2025-01-28 13:36:03 +01:00
Anthony LC
545e8b2a3c 🔥(backend) remove code related to export pdf docx
The export is managed by the frontend, so we
don't need the code related to the export
in the backend side anymore.
2025-01-28 13:36:03 +01:00
Anthony LC
81837aff2b (frontend) export pdf docx front side
We have added the export to pdf and docx feature
to the front side. Thanks to that, the images are now
correctly exported even when the doc is private.
To be able to export the doc, the data must be
in blocknote format, for legacy purpose, we have
to convert the template to blocknote format before
exporting it.
2025-01-28 13:36:03 +01:00
lunika
40c1107959 🌐(i18n) update translated strings
Update translated files with new translations
2025-01-28 12:59:54 +01:00
Manuel Raynaud
0d7d42254b (helm) add a job allowing to run arbitrary management command
For a specific deployment we may need to run a specific management
command, like the one added previously updating all files content-type.
A template is added responsible to manage this case. The job will be
created only if the backend.job.command is set.
2025-01-28 10:33:30 +01:00
Anthony LC
67dc7feb98 🚑️(backend) command to update attachment content-type
The uploaded files in the system are missing
the content-type.
We add a command to update the content-type of
the existing uploaded files.
This command will run one time when we will deploy
to the environments.
2025-01-28 10:33:30 +01:00
Anthony LC
5b4b100e90 🏷️(backend) add content-type to uploaded files
All the uploaded files had the content-type set
to `application/octet-stream`. It create issues
when the file is downloaded from the frontend
because the browser doesn't know how to handle
the file.
We now determine the content-type of the file
and set it to the file object.
2025-01-28 10:33:30 +01:00
Anthony LC
b8be010389 🚚(helm) add posthog proxy
To contourn ads blocker, we add a proxy to the
posthog service. This way, we can access the
service from the same domain as the frontend.
2025-01-28 10:05:37 +01:00
Anthony LC
97cfa2c1ad (frontend) integrate posthog analytics
We integrate posthog, it will help us to track
user behavior and improve the product.
We get the configuration from the backend config
endpoint.
2025-01-28 10:05:37 +01:00
Anthony LC
c018c6fcf5 🔧(backend) add posthog configuration
We add the posthog configuration to the project.
We will expose the posthog configuration to the
frontend.
2025-01-28 10:05:37 +01:00
Nathan Panchout
70048328d1 (frontend) update document sharing tests
- Added Share button interactions in various document visibility
scenarios
- Updated test assertions for share and copy link functionality
- Improved test coverage for document sharing features
2025-01-28 08:59:28 +01:00
Nathan Panchout
55ddfe9181 💄(frontend) refactor document sharing and grid components
Improvements:
- Added disabled state for dropdown menus in share settings
- Updated document grid layout and responsiveness
- Simplified sharing and access count logic
- Improved tooltips and visibility of shared documents
- Created a new responsive doc grid hook
2025-01-28 08:59:28 +01:00
renovate[bot]
ee41d156c7 ⬆️(dependencies) update django to v5.1.5 [SECURITY] 2025-01-27 10:06:16 +01:00
Manuel Raynaud
5be2bc7360 ♻️(actions) create a reusable workflow to install front dependencies
In more than one workflow we need to install frontend dependencies and
this 3 workflows we are copy/pasting the same code. We want to refactor
this by creating a reusable workflow
https://docs.github.com/en/actions/sharing-automations/reusing-workflows
2025-01-24 12:22:48 +01:00
Manuel Raynaud
e46ba4f506 🌐(back) update source translations
In order to have a repo correctly confiogured to managed translation
with crowdin, source translations for the backend app are updated
2025-01-24 12:22:48 +01:00
Manuel Raynaud
7c8b969fa9 🔥(back) remove compiled translation file
The compiles translation file should not be track with git.
2025-01-24 12:22:48 +01:00
Manuel Raynaud
95515fd460 🌐(action) create a workflow to download translation
A new workflow is added responsible todownload new translated strings
and then create a pull request to update the code.
2025-01-24 12:22:48 +01:00
Manuel Raynaud
ce6cfc22ef 🌐(action) upload sources translation on crowdin
Crowdin has released its own github action to automatize translation
workflow. We want to use to upload sources when a PR is merged.
2025-01-24 12:22:48 +01:00
virgile-dev
4b3b441fc3 📝(project) update readme following upgrade to v2
Remove old mentions to "impress" following the repository renaming.
Improve and update descriptions to better reflect the status of the
project after release version 2.
2025-01-21 23:39:22 +01:00
Anthony LC
9194bf5a90 🔖(patch) release 2.0.1
Fixed:
🐛(frontend) title copy break app
2025-01-17 11:58:55 +01:00
Anthony LC
dc63a5839e ⬇️(frontend) downgraded blocknote to 0.21.0
The last version of Blocknote (0.22.0) has a bug,
when we copy paste a title, the app sometimes crashes.
Better to downgrade to 0.21.0 until the bug is fixed.
2025-01-17 11:33:47 +01:00
Anthony LC
d406846986 🎨(frontend) format css blocknote editor
We use the "css" function of style components
to format correctly the blocknote editor css.
2025-01-17 11:33:47 +01:00
Nathan Panchout
e85b07021e 🐛(frontend) fix collaboration cursor
- The collaboration slider is not fully shown when a user is at the very
top of the document
2025-01-17 11:02:41 +01:00
Nathan Panchout
282200ac3d 🐛(frontend) hide the sharing method when you don't have the rights
- Added a new hook `useCopyDocLink` to handle copying document links to
the clipboard with success/error notifications.
- Updated the `DocToolBox`, `DocsGridActions`, and `DocShareModal`
components to utilize the new copy link feature.
- Enhanced tests to verify the functionality of the copy link button in
various scenarios.
- Adjusted visibility checks for sharing options based on user access
rights.
2025-01-17 11:02:41 +01:00
Anthony LC
de8dea20d5 🔖(major) release 2.0.0
Added:
- 🔧(backend) add option to configure list of
essential OIDC claims
- 🔧(helm) add option to disable default tls
setting by @dominikkaminski
- 💄(frontend) Add left panel
- 💄(frontend) add filtering to left panel
- (frontend) new share modal ui
- (frontend) add favorite feature

Changed:
- 🏗️(yjs-server) organize yjs server
- ♻️(frontend) better separation collaboration
process
- 💄(frontend) updating the header and leftpanel
for responsive
- 💄(frontend) update DocsGrid component
- 💄(frontend) update DocsGridOptions component
- 💄(frontend) update DocHeader ui
- 💄(frontend) update doc versioning ui
- 💄(frontend) update doc summary u

Fixed:
- 🐛(backend) fix create document via s2s
if sub unknown but email found
- 🐛(frontend) hide search and create doc
button if not authenticated
- 🐛(backend) race condition creation issue
2025-01-15 12:46:00 +01:00
Anthony LC
342fc2ab59 ✏️(backend) fix read_only_fields is_favorite
is_favorite has a typo error.
This commit fixes it.
2025-01-15 12:13:40 +01:00
Anthony LC
b8132ef393 🐛(backend) creation race condition
3 requests we able to create a document:
- POST document request
- GET collaboration-auth
- GET media-auth

If the 2 last were faster than the first, a
document was created without the necessary
informations.
2025-01-15 12:13:40 +01:00
Nathan Panchout
2ede746d8a (frontend) hide search and create doc button if not logged
- Added visibility checks for 'search' and 'New doc' buttons in the
document visibility tests.
- Updated LeftPanelHeader to conditionally render 'search' and 'New doc'
buttons based on user authentication status, improving user experience
and access control.
2025-01-15 12:00:40 +01:00
Anthony LC
5bd0764bdd 🌐(frontend) add last translations
Add the missing translations FR / DE.
2025-01-14 17:38:19 +01:00
Samuel Paccoud - DINUM
610948cd16 🐛(backend) fix create document for user when sub does not match
When creating a document on behalf of a user via the server-to-server
API, a special edge case was broken that should should never happen
but happens in our OIDC federation because one of the provider modifies
the users "sub" each time they login.

We end-up with existing users for who the email matches but not the sub.
They were not correctly handled.

I made a few additional fixes and improvements to the endpoint.
2025-01-14 16:18:14 +01:00
Samuel Paccoud - DINUM
96bb99d6ec 🐛(compose) fix "port already taken" errors when starting docker compose
We have changed the project's name from "impress" to "docs" but haven't
replaced all occurrences of impress in the project because we want to be
careful of the consequences on deployments.

The name of the docker compose project was different for the "make pylint"
target. This was causing the bug error on ports. Let's rename it without
waiting.
2025-01-14 16:18:14 +01:00
244 changed files with 12416 additions and 5334 deletions

77
.github/workflows/crowdin_download.yml vendored Normal file
View File

@@ -0,0 +1,77 @@
name: Download translations from Crowdin
on:
workflow_dispatch:
push:
branches:
- 'release/**'
jobs:
install-dependencies:
uses: ./.github/workflows/dependencies.yml
with:
node_version: '20.x'
with-front-dependencies-installation: true
synchronize-with-crowdin:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Create empty source files
run: |
touch src/backend/locale/django.pot
mkdir -p src/frontend/packages/i18n/locales/impress/
touch src/frontend/packages/i18n/locales/impress/translations-crowdin.json
# crowdin workflow
- name: crowdin action
uses: crowdin/github-action@v2
with:
config: crowdin/config.yml
upload_sources: false
upload_translations: false
download_translations: true
create_pull_request: false
push_translations: false
push_sources: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# A numeric ID, found at https://crowdin.com/project/<projectName>/tools/api
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
# Visit https://crowdin.com/settings#api-key to create this token
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
CROWDIN_BASE_PATH: "../src/"
# frontend i18n
- name: Restore the frontend cache
uses: actions/cache@v4
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
fail-on-cache-miss: true
- name: generate translations files
working-directory: src/frontend
run: yarn i18n:deploy
# Create a new PR
- name: Create a new Pull Request with new translated strings
uses: peter-evans/create-pull-request@v7
with:
commit-message: |
🌐(i18n) update translated strings
Update translated files with new translations
title: 🌐(i18n) update translated strings
body: |
## Purpose
update translated strings
## Proposal
- [x] update translated strings
branch: i18n/update-translations
labels: i18n

76
.github/workflows/crowdin_upload.yml vendored Normal file
View File

@@ -0,0 +1,76 @@
name: Update crowdin sources
on:
workflow_dispatch:
push:
branches:
- main
jobs:
install-dependencies:
uses: ./.github/workflows/dependencies.yml
with:
node_version: '20.x'
with-front-dependencies-installation: true
with-build_mails: true
synchronize-with-crowdin:
needs: install-dependencies
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
# Backend i18n
- name: Install Python
uses: actions/setup-python@v3
with:
python-version: "3.12.6"
- name: Upgrade pip and setuptools
run: pip install --upgrade pip setuptools
- name: Install development dependencies
run: pip install --user .
working-directory: src/backend
- 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') }}
fail-on-cache-miss: true
- name: Install gettext
run: |
sudo apt-get update
sudo apt-get install -y gettext pandoc
- name: generate pot files
working-directory: src/backend
run: |
DJANGO_CONFIGURATION=Build python manage.py makemessages -a --keep-pot
# frontend i18n
- name: Restore the frontend cache
uses: actions/cache@v4
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
fail-on-cache-miss: true
- name: generate source translation file
working-directory: src/frontend
run: yarn i18n:extract
# crowdin workflow
- name: crowdin action
uses: crowdin/github-action@v2
with:
config: crowdin/config.yml
upload_sources: true
upload_translations: false
download_translations: false
create_pull_request: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# A numeric ID, found at https://crowdin.com/project/<projectName>/tools/api
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
# Visit https://crowdin.com/settings#api-key to create this token
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
CROWDIN_BASE_PATH: "../src/"

85
.github/workflows/dependencies.yml vendored Normal file
View File

@@ -0,0 +1,85 @@
name: Dependency reusable workflow
on:
workflow_call:
inputs:
node_version:
required: false
default: '20.x'
type: string
with-front-dependencies-installation:
type: boolean
default: false
with-build_mails:
type: boolean
default: false
jobs:
front-dependencies-installation:
if: ${{ inputs.with-front-dependencies-installation == true }}
runs-on: ubuntu-latest
steps:
- name: Checkout
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: Setup Node.js
if: steps.front-node_modules.outputs.cache-hit != 'true'
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node_version }}
- name: Install dependencies
if: steps.front-node_modules.outputs.cache-hit != 'true'
run: cd src/frontend/ && yarn install --frozen-lockfile
- name: Cache install frontend
if: steps.front-node_modules.outputs.cache-hit != 'true'
uses: actions/cache@v4
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
build-mails:
if: ${{ inputs.with-build_mails == true }}
runs-on: ubuntu-latest
defaults:
run:
working-directory: src/mail
steps:
- name: Checkout repository
uses: actions/checkout@v4
- 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: Setup Node.js
if: steps.mail-templates.outputs.cache-hit != 'true'
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node_version }}
- name: Install yarn
if: steps.mail-templates.outputs.cache-hit != 'true'
run: npm install -g yarn
- name: Install node dependencies
if: steps.mail-templates.outputs.cache-hit != 'true'
run: yarn install --frozen-lockfile
- name: Build mails
if: steps.mail-templates.outputs.cache-hit != 'true'
run: yarn build
- name: Cache mail templates
if: steps.mail-templates.outputs.cache-hit != 'true'
uses: actions/cache@v4
with:
path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }}

View File

@@ -9,39 +9,16 @@ on:
- "*"
jobs:
install-front:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20.x"
- name: Restore the frontend cache
uses: actions/cache@v4
id: front-node_modules
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
- name: Install dependencies
if: steps.front-node_modules.outputs.cache-hit != 'true'
run: cd src/frontend/ && yarn install --frozen-lockfile
- name: Cache install frontend
if: steps.front-node_modules.outputs.cache-hit != 'true'
uses: actions/cache@v4
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
install-dependencies:
uses: ./.github/workflows/dependencies.yml
with:
node_version: '20.x'
with-front-dependencies-installation: true
test-front:
needs: install-dependencies
runs-on: ubuntu-latest
needs: install-front
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -53,44 +30,54 @@ jobs:
- 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') }}
fail-on-cache-miss: true
- name: Test App
run: cd src/frontend/ && yarn test
lint-front:
runs-on: ubuntu-latest
needs: install-front
needs: install-dependencies
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20.x"
- name: Restore the frontend cache
uses: actions/cache@v4
id: front-node_modules
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
fail-on-cache-miss: true
- name: Check linting
run: cd src/frontend/ && yarn lint
test-e2e-chromium:
runs-on: ubuntu-latest
needs: install-dependencies
timeout-minutes: 20
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20.x"
- name: Restore the frontend cache
uses: actions/cache@v4
id: front-node_modules
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
fail-on-cache-miss: true
- name: Set e2e env variables
run: cat env.d/development/common.e2e.dist >> env.d/development/common.dist
@@ -141,12 +128,17 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20.x"
- name: Restore the frontend cache
uses: actions/cache@v4
id: front-node_modules
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
fail-on-cache-miss: true
- name: Set e2e env variables
run: cat env.d/development/common.e2e.dist >> env.d/development/common.dist

View File

@@ -9,6 +9,11 @@ on:
- "*"
jobs:
install-dependencies:
uses: ./.github/workflows/dependencies.yml
with:
with-build_mails: true
lint-git:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' # Makes sense only for pull requests
@@ -56,46 +61,6 @@ jobs:
exit 1
fi
build-mails:
runs-on: ubuntu-latest
defaults:
run:
working-directory: src/mail
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: "18"
- name: Restore the mail templates
uses: actions/cache@v4
id: mail-templates
with:
path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
- name: Install yarn
if: steps.mail-templates.outputs.cache-hit != 'true'
run: npm install -g yarn
- name: Install node dependencies
if: steps.mail-templates.outputs.cache-hit != 'true'
run: yarn install --frozen-lockfile
- name: Build mails
if: steps.mail-templates.outputs.cache-hit != 'true'
run: yarn build
- name: Cache mail templates
if: steps.mail-templates.outputs.cache-hit != 'true'
uses: actions/cache@v4
with:
path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
lint-back:
runs-on: ubuntu-latest
defaults:
@@ -121,7 +86,7 @@ jobs:
test-back:
runs-on: ubuntu-latest
needs: build-mails
needs: install-dependencies
defaults:
run:
@@ -169,6 +134,7 @@ jobs:
with:
path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
fail-on-cache-miss: true
- name: Start MinIO
run: |
@@ -206,10 +172,11 @@ jobs:
- name: Install development dependencies
run: pip install --user .[dev]
- name: Install gettext (required to compile messages)
- name: Install gettext (required to compile messages) and MIME support
run: |
sudo apt-get update
sudo apt-get install -y gettext pandoc
sudo apt-get install -y gettext pandoc shared-mime-info
sudo wget https://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types -O /etc/mime.types
- name: Generate a MO file from strings extracted from the project
run: python manage.py compilemessages

1
.gitignore vendored
View File

@@ -30,6 +30,7 @@ MANIFEST
.next/
# Translations # Translations
*.mo
*.pot
# Environments

View File

@@ -11,12 +11,61 @@ and this project adheres to
## Added
- 📝(doc) Add security.md and codeofconduct.md #604
- ✨(frontend) add home page #553
## Fixed
🌐(CI) Fix email partially translated #616
## [2.1.0] - 2025-01-29
## Added
- ✨(backend) add soft delete and restore API endpoints to documents #516
- ✨(backend) allow organizing documents in a tree structure #516
- ✨(backend) add "excerpt" field to document list serializer #516
- ✨(backend) add github actions to manage Crowdin workflow #559 & #563
- 📈Integrate Posthog #540
- 🏷️(backend) add content-type to uploaded files #552
- ✨(frontend) export pdf docx front side #537
## Changed
- 💄(frontend) add abilities on doc row #581
- 💄(frontend) improve DocsGridItem responsive padding #582
- 🔧(backend) Bump maximum page size to 200 #516
- 📝(doc) Improve Read me #558
## Fixed
- 🐛Fix invitations #575
## Removed
- 🔥(backend) remove "content" field from list serializer # 516
## [2.0.1] - 2025-01-17
## Fixed
-🐛(frontend) share modal is shown when you don't have the abilities #557
-🐛(frontend) title copy break app #564
## [2.0.0] - 2025-01-13
## Added
- 🔧(backend) add option to configure list of essential OIDC claims #525 & #531
- 🔧(helm) add option to disable default tls setting by @dominikkaminski #519
- 💄(frontend) Add left panel #420
- 💄(frontend) add filtering to left panel #475
- ✨(frontend) new share modal ui #489
- ✨(frontend) add favorite feature #515
- 📝(documentation) Documentation about self-hosted installation #530
- ✨(helm) helm versioning #530
## Changed
@@ -25,10 +74,16 @@ and this project adheres to
- 💄(frontend) updating the header and leftpanel for responsive #421
- 💄(frontend) update DocsGrid component #431
- 💄(frontend) update DocsGridOptions component #432
- 💄(frontend) update DocHeader ui #446
- 💄(frontend) update DocHeader ui #448
- 💄(frontend) update doc versioning ui #463
- 💄(frontend) update doc summary ui #473
- 📝(doc) update readme.md to match V2 changes #558 & #572
## Fixed
- 🐛(backend) fix create document via s2s if sub unknown but email found #543
- 🐛(frontend) hide search and create doc button if not authenticated #555
- 🐛(backend) race condition creation issue #556
## [1.10.0] - 2024-12-17
@@ -197,7 +252,7 @@ and this project adheres to
- 🛂(frontend) match email if no existing user matches the sub
- 🐛(backend) gitlab oicd userinfo endpoint #232
- 🛂(frontend) redirect to the OIDC when private doc and unauthenticated #292
- 🛂(frontend) redirect to the OIDC when private doc and unauthentified #292
- ♻️(backend) getting list of document versions available for a user #258
- 🔧(backend) fix configuration to avoid different ssl warning #297
- 🐛(frontend) fix editor break line not working #302
@@ -326,7 +381,7 @@ and this project adheres to
- ⚡️(e2e) unique login between tests (#80)
- ⚡️(CI) improve e2e job (#86)
- ♻️(frontend) improve the error and message info ui (#93)
- ✏️(frontend) change all occurrences of pad to doc (#99)
- ✏️(frontend) change all occurences of pad to doc (#99)
## Fixed
@@ -346,7 +401,10 @@ and this project adheres to
- 🚀 Impress, project to manage your documents easily and collaboratively.
[unreleased]: https://github.com/numerique-gouv/impress/compare/v1.10.0...main
[unreleased]: https://github.com/numerique-gouv/impress/compare/v2.1.0...main
[v2.1.0]: https://github.com/numerique-gouv/impress/releases/v2.1.0
[v2.0.1]: https://github.com/numerique-gouv/impress/releases/v2.0.1
[v2.0.0]: https://github.com/numerique-gouv/impress/releases/v2.0.0
[v1.10.0]: https://github.com/numerique-gouv/impress/releases/v1.10.0
[v1.9.0]: https://github.com/numerique-gouv/impress/releases/v1.9.0
[v1.8.2]: https://github.com/numerique-gouv/impress/releases/v1.8.2

75
CODE_OF_CONDUCT.md Normal file
View File

@@ -0,0 +1,75 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our community include:
- Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
- Focusing on what is best not just for us as individuals, but for the overall community
Examples of unacceptable behavior include:
- The use of sexualized language or imagery, and sexual attention or advances of any kind
- Trolling, insulting or derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or email address, without their explicit permission
- Other conduct which could reasonably be considered inappropriate in a professional setting
## Enforcement Responsibilities
- Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
- Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.
## Scope
- This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
## Enforcement
- Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at docs@numerique.gouv.fr.
- All complaints will be reviewed and investigated promptly and fairly.
- All community leaders are obligated to respect the privacy and security of the reporter of any incident.
## Enforcement Guidelines
- Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this
## Code of Conduct:
1. Correction
Community Impact: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
Consequence: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
2. Warning
Community Impact: A violation through a single incident or series of actions.
Consequence: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
3. Temporary Ban
Community Impact: A serious violation of community standards, including sustained inappropriate behavior.
Consequence: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
4. Permanent Ban
Community Impact: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
Consequence: A permanent ban from any sort of public interaction within the community.
Attribution
This Code of Conduct is adapted from the Contributor Covenant, version 2.1, available at https://www.contributor-covenant.org/version/2/1/code_of_conduct.html.
Community Impact Guidelines were inspired by Mozilla's code of conduct enforcement ladder.
For answers to common questions about this code of conduct, see the FAQ at https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations.

View File

@@ -2,7 +2,14 @@
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.
To get started with the project, please refer to the [README.md](https://github.com/suitenumerique/docs/blob/main/README.md) for detailed instructions.
Please also check out our [dev handbook](https://suitenumerique.gitbook.io/handbook) to learn our best practices.
## Help us with translations
You can help us with translations on [Crowdin](https://crowdin.com/project/lasuite-docs).
Your language is not there? Request it on our Crowdin page 😊.
## Creating an Issue

View File

@@ -51,7 +51,7 @@ COPY ./src/backend /app/
WORKDIR /app
# collectstatic
RUN DJANGO_CONFIGURATION=Build DJANGO_JWT_PRIVATE_SIGNING_KEY=Dummy \
RUN DJANGO_CONFIGURATION=Build \
python manage.py collectstatic --noinput
# Replace duplicated file by a symlink to decrease the overall size of the
@@ -72,10 +72,11 @@ RUN apk add \
gettext \
gdk-pixbuf \
libffi-dev \
pandoc \
pango \
shared-mime-info
RUN wget https://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types -O /etc/mime.types
# Copy entrypoint
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
@@ -92,6 +93,11 @@ COPY ./src/backend /app/
WORKDIR /app
# Generate compiled translation messages
RUN DJANGO_CONFIGURATION=Build \
python manage.py compilemessages
# We wrap commands run in this container by the following entrypoint that
# creates a user on-the-fly with the container user ID (see USER) and root group
# ID.

171
README.md
View File

@@ -1,113 +1,184 @@
# Impress
<p align="center">
<a href="https://github.com/suitenumerique/docs">
<img alt="Docs" src="/docs/assets/docs-logo.png" width="300" />
</a>
</p>
Impress is a web application for real-time collaborative text editing with user and role based access rights.
Features include :
- User authentication through OIDC
- BlocNote.js text editing experience (markdown support, dynamic conversion, block structure, slash commands for block creation)
- Document export to pdf and docx from predefined templates
- Granular document permissions
- Public link sharing
- Offline mode
<p align="center">
Welcome to Docs! The open source document editor where your notes can become knowledge through live collaboration
</p>
Impress is built on top of [Django Rest Framework](https://www.django-rest-framework.org/), [Next.js](https://nextjs.org/) and [BlocNote.js](https://www.blocknotejs.org/)
<p align="center">
<a href="https://matrix.to/#/#docs-official:matrix.org">
Chat on Matrix
</a> - <a href="/docs/">
Documentation
</a> - <a href="#getting-started-">
Getting started
</a> - <a href="mailto:docs@numerique.gouv.fr">
Reach out
</a>
</p>
## Getting started
<img src="/docs/assets/docs_live_collaboration_light.gif" width="100%" align="center"/>
### Prerequisite
## Why use Docs ❓
Docs is a collaborative text editor designed to address common challenges in knowledge building and sharing.
Make sure you have a recent version of Docker and [Docker
Compose](https://docs.docker.com/compose/install) installed on your laptop:
### Write
* 😌 Simple collaborative editing without the formatting complexity of markdown
* 🔌 Offline? No problem, keep writing, your edits will get synced when back online
* 💅 Create clean documents with limited but beautiful formatting options and focus on content
* 🧱 Built for productivity (markdown support, many block types, slash commands, keyboard shortcuts).
* ✨ Save time thanks to our AI actions (generate, sum up, correct, translate)
```bash
### Collaborate
* 🤝 Collaborate in realtime with your team mates
* 🔒 Granular access control to keep your information secure and shared with the right people
* 📑 Professional document exports in multiple formats (.odt, .doc, .pdf) with customizable templates
* 📚 Built-in wiki functionality to transform your team's collaborative work into organized knowledge `ETA 02/2025`
### Self-host
* 🚀 Easy to install, scalable and secure alternative to Notion, Outline or Confluence
## Getting started 🔧
### Test it
Test Docs on your browser by logging in on this [environment](https://impress-preprod.beta.numerique.gouv.fr/docs/0aa856e9-da41-4d59-b73d-a61cb2c1245f/)
```
email: test.docs@yopmail.com
password: I'd<3ToTestDocs
```
### Run it locally
**Prerequisite**
Make sure you have a recent version of Docker and [Docker Compose](https://docs.docker.com/compose/install) installed on your laptop:
```shellscript
$ docker -v
Docker version 20.10.2, build 2291f61
Docker version 20.10.2, build 2291f61
$ docker compose -v
docker compose version 1.27.4, build 40524192
docker compose version 1.27.4, build 40524192
```
> ⚠️ You may need to run the following commands with `sudo` but this can be
> avoided by assigning your user to the `docker` group.
### Project bootstrap
> ⚠️ You may need to run the following commands with sudo but this can be avoided by adding your user to the `docker` group.
**Project bootstrap**
The easiest way to start working on the project is to use GNU Make:
```bash
```shellscript
$ make bootstrap FLUSH_ARGS='--no-input'
```
This command builds the `app` container, installs dependencies, performs
database migrations and compile translations. It's a good idea to use this
command each time you are pulling code from the project repository to avoid
dependency-releated or migration-releated issues.
This command builds the `app` container, installs dependencies, performs database migrations and compile translations. It's a good idea to use this
command each time you are pulling code from the project repository to avoid dependency-related or migration-related issues.
Your Docker services should now be up and running 🎉
You can access to the project by going to http://localhost:3000.
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
```shellscript
$ make run-with-frontend
```
---
⚠️ For the frontend developer, it is often better to run the frontend in development mode locally.
⚠️ 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
```shellscript
$ make frontend-install
```
And run the frontend locally in development mode with the following command:
```bash
```shellscript
$ make run-frontend-development
```
To start all the services, except the frontend container, you can use the following command:
```bash
```shellscript
$ make run
```
---
### Adding content
**Adding content**
You can create a basic demo site by running:
$ make demo
```shellscript
$ make demo
```
Finally, you can check all available Make rules using:
```bash
```shellscript
$ make help
```
### Django admin
**Django admin**
You can access the Django admin site at
[http://localhost:8071/admin](http://localhost:8071/admin).
<http://localhost:8071/admin>.
You first need to create a superuser account:
```bash
```shellscript
$ make superuser
```
## Contributing
## Feedback 🙋‍♂️🙋‍♀️
We'd love to hear your thoughts and hear about your experiments, so come and say hi on [Matrix](https://matrix.to/#/#docs-official:matrix.org).
This project is intended to be community-driven, so please, do not hesitate to
get in touch if you have any question related to our implementation or design
decisions.
## Roadmap
Want to know where the project is headed? [🗺️ Checkout our roadmap](https://github.com/orgs/numerique-gouv/projects/13/views/11)
## License
## Licence 📝
This work is released under the MIT License (see [LICENSE](https://github.com/suitenumerique/docs/blob/main/LICENSE)).
This work is released under the MIT License (see [LICENSE](./LICENSE)).
While Docs is a public driven initiative our licence choice is an invitation for private sector actors to use, sell and contribute to the project.
## Contributing 🙌
This project is intended to be community-driven, so please, do not hesitate to [get in touch](https://matrix.to/#/#docs-official:matrix.org) if you have any question related to our implementation or design decisions.
You can help us with translations on [Crowdin](https://crowdin.com/project/lasuite-docs).
If you intend to make pull requests see [CONTRIBUTING](https://github.com/suitenumerique/docs/blob/main/CONTRIBUTING.md) for guidelines.
Directory structure:
```markdown
docs
├── bin - executable scripts or binaries that are used for various tasks, such as setup scripts, utility scripts, or custom commands.
├── crowdin - for crowdin translations, a tool or service that helps manage translations for the project.
├── docker - Dockerfiles and related configuration files used to build Docker images for the project. These images can be used for development, testing, or production environments.
├── docs - documentation for the project, including user guides, API documentation, and other helpful resources.
├── env.d/development - environment-specific configuration files for the development environment. These files might include environment variables, configuration settings, or other setup files needed for development.
├── gitlint - configuration files for `gitlint`, a tool that enforces commit message guidelines to ensure consistency and quality in commit messages.
├── playground - experimental or temporary code, where developers can test new features or ideas without affecting the main codebase.
└── src - main source code directory, containing the core application code, libraries, and modules of the project.
```
## Credits ❤️
### Stack
Docs is built on top of [Django Rest Framework](https://www.django-rest-framework.org/), [Next.js](https://nextjs.org/), [MinIO](https://min.io/), [BlockNote.js](https://www.blocknotejs.org/), [HocusPocus](https://tiptap.dev/docs/hocuspocus/introduction) and [Yjs](https://yjs.dev/)
### Gov ❤️ open source
Docs is the result of a joint effort led by the French 🇫🇷🥖 ([DINUM](https://www.numerique.gouv.fr/dinum/)) and German 🇩🇪🥨 governments ([ZenDiS](https://zendis.de/)).
We are proud sponsors of [BlockNotejs](https://www.blocknotejs.org/) and [Yjs](https://yjs.dev/).
We are always looking for new public partners (we are currently onboarding the Netherlands 🇳🇱🧀), feel free to [reach out](mailto:docs@numerique.gouv.fr) if you are interested in using or contributing to Docs.
<p align="center">
<img src="/docs/assets/europe_opensource.png" width="50%"/>
</p>

23
SECURITY.md Normal file
View File

@@ -0,0 +1,23 @@
# Security Policy
## Reporting a Vulnerability
Security is very important to us.
If you have any issue regarding security, please disclose the information responsibly submiting [this form](https://vdp.numerique.gouv.fr/p/Send-a-report?lang=en) and not by creating an issue on the repository. You can also email us at docs@numerique.gouv.fr
We appreciate your effort to make Docs more secure.
## Vulnerability disclosure policy
Working with security issues in an open source project can be challenging, as we are required to disclose potential problems that could be exploited by attackers. With this in mind, our security fix policy is as follows:
1. The Maintainers team will handle the fix as usual (Pull Request,
release).
2. In the release notes, we will include the identification numbers from the
GitHub Advisory Database (GHSA) and, if applicable, the Common Vulnerabilities
and Exposures (CVE) identifier for the vulnerability.
3. Once this grace period has passed, we will publish the vulnerability.
By adhering to this security policy, we aim to address security concerns
effectively and responsibly in our open source software project.

View File

@@ -7,7 +7,7 @@ UNSET_USER=0
TERRAFORM_DIRECTORY="./env.d/terraform"
COMPOSE_FILE="${REPO_DIR}/docker-compose.yml"
COMPOSE_PROJECT="impress"
COMPOSE_PROJECT="docs"
# _set_user: set (or unset) default user id used to run docker commands

View File

@@ -1,7 +1,7 @@
#
# Your crowdin's credentials
#
api_token_env: CROWDIN_API_TOKEN
api_token_env: CROWDIN_PERSONAL_TOKEN
project_id_env: CROWDIN_PROJECT_ID
base_path_env: CROWDIN_BASE_PATH
@@ -15,11 +15,11 @@ preserve_hierarchy: true
# Files configuration
#
files: [
{
source : "/backend/locale/django.pot",
dest: "/backend-impress.pot",
translation : "/backend/locale/%locale_with_underscore%/LC_MESSAGES/django.po"
},
{
source : "/backend/locale/django.pot",
dest: "/backend-impress.pot",
translation : "/backend/locale/%locale_with_underscore%/LC_MESSAGES/django.po"
},
{
source: "/frontend/packages/i18n/locales/impress/translations-crowdin.json",
dest: "/frontend-impress.json",

View File

@@ -151,7 +151,7 @@ services:
image: node:18
user: "${DOCKER_USER:-1000}"
environment:
HOME: /tmp
HOME: /tmp
volumes:
- ".:/app"

BIN
docs/assets/docs-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

BIN
docs/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

@@ -40,6 +40,7 @@ backend:
LOGIN_REDIRECT_URL: https://impress.127.0.0.1.nip.io
LOGIN_REDIRECT_URL_FAILURE: https://impress.127.0.0.1.nip.io
LOGOUT_REDIRECT_URL: https://impress.127.0.0.1.nip.io
POSTHOG_KEY: "{'id': 'posthog_key', 'host': 'https://product.impress.127.0.0.1.nip.io'}"
DB_HOST: postgresql
DB_NAME: impress
DB_USER: dinum
@@ -121,6 +122,12 @@ yProvider:
COLLABORATION_SERVER_SECRET: my-secret
Y_PROVIDER_API_KEY: my-secret
posthog:
ingress:
enabled: false
ingressAssets:
enabled: false
ingress:
enabled: true
host: impress.127.0.0.1.nip.io

View File

@@ -6,7 +6,7 @@ Whenever we are cooking a new release (e.g. `4.18.1`) we should follow a standar
2. Bump the release number for backend project, frontend projects, and Helm files:
- for backend, update the version number by hand in `pyproject.toml`,
- for each project (`src/frontend`, `src/frontend/apps/*`, `src/frontend/packages/*`, `src/mail`), run `yarn version --new-version --no-git-tag-version 4.18.1` in their directory. This will update their `package.json` for you,
- for each projects (`src/frontend`, `src/frontend/apps/*`, `src/frontend/packages/*`, `src/mail`), run `yarn version --new-version --no-git-tag-version 4.18.1` in their directory. This will update their `package.json` for you,
- for Helm, update Docker image tag in files located at `src/helm/env.d` for both `preprod` and `production` environments:
```yaml

View File

@@ -1,3 +1,3 @@
CROWDIN_API_TOKEN=Your-Api-Token
CROWDIN_PERSONAL_TOKEN=Your-Personal-Token
CROWDIN_PROJECT_ID=Your-Project-Id
CROWDIN_BASE_PATH=/app/src

View File

@@ -14,10 +14,15 @@
"groupName": "ignored js dependencies",
"matchManagers": ["npm"],
"matchPackageNames": [
"@openfun/cunningham-react",
"@types/react",
"@types/react-dom",
"eslint",
"fetch-mock",
"node",
"node-fetch",
"eslint",
"react",
"react-dom",
"workbox-webpack-plugin"
]
}

View File

@@ -4,12 +4,16 @@ from django.contrib import admin
from django.contrib.auth import admin as auth_admin
from django.utils.translation import gettext_lazy as _
from treebeard.admin import TreeAdmin
from treebeard.forms import movenodeform_factory
from . import models
class TemplateAccessInline(admin.TabularInline):
"""Inline admin class for template accesses."""
autocomplete_fields = ["user"]
model = models.TemplateAccess
extra = 0
@@ -111,14 +115,47 @@ class TemplateAdmin(admin.ModelAdmin):
class DocumentAccessInline(admin.TabularInline):
"""Inline admin class for template accesses."""
autocomplete_fields = ["user"]
model = models.DocumentAccess
extra = 0
@admin.register(models.Document)
class DocumentAdmin(admin.ModelAdmin):
class DocumentAdmin(TreeAdmin):
"""Document admin interface declaration."""
fieldsets = (
(
None,
{
"fields": (
"id",
"title",
)
},
),
(
_("Permissions"),
{
"fields": (
"creator",
"link_reach",
"link_role",
)
},
),
(
_("Tree structure"),
{
"fields": (
"path",
"depth",
"numchild",
)
},
),
)
form = movenodeform_factory(models.Document)
inlines = (DocumentAccessInline,)
list_display = (
"id",
@@ -128,6 +165,14 @@ class DocumentAdmin(admin.ModelAdmin):
"created_at",
"updated_at",
)
readonly_fields = (
"creator",
"depth",
"id",
"numchild",
"path",
)
search_fields = ("id", "title")
@admin.register(models.Invitation)

View File

@@ -24,7 +24,7 @@ class DocumentFilter(django_filters.FilterSet):
class Meta:
model = models.Document
fields = ["is_creator_me", "is_favorite", "link_reach", "title"]
fields = ["is_creator_me", "is_favorite", "title"]
# pylint: disable=unused-argument
def filter_is_creator_me(self, queryset, name, value):
@@ -63,7 +63,4 @@ class DocumentFilter(django_filters.FilterSet):
if not user.is_authenticated:
return queryset
if value:
return queryset.filter(favorited_by_users__user=user)
return queryset.exclude(favorited_by_users__user=user)
return queryset.filter(is_favorite=bool(value))

View File

@@ -2,13 +2,15 @@
from django.core import exceptions
from django.db.models import Q
from django.http import Http404
from rest_framework import permissions
from core.models import DocumentAccess, RoleChoices
from core.models import DocumentAccess, RoleChoices, get_trashbin_cutoff
ACTION_FOR_METHOD_TO_PERMISSION = {
"versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"}
"versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"},
"children": {"GET": "children_list", "POST": "children_create"},
}
@@ -109,3 +111,26 @@ class AccessPermission(permissions.BasePermission):
except KeyError:
pass
return abilities.get(action, False)
class DocumentAccessPermission(AccessPermission):
"""Subclass to handle soft deletion specificities."""
def has_object_permission(self, request, view, obj):
"""
Return a 404 on deleted documents
- for which the trashbin cutoff is past
- for which the current user is not owner of the document or one of its ancestors
"""
if (
deleted_at := obj.ancestors_deleted_at
) and deleted_at < get_trashbin_cutoff():
raise Http404
# Compute permission first to ensure the "user_roles" attribute is set
has_permission = super().has_object_permission(request, view, obj)
if obj.ancestors_deleted_at and not RoleChoices.OWNER in obj.user_roles:
raise Http404
return has_permission

View File

@@ -147,34 +147,54 @@ class ListDocumentSerializer(BaseResourceSerializer):
is_favorite = serializers.BooleanField(read_only=True)
nb_accesses = serializers.IntegerField(read_only=True)
user_roles = serializers.SerializerMethodField(read_only=True)
class Meta:
model = models.Document
fields = [
"id",
"abilities",
"content",
"created_at",
"creator",
"depth",
"excerpt",
"is_favorite",
"link_role",
"link_reach",
"nb_accesses",
"numchild",
"path",
"title",
"updated_at",
"user_roles",
]
read_only_fields = [
"id",
"abilities",
"created_at",
"creator",
"depth",
"excerpt",
"is_favorite",
"link_role",
"link_reach",
"nb_accesses",
"numchild",
"path",
"updated_at",
"user_roles",
]
def get_user_roles(self, document):
"""
Return roles of the logged-in user for the current document,
taking into account ancestors.
"""
request = self.context.get("request")
if request:
return document.get_roles(request.user)
return []
class DocumentSerializer(ListDocumentSerializer):
"""Serialize documents with all fields for display in detail views."""
@@ -189,23 +209,32 @@ class DocumentSerializer(ListDocumentSerializer):
"content",
"created_at",
"creator",
"depth",
"excerpt",
"is_favorite",
"link_role",
"link_reach",
"nb_accesses",
"numchild",
"path",
"title",
"updated_at",
"user_roles",
]
read_only_fields = [
"id",
"abilities",
"created_at",
"creator",
"is_avorite",
"depth",
"is_favorite",
"link_role",
"link_reach",
"nb_accesses",
"numchild",
"path",
"updated_at",
"user_roles",
]
def get_fields(self):
@@ -264,13 +293,17 @@ class ServerCreateDocumentSerializer(serializers.Serializer):
"""Create the document and associate it with the user or send an invitation."""
language = validated_data.get("language", settings.LANGUAGE_CODE)
# Get the user based on the sub (unique identifier)
# Get the user on its sub (unique identifier). Default on email if allowed in settings
email = validated_data["email"]
try:
user = models.User.objects.get(sub=validated_data["sub"])
except (models.User.DoesNotExist, KeyError):
user = None
email = validated_data["email"]
else:
user = models.User.objects.get_user_by_sub_or_email(
validated_data["sub"], email
)
except models.DuplicateEmailError as err:
raise serializers.ValidationError({"email": [err.message]}) from err
if user:
email = user.email
language = user.language or language
@@ -279,9 +312,11 @@ class ServerCreateDocumentSerializer(serializers.Serializer):
validated_data["content"]
)
except ConversionError as err:
raise exceptions.APIException(detail="could not convert content") from err
raise serializers.ValidationError(
{"content": ["Could not convert content"]}
) from err
document = models.Document.objects.create(
document = models.Document.add_root(
title=validated_data["title"],
content=document_content,
creator=user,
@@ -302,7 +337,11 @@ class ServerCreateDocumentSerializer(serializers.Serializer):
role=models.RoleChoices.OWNER,
)
# Notify the user about the newly created document
self._send_email_notification(document, validated_data, email, language)
return document
def _send_email_notification(self, document, validated_data, email, language):
"""Notify the user about the newly created document."""
subject = validated_data.get("subject") or _(
"A new document was created on your behalf!"
)
@@ -313,8 +352,6 @@ class ServerCreateDocumentSerializer(serializers.Serializer):
}
document.send_email(subject, [email], context, language)
return document
def update(self, instance, validated_data):
"""
This serializer does not support updates.
@@ -380,6 +417,7 @@ class FileUploadSerializer(serializers.Serializer):
raise serializers.ValidationError("Could not determine file extension.")
self.context["expected_extension"] = extension
self.context["content_type"] = magic_mime_type
return file
@@ -387,6 +425,7 @@ class FileUploadSerializer(serializers.Serializer):
"""Override validate to add the computed extension to validated_data."""
attrs["expected_extension"] = self.context["expected_extension"]
attrs["is_unsafe"] = self.context["is_unsafe"]
attrs["content_type"] = self.context["content_type"]
return attrs
@@ -529,3 +568,37 @@ class AITranslateSerializer(serializers.Serializer):
if len(value.strip()) == 0:
raise serializers.ValidationError("Text field cannot be empty.")
return value
class MoveDocumentSerializer(serializers.Serializer):
"""
Serializer for validating input data to move a document within the tree structure.
Fields:
- target_document_id (UUIDField): The ID of the target parent document where the
document should be moved. This field is required and must be a valid UUID.
- position (ChoiceField): Specifies the position of the document in relation to
the target parent's children.
Choices:
- "first-child": Place the document as the first child of the target parent.
- "last-child": Place the document as the last child of the target parent (default).
- "left": Place the document as the left sibling of the target parent.
- "right": Place the document as the right sibling of the target parent.
Example:
Input payload for moving a document:
{
"target_document_id": "123e4567-e89b-12d3-a456-426614174000",
"position": "first-child"
}
Notes:
- The `target_document_id` is mandatory.
- The `position` defaults to "last-child" if not provided.
"""
target_document_id = serializers.UUIDField(required=True)
position = serializers.ChoiceField(
choices=enums.MoveNodePositionChoices.choices,
default=enums.MoveNodePositionChoices.LAST_CHILD,
)

View File

@@ -11,6 +11,29 @@ import botocore
from rest_framework.throttling import BaseThrottle
def filter_root_paths(paths, skip_sorting=False):
"""
Filters root paths from a list of paths representing a tree structure.
A root path is defined as a path that is not a prefix of any other path.
Args:
paths (list of str): The list of paths.
Returns:
list of str: The filtered list of root paths.
"""
if not skip_sorting:
paths.sort()
root_paths = []
for path in paths:
# If the current path is not a prefix of the last added root path, add it
if not root_paths or not path.startswith(root_paths[-1]):
root_paths.append(path)
return root_paths
def generate_s3_authorization_headers(key):
"""
Generate authorization headers for an s3 object.

View File

@@ -8,24 +8,20 @@ from urllib.parse import urlparse
from django.conf import settings
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.contrib.postgres.search import TrigramSimilarity
from django.core.exceptions import ValidationError
from django.core.files.storage import default_storage
from django.db import models as db
from django.db.models import (
Count,
Exists,
OuterRef,
Q,
Subquery,
Value,
)
from django.db import transaction
from django.db.models.expressions import RawSQL
from django.db.models.functions import Left, Length
from django.http import Http404
import rest_framework as drf
from botocore.exceptions import ClientError
from django_filters import rest_framework as drf_filters
from rest_framework import filters, status
from rest_framework import filters, status, viewsets
from rest_framework import response as drf_response
from rest_framework.permissions import AllowAny
@@ -52,7 +48,7 @@ COLLABORATION_WS_URL_PATTERN = re.compile(rf"(?:^|&)room=(?P<pk>{UUID_REGEX})(?:
# pylint: disable=too-many-ancestors
class NestedGenericViewSet(drf.viewsets.GenericViewSet):
class NestedGenericViewSet(viewsets.GenericViewSet):
"""
A generic Viewset aims to be used in a nested route context.
e.g: `/api/v1.0/resource_1/<resource_1_pk>/resource_2/<resource_2_pk>/`
@@ -112,35 +108,41 @@ class SerializerPerActionMixin:
This mixin is useful to avoid to define a serializer class for each action in the
`get_serializer_class` method.
"""
serializer_classes: dict[str, type] = {}
default_serializer_class: type = None
Example:
```
class MyViewSet(SerializerPerActionMixin, viewsets.GenericViewSet):
serializer_class = MySerializer
list_serializer_class = MyListSerializer
retrieve_serializer_class = MyRetrieveSerializer
```
"""
def get_serializer_class(self):
"""
Return the serializer class to use depending on the action.
"""
return self.serializer_classes.get(self.action, self.default_serializer_class)
if serializer_class := getattr(self, f"{self.action}_serializer_class", None):
return serializer_class
return super().get_serializer_class()
class Pagination(drf.pagination.PageNumberPagination):
"""Pagination to display no more than 100 objects per page sorted by creation date."""
ordering = "-created_on"
max_page_size = 100
max_page_size = 200
page_size_query_param = "page_size"
class UserViewSet(
drf.mixins.UpdateModelMixin, drf.viewsets.GenericViewSet, drf.mixins.ListModelMixin
drf.mixins.UpdateModelMixin, viewsets.GenericViewSet, drf.mixins.ListModelMixin
):
"""User ViewSet"""
permission_classes = [permissions.IsSelf]
queryset = models.User.objects.all()
serializer_class = serializers.UserSerializer
ordering = ["-created_at"]
def get_queryset(self):
"""
@@ -151,29 +153,35 @@ class UserViewSet(
"""
queryset = self.queryset
if self.action == "list":
# Exclude all users already in the given document
if document_id := self.request.GET.get("document_id", ""):
queryset = queryset.exclude(documentaccess__document_id=document_id)
if self.action != "list":
return queryset
# 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)
# Exclude all users already in the given document
if document_id := self.request.GET.get("document_id", ""):
queryset = queryset.exclude(documentaccess__document_id=document_id)
queryset = queryset.annotate(
similarity=TrigramSimilarity("email", query)
if not (query := self.request.GET.get("q", "")):
return queryset
# For emails, match emails by Levenstein distance to prevent typing errors
if "@" in query:
return (
queryset.annotate(
distance=RawSQL("levenshtein(email::text, %s::text)", (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
.filter(distance__lte=3)
.order_by("distance", "email")
)
queryset = queryset.filter(similarity__gt=threshold).order_by(
"-similarity", "email"
)
return queryset
# Use trigram similarity for non-email-like queries
# For performance reasons we filter first by similarity, which relies on an
# index, then only calculate precise similarity scores for sorting purposes
return (
queryset.filter(email__trigram_word_similar=query)
.annotate(similarity=TrigramSimilarity("email", query))
.filter(similarity__gt=0.2)
.order_by("-similarity", "email")
)
@drf.decorators.action(
detail=False,
@@ -302,100 +310,241 @@ class DocumentMetadata(drf.metadata.SimpleMetadata):
return simple_metadata
# pylint: disable=too-many-public-methods
class DocumentViewSet(
SerializerPerActionMixin,
drf.mixins.CreateModelMixin,
drf.mixins.DestroyModelMixin,
drf.mixins.ListModelMixin,
drf.mixins.UpdateModelMixin,
drf.viewsets.GenericViewSet,
viewsets.GenericViewSet,
):
"""
Document ViewSet for managing documents.
DocumentViewSet API.
Provides endpoints for creating, updating, and deleting documents,
along with filtering options.
This view set provides CRUD operations and additional actions for managing documents.
Supports filtering, ordering, and annotations for enhanced querying capabilities.
Filtering:
### API Endpoints:
1. **List**: Retrieve a paginated list of documents.
Example: GET /documents/?page=2
2. **Retrieve**: Get a specific document by its ID.
Example: GET /documents/{id}/
3. **Create**: Create a new document.
Example: POST /documents/
4. **Update**: Update a document by its ID.
Example: PUT /documents/{id}/
5. **Delete**: Soft delete a document by its ID.
Example: DELETE /documents/{id}/
### Additional Actions:
1. **Trashbin**: List soft deleted documents for a document owner
Example: GET /documents/{id}/trashbin/
2. **Children**: List or create child documents.
Example: GET, POST /documents/{id}/children/
3. **Versions List**: Retrieve version history of a document.
Example: GET /documents/{id}/versions/
4. **Version Detail**: Get or delete a specific document version.
Example: GET, DELETE /documents/{id}/versions/{version_id}/
5. **Favorite**: Get list of favorite documents for a user. Mark or unmark
a document as favorite.
Examples:
- GET /documents/favorite/
- POST, DELETE /documents/{id}/favorite/
6. **Create for Owner**: Create a document via server-to-server on behalf of a user.
Example: POST /documents/create-for-owner/
7. **Link Configuration**: Update document link configuration.
Example: PUT /documents/{id}/link-configuration/
8. **Attachment Upload**: Upload a file attachment for the document.
Example: POST /documents/{id}/attachment-upload/
9. **Media Auth**: Authorize access to document media.
Example: GET /documents/media-auth/
10. **Collaboration Auth**: Authorize access to the collaboration server for a document.
Example: GET /documents/collaboration-auth/
11. **AI Transform**: Apply a transformation action on a piece of text with AI.
Example: POST /documents/{id}/ai-transform/
Expected data:
- text (str): The input text.
- action (str): The transformation type, one of [prompt, correct, rephrase, summarize].
Returns: JSON response with the processed text.
Throttled by: AIDocumentRateThrottle, AIUserRateThrottle.
12. **AI Translate**: Translate a piece of text with AI.
Example: POST /documents/{id}/ai-translate/
Expected data:
- text (str): The input text.
- language (str): The target language, chosen from settings.LANGUAGES.
Returns: JSON response with the translated text.
Throttled by: AIDocumentRateThrottle, AIUserRateThrottle.
### Ordering: created_at, updated_at, is_favorite, title
Example:
- Ascending: GET /api/v1.0/documents/?ordering=created_at
- Desceding: GET /api/v1.0/documents/?ordering=-title
### Filtering:
- `is_creator_me=true`: Returns documents created by the current user.
- `is_creator_me=false`: Returns documents created by other users.
- `is_favorite=true`: Returns documents marked as favorite by the current user
- `is_favorite=false`: Returns documents not marked as favorite by the current user
- `title=hello`: Returns documents which title contains the "hello" string
Example Usage:
Example:
- GET /api/v1.0/documents/?is_creator_me=true&is_favorite=true
- GET /api/v1.0/documents/?is_creator_me=false&title=hello
### Annotations:
1. **is_favorite**: Indicates whether the document is marked as favorite by the current user.
2. **user_roles**: Roles the current user has on the document or its ancestors.
### Notes:
- Only the highest ancestor in a document hierarchy is shown in list views.
- Implements soft delete logic to retain document tree structures.
"""
filter_backends = [drf_filters.DjangoFilterBackend, filters.OrderingFilter]
filter_backends = [drf_filters.DjangoFilterBackend]
filterset_class = DocumentFilter
metadata_class = DocumentMetadata
ordering = ["-updated_at"]
ordering_fields = ["created_at", "is_favorite", "updated_at", "title"]
ordering_fields = ["created_at", "updated_at", "title"]
permission_classes = [
permissions.AccessPermission,
permissions.DocumentAccessPermission,
]
queryset = models.Document.objects.all()
serializer_class = serializers.DocumentSerializer
list_serializer_class = serializers.ListDocumentSerializer
trashbin_serializer_class = serializers.ListDocumentSerializer
children_serializer_class = serializers.ListDocumentSerializer
ai_translate_serializer_class = serializers.AITranslateSerializer
def get_serializer_class(self):
def annotate_is_favorite(self, queryset):
"""
Use ListDocumentSerializer for list actions, otherwise use DocumentSerializer.
Annotate document queryset with the favorite status for the current user.
"""
if self.action == "list":
return serializers.ListDocumentSerializer
return self.serializer_class
def get_queryset(self):
"""Optimize queryset to include favorite status for the current user."""
queryset = super().get_queryset()
user = self.request.user
# Annotate the number of accesses associated with each document
queryset = queryset.annotate(nb_accesses=Count("accesses", distinct=True))
if not user.is_authenticated:
# If the user is not authenticated, annotate `is_favorite` as False
return queryset.annotate(is_favorite=Value(False))
# Annotate the queryset to indicate if the document is favorited by the current user
favorite_exists = models.DocumentFavorite.objects.filter(
document_id=OuterRef("pk"), user=user
)
queryset = queryset.annotate(is_favorite=Exists(favorite_exists))
# Annotate the queryset with the logged-in user roles
user_roles_query = (
models.DocumentAccess.objects.filter(
Q(user=user) | Q(team__in=user.teams),
document_id=OuterRef("pk"),
)
.values("document")
.annotate(roles_array=ArrayAgg("role"))
.values("roles_array")
)
return queryset.annotate(user_roles=Subquery(user_roles_query)).distinct()
def list(self, request, *args, **kwargs):
"""Restrict resources returned by the list endpoint"""
queryset = self.filter_queryset(self.get_queryset())
user = self.request.user
if user.is_authenticated:
queryset = queryset.filter(
db.Q(accesses__user=user)
| db.Q(accesses__team__in=user.teams)
| (
db.Q(link_traces__user=user)
& ~db.Q(link_reach=models.LinkReachChoices.RESTRICTED)
favorite_exists_subquery = models.DocumentFavorite.objects.filter(
document_id=db.OuterRef("pk"), user=user
)
return queryset.annotate(is_favorite=db.Exists(favorite_exists_subquery))
return queryset.annotate(is_favorite=db.Value(False))
def annotate_user_roles(self, queryset):
"""
Annotate document queryset with the roles of the current user
on the document or its ancestors.
"""
user = self.request.user
output_field = ArrayField(base_field=db.CharField())
if user.is_authenticated:
user_roles_subquery = models.DocumentAccess.objects.filter(
db.Q(user=user) | db.Q(team__in=user.teams),
document__path=Left(db.OuterRef("path"), Length("document__path")),
).values_list("role", flat=True)
return queryset.annotate(
user_roles=db.Func(
user_roles_subquery, function="ARRAY", output_field=output_field
)
)
else:
queryset = queryset.none()
return queryset.annotate(
user_roles=db.Value([], output_field=output_field),
)
def get_queryset(self):
"""Get queryset performing all annotation and filtering on the document tree structure."""
user = self.request.user
queryset = super().get_queryset()
# Only list views need filtering and annotation
if self.detail:
return queryset
if not user.is_authenticated:
return queryset.none()
queryset = queryset.filter(ancestors_deleted_at__isnull=True)
# Filter documents to which the current user has access...
access_documents_ids = models.DocumentAccess.objects.filter(
db.Q(user=user) | db.Q(team__in=user.teams)
).values_list("document_id", flat=True)
# ...or that were previously accessed and are not restricted
traced_documents_ids = models.LinkTrace.objects.filter(user=user).values_list(
"document_id", flat=True
)
return queryset.filter(
db.Q(id__in=access_documents_ids)
| (
db.Q(id__in=traced_documents_ids)
& ~db.Q(link_reach=models.LinkReachChoices.RESTRICTED)
)
)
def filter_queryset(self, queryset):
"""Apply annotations and filters sequentially."""
filterset = DocumentFilter(
self.request.GET, queryset=queryset, request=self.request
)
filterset.is_valid()
filter_data = filterset.form.cleaned_data
# Filter as early as possible on fields that are available on the model
for field in ["is_creator_me", "title"]:
queryset = filterset.filters[field].filter(queryset, filter_data[field])
queryset = self.annotate_user_roles(queryset)
if self.action == "list":
# Among the results, we may have documents that are ancestors/descendants
# of each other. In this case we want to keep only the highest ancestors.
root_paths = utils.filter_root_paths(
queryset.order_by("path").values_list("path", flat=True),
skip_sorting=True,
)
queryset = queryset.filter(path__in=root_paths)
# Annotate the queryset with an attribute marking instances as highest ancestor
# in order to save some time while computing abilities in the instance
queryset = queryset.annotate(
is_highest_ancestor_for_user=db.Value(
True, output_field=db.BooleanField()
)
)
# Annotate favorite status and filter if applicable as late as possible
queryset = self.annotate_is_favorite(queryset)
queryset = filterset.filters["is_favorite"].filter(
queryset, filter_data["is_favorite"]
)
# Apply ordering only now that everyting is filtered and annotated
return filters.OrderingFilter().filter_queryset(self.request, queryset, self)
def get_response_for_queryset(self, queryset):
"""Return paginated response for the queryset if requested."""
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
result = self.get_paginated_response(serializer.data)
return result
serializer = self.get_serializer(queryset, many=True)
return drf.response.Response(serializer.data)
@@ -406,32 +555,75 @@ class DocumentViewSet(
on a user's list view even though the user has no specific role in the document (link
access when the link reach configuration of the document allows it).
"""
user = self.request.user
instance = self.get_object()
serializer = self.get_serializer(instance)
if self.request.user.is_authenticated:
try:
# Add a trace that the user visited the document (this is needed to include
# the document in the user's list view)
models.LinkTrace.objects.create(
document=instance,
user=self.request.user,
)
except ValidationError:
# The trace already exists, so we just pass without doing anything
pass
# The `create` query generates 5 db queries which are much less efficient than an
# `exists` query. The user will visit the document many times after the first visit
# so that's what we should optimize for.
if (
user.is_authenticated
and not instance.link_traces.filter(user=user).exists()
):
models.LinkTrace.objects.create(document=instance, user=request.user)
return drf.response.Response(serializer.data)
@transaction.atomic
def perform_create(self, serializer):
"""Set the current user as creator and owner of the newly created object."""
obj = serializer.save(creator=self.request.user)
obj = models.Document.add_root(
creator=self.request.user,
**serializer.validated_data,
)
serializer.instance = obj
models.DocumentAccess.objects.create(
document=obj,
user=self.request.user,
role=models.RoleChoices.OWNER,
)
def perform_destroy(self, instance):
"""Override to implement a soft delete instead of dumping the record in database."""
instance.soft_delete()
@drf.decorators.action(
detail=False,
methods=["get"],
)
def favorite_list(self, request, *args, **kwargs):
"""Get list of favorite documents for the current user."""
user = request.user
favorite_documents_ids = models.DocumentFavorite.objects.filter(
user=user
).values_list("document_id", flat=True)
queryset = self.get_queryset()
queryset = queryset.filter(id__in=favorite_documents_ids)
return self.get_response_for_queryset(queryset)
@drf.decorators.action(
detail=False,
methods=["get"],
)
def trashbin(self, request, *args, **kwargs):
"""
Retrieve soft-deleted documents for which the current user has the owner role.
The selected documents are those deleted within the cutoff period defined in the
settings (see TRASHBIN_CUTOFF_DAYS), before they are considered permanently deleted.
"""
queryset = self.queryset.filter(
deleted_at__isnull=False,
deleted_at__gte=models.get_trashbin_cutoff(),
)
queryset = self.annotate_user_roles(queryset)
queryset = queryset.filter(user_roles__contains=[models.RoleChoices.OWNER])
return self.get_response_for_queryset(queryset)
@drf.decorators.action(
authentication_classes=[authentication.ServerToServerAuthentication],
detail=False,
@@ -456,6 +648,123 @@ class DocumentViewSet(
{"id": str(document.id)}, status=status.HTTP_201_CREATED
)
@drf.decorators.action(detail=True, methods=["post"])
@transaction.atomic
def move(self, request, *args, **kwargs):
"""
Move a document to another location within the document tree.
The user must be an administrator or owner of both the document being moved
and the target parent document.
"""
user = request.user
document = self.get_object() # including permission checks
# Validate the input payload
serializer = serializers.MoveDocumentSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
validated_data = serializer.validated_data
target_document_id = validated_data["target_document_id"]
try:
target_document = models.Document.objects.get(
id=target_document_id, ancestors_deleted_at__isnull=True
)
except models.Document.DoesNotExist:
return drf.response.Response(
{"target_document_id": "Target parent document does not exist."},
status=status.HTTP_400_BAD_REQUEST,
)
position = validated_data["position"]
message = None
if position in [
enums.MoveNodePositionChoices.FIRST_CHILD,
enums.MoveNodePositionChoices.LAST_CHILD,
]:
if not target_document.get_abilities(user).get("move"):
message = (
"You do not have permission to move documents "
"as a child to this target document."
)
elif not target_document.is_root():
if not target_document.get_parent().get_abilities(user).get("move"):
message = (
"You do not have permission to move documents "
"as a sibling of this target document."
)
if message:
return drf.response.Response(
{"target_document_id": message},
status=status.HTTP_400_BAD_REQUEST,
)
document.move(target_document, pos=position)
return drf.response.Response(
{"message": "Document moved successfully."}, status=status.HTTP_200_OK
)
@drf.decorators.action(
detail=True,
methods=["post"],
)
def restore(self, request, *args, **kwargs):
"""
Restore a soft-deleted document if it was deleted less than x days ago.
"""
document = self.get_object()
document.restore()
return drf_response.Response(
{"detail": "Document has been successfully restored."},
status=status.HTTP_200_OK,
)
@drf.decorators.action(
detail=True,
methods=["get", "post"],
ordering=["path"],
url_path="children",
)
def children(self, request, *args, **kwargs):
"""Handle listing and creating children of a document"""
document = self.get_object()
if request.method == "POST":
# Create a child document
serializer = serializers.DocumentSerializer(
data=request.data, context=self.get_serializer_context()
)
serializer.is_valid(raise_exception=True)
with transaction.atomic():
child_document = document.add_child(
creator=request.user,
**serializer.validated_data,
)
models.DocumentAccess.objects.create(
document=child_document,
user=request.user,
role=models.RoleChoices.OWNER,
)
# Set the created instance to the serializer
serializer.instance = child_document
headers = self.get_success_headers(serializer.data)
return drf.response.Response(
serializer.data, status=status.HTTP_201_CREATED, headers=headers
)
# GET: List children
queryset = document.get_children().filter(deleted_at__isnull=True)
queryset = self.filter_queryset(queryset)
queryset = self.annotate_is_favorite(queryset)
queryset = self.annotate_user_roles(queryset)
return self.get_response_for_queryset(queryset)
@drf.decorators.action(detail=True, methods=["get"], url_path="versions")
def versions_list(self, request, *args, **kwargs):
"""
@@ -474,8 +783,9 @@ class DocumentViewSet(
# Users should not see version history dating from before they gained access to the
# document. Filter to get the minimum access date for the logged-in user
access_queryset = document.accesses.filter(
db.Q(user=user) | db.Q(team__in=user.teams)
access_queryset = models.DocumentAccess.objects.filter(
db.Q(user=user) | db.Q(team__in=user.teams),
document__path=Left(db.Value(document.path), Length("document__path")),
).aggregate(min_date=db.Min("created_at"))
# Handle the case where the user has no accesses
@@ -513,10 +823,12 @@ class DocumentViewSet(
user = request.user
min_datetime = min(
access.created_at
for access in document.accesses.filter(
for access in models.DocumentAccess.objects.filter(
db.Q(user=user) | db.Q(team__in=user.teams),
document__path=Left(db.Value(document.path), Length("document__path")),
)
)
if response["LastModified"] < min_datetime:
raise Http404
@@ -606,7 +918,10 @@ class DocumentViewSet(
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)}}
extra_args = {
"Metadata": {"owner": str(request.user.id)},
"ContentType": serializer.validated_data["content_type"],
}
if serializer.validated_data["is_unsafe"]:
extra_args["Metadata"]["is_unsafe"] = "true"
@@ -630,7 +945,7 @@ class DocumentViewSet(
nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress
is configured to do this.
Based on the original url and the logged-in user, we must decide if we authorize Nginx
Based on the original url and the logged in user, we must decide if we authorize Nginx
to let this request go through (by returning a 200 code) or if we block it (by returning
a 403 error). Note that we return 403 errors without any further details for security
reasons.
@@ -677,7 +992,7 @@ class DocumentViewSet(
# Fetch the document and check if the user has access
try:
document, _created = models.Document.objects.get_or_create(pk=pk)
document = models.Document.objects.get(pk=pk)
except models.Document.DoesNotExist as exc:
logger.debug("Document with ID '%s' does not exist", pk)
raise drf.exceptions.PermissionDenied() from exc
@@ -768,7 +1083,6 @@ class DocumentViewSet(
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],
)
@@ -801,7 +1115,7 @@ class DocumentAccessViewSet(
drf.mixins.ListModelMixin,
drf.mixins.RetrieveModelMixin,
drf.mixins.UpdateModelMixin,
drf.viewsets.GenericViewSet,
viewsets.GenericViewSet,
):
"""
API ViewSet for all interactions with document accesses.
@@ -835,7 +1149,7 @@ class DocumentAccessViewSet(
serializer_class = serializers.DocumentAccessSerializer
def perform_create(self, serializer):
"""Add new access to the document and email the new added user."""
"""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")
@@ -847,7 +1161,7 @@ class DocumentAccessViewSet(
)
def perform_update(self, serializer):
"""Update access to the document and notify the collaboration server."""
"""Update an access to the document and notify the collaboration server."""
access = serializer.save()
access_user_id = None
@@ -860,7 +1174,7 @@ class DocumentAccessViewSet(
)
def perform_destroy(self, instance):
"""Delete access to the document and notify the collaboration server."""
"""Delete an access to the document and notify the collaboration server."""
instance.delete()
# Notify collaboration server about the access removed
@@ -874,7 +1188,7 @@ class TemplateViewSet(
drf.mixins.DestroyModelMixin,
drf.mixins.RetrieveModelMixin,
drf.mixins.UpdateModelMixin,
drf.viewsets.GenericViewSet,
viewsets.GenericViewSet,
):
"""Template ViewSet"""
@@ -898,14 +1212,14 @@ class TemplateViewSet(
user_roles_query = (
models.TemplateAccess.objects.filter(
Q(user=user) | Q(team__in=user.teams),
template_id=OuterRef("pk"),
db.Q(user=user) | db.Q(team__in=user.teams),
template_id=db.OuterRef("pk"),
)
.values("template")
.annotate(roles_array=ArrayAgg("role"))
.values("roles_array")
)
return queryset.annotate(user_roles=Subquery(user_roles_query)).distinct()
return queryset.annotate(user_roles=db.Subquery(user_roles_query)).distinct()
def list(self, request, *args, **kwargs):
"""Restrict templates returned by the list endpoint"""
@@ -928,6 +1242,7 @@ class TemplateViewSet(
serializer = self.get_serializer(queryset, many=True)
return drf.response.Response(serializer.data)
@transaction.atomic
def perform_create(self, serializer):
"""Set the current user as owner of the newly created object."""
obj = serializer.save()
@@ -937,40 +1252,6 @@ class TemplateViewSet(
role=models.RoleChoices.OWNER,
)
@drf.decorators.action(
detail=True,
methods=["post"],
url_path="generate-document",
permission_classes=[permissions.AccessPermission],
)
# pylint: disable=unused-argument
def generate_document(self, request, pk=None):
"""
Generate and return a document for this template around the
body passed as argument.
2 types of body are accepted:
- HTML: body_type = "html"
- Markdown: body_type = "markdown"
2 types of documents can be generated:
- PDF: format = "pdf"
- Docx: format = "docx"
"""
serializer = serializers.DocumentGenerationSerializer(data=request.data)
if not serializer.is_valid():
return drf.response.Response(
serializer.errors, status=drf.status.HTTP_400_BAD_REQUEST
)
body = serializer.validated_data["body"]
body_type = serializer.validated_data["body_type"]
export_format = serializer.validated_data["format"]
template = self.get_object()
return template.generate_document(body, body_type, export_format)
class TemplateAccessViewSet(
ResourceAccessViewsetMixin,
@@ -979,7 +1260,7 @@ class TemplateAccessViewSet(
drf.mixins.ListModelMixin,
drf.mixins.RetrieveModelMixin,
drf.mixins.UpdateModelMixin,
drf.viewsets.GenericViewSet,
viewsets.GenericViewSet,
):
"""
API ViewSet for all interactions with template accesses.
@@ -1019,7 +1300,7 @@ class InvitationViewset(
drf.mixins.RetrieveModelMixin,
drf.mixins.DestroyModelMixin,
drf.mixins.UpdateModelMixin,
drf.viewsets.GenericViewSet,
viewsets.GenericViewSet,
):
"""API ViewSet for user invitations to document.
@@ -1099,7 +1380,7 @@ class InvitationViewset(
return queryset
def perform_create(self, serializer):
"""Save invitation to a document then email the invited user."""
"""Save invitation to a document then send an email to the invited user."""
invitation = serializer.save()
language = self.request.headers.get("Content-Language", "en-us")
@@ -1125,6 +1406,7 @@ class ConfigView(drf.views.APIView):
"ENVIRONMENT",
"FRONTEND_THEME",
"MEDIA_BASE_URL",
"POSTHOG_KEY",
"LANGUAGES",
"LANGUAGE_CODE",
"SENTRY_DSN",

View File

@@ -11,7 +11,7 @@ from mozilla_django_oidc.auth import (
OIDCAuthenticationBackend as MozillaOIDCAuthenticationBackend,
)
from core.models import User
from core.models import DuplicateEmailError, User
logger = logging.getLogger(__name__)
@@ -98,7 +98,10 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
"short_name": short_name,
}
user = self.get_existing_user(sub, email)
try:
user = User.objects.get_user_by_sub_or_email(sub, email)
except DuplicateEmailError as err:
raise SuspiciousOperation(err.message) from err
if user:
if not user.is_active:
@@ -117,16 +120,6 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
)
return full_name or None
def get_existing_user(self, sub, email):
"""Fetch an existing user by sub (or email as a fallback respecting fallback setting."""
try:
return User.objects.get(sub=sub)
except User.DoesNotExist:
if email and settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION:
return User.objects.filter(email=email).first()
return None
def update_user_if_needed(self, user, claims):
"""Update user claims if they have changed."""
has_changed = any(

View File

@@ -2,7 +2,7 @@
from django.urls import path
from mozilla_django_oidc.urls import urlpatterns as mozilla_oidc_urls
from mozilla_django_oidc.urls import urlpatterns as mozzila_oidc_urls
from .views import OIDCLogoutCallbackView, OIDCLogoutView
@@ -14,5 +14,5 @@ urlpatterns = [
OIDCLogoutCallbackView.as_view(),
name="oidc_logout_callback",
),
*mozilla_oidc_urls,
*mozzila_oidc_urls,
]

View File

@@ -3,6 +3,7 @@ Core application enums declaration
"""
from django.conf import global_settings
from django.db import models
from django.utils.translation import gettext_lazy as _
# In Django's code base, `LANGUAGES` is set by default with all supported languages.
@@ -10,3 +11,14 @@ from django.utils.translation import gettext_lazy as _
# active in the app.
# pylint: disable=no-member
ALL_LANGUAGES = {language: _(name) for language, name in global_settings.LANGUAGES}
class MoveNodePositionChoices(models.TextChoices):
"""Defines the possible positions when moving a django-treebeard node."""
FIRST_CHILD = "first-child", _("First child")
LAST_CHILD = "last-child", _("Last child")
FIRST_SIBLING = "first-sibling", _("First sibling")
LAST_SIBLING = "last-sibling", _("Last sibling")
LEFT = "left", _("Left")
RIGHT = "right", _("Right")

View File

@@ -19,7 +19,6 @@ class UserFactory(factory.django.DjangoModelFactory):
class Meta:
model = models.User
skip_postgeneration_save = True
sub = factory.Sequence(lambda n: f"user{n!s}")
email = factory.Faker("email")
@@ -37,8 +36,6 @@ class UserFactory(factory.django.DjangoModelFactory):
if create and (extracted is True):
UserDocumentAccessFactory(user=self, role="owner")
self.save()
@factory.post_generation
def with_owned_template(self, create, extracted, **kwargs):
"""
@@ -48,7 +45,22 @@ class UserFactory(factory.django.DjangoModelFactory):
if create and (extracted is True):
UserTemplateAccessFactory(user=self, role="owner")
self.save()
class ParentNodeFactory(factory.declarations.ParameteredAttribute):
"""Custom factory attribute for setting the parent node."""
def generate(self, step, params):
"""
Generate a parent node for the factory.
This method is invoked during the factory's build process to determine the parent
node of the current object being created. If `params` is provided, it uses the factory's
metadata to recursively create or fetch the parent node. Otherwise, it returns `None`.
"""
if not params:
return None
subfactory = step.builder.factory_meta.factory
return step.recurse(subfactory, params)
class DocumentFactory(factory.django.DjangoModelFactory):
@@ -59,9 +71,13 @@ class DocumentFactory(factory.django.DjangoModelFactory):
django_get_or_create = ("title",)
skip_postgeneration_save = True
parent = ParentNodeFactory()
title = factory.Sequence(lambda n: f"document{n}")
excerpt = factory.Sequence(lambda n: f"excerpt{n}")
content = factory.Sequence(lambda n: f"content{n}")
creator = factory.SubFactory(UserFactory)
deleted_at = None
link_reach = factory.fuzzy.FuzzyChoice(
[a[0] for a in models.LinkReachChoices.choices]
)
@@ -69,6 +85,29 @@ class DocumentFactory(factory.django.DjangoModelFactory):
[r[0] for r in models.LinkRoleChoices.choices]
)
@classmethod
def _create(cls, model_class, *args, **kwargs):
"""
Custom creation logic for the factory: creates a document as a child node if
a parent is provided; otherwise, creates it as a root node.
"""
parent = kwargs.pop("parent", None)
if parent:
# Add as a child node
kwargs["ancestors_deleted_at"] = (
kwargs.get("ancestors_deleted_at") or parent.ancestors_deleted_at
)
return parent.add_child(instance=model_class(**kwargs))
# Add as a root node
return model_class.add_root(instance=model_class(**kwargs))
@factory.lazy_attribute
def ancestors_deleted_at(self):
"""Should always be set when "deleted_at" is set."""
return self.deleted_at
@factory.post_generation
def users(self, create, extracted, **kwargs):
"""Add users to document from a given list of users with or without roles."""
@@ -79,6 +118,16 @@ class DocumentFactory(factory.django.DjangoModelFactory):
else:
UserDocumentAccessFactory(document=self, user=item[0], role=item[1])
@factory.post_generation
def teams(self, create, extracted, **kwargs):
"""Add teams to document from a given list of teams with or without roles."""
if create and extracted:
for item in extracted:
if isinstance(item, str):
TeamDocumentAccessFactory(document=self, team=item)
else:
TeamDocumentAccessFactory(document=self, team=item[0], role=item[1])
@factory.post_generation
def link_traces(self, create, extracted, **kwargs):
"""Add link traces to document from a given list of users."""

View File

View File

@@ -0,0 +1,95 @@
"""Management command updating the metadata for all the files in the MinIO bucket."""
from django.core.files.storage import default_storage
from django.core.management.base import BaseCommand
import magic
from core.models import Document
# pylint: disable=too-many-locals, broad-exception-caught
class Command(BaseCommand):
"""Update the metadata for all the files in the MinIO bucket."""
help = __doc__
def handle(self, *args, **options):
"""Execute management command."""
s3_client = default_storage.connection.meta.client
bucket_name = default_storage.bucket_name
mime_detector = magic.Magic(mime=True)
documents = Document.objects.all()
self.stdout.write(
f"[INFO] Found {documents.count()} documents. Starting ContentType fix..."
)
for doc in documents:
doc_id_str = str(doc.id)
prefix = f"{doc_id_str}/attachments/"
self.stdout.write(
f"[INFO] Processing attachments under prefix '{prefix}' ..."
)
continuation_token = None
total_updated = 0
while True:
list_kwargs = {"Bucket": bucket_name, "Prefix": prefix}
if continuation_token:
list_kwargs["ContinuationToken"] = continuation_token
response = s3_client.list_objects_v2(**list_kwargs)
# If no objects found under this prefix, break out of the loop
if "Contents" not in response:
break
for obj in response["Contents"]:
key = obj["Key"]
# Skip if it's a folder
if key.endswith("/"):
continue
try:
# Get existing metadata
head_resp = s3_client.head_object(Bucket=bucket_name, Key=key)
# Read first ~1KB for MIME detection
partial_obj = s3_client.get_object(
Bucket=bucket_name, Key=key, Range="bytes=0-1023"
)
partial_data = partial_obj["Body"].read()
# Detect MIME type
magic_mime_type = mime_detector.from_buffer(partial_data)
# Update ContentType
s3_client.copy_object(
Bucket=bucket_name,
CopySource={"Bucket": bucket_name, "Key": key},
Key=key,
ContentType=magic_mime_type,
Metadata=head_resp.get("Metadata", {}),
MetadataDirective="REPLACE",
)
total_updated += 1
except Exception as exc: # noqa
self.stderr.write(
f"[ERROR] Could not update ContentType for {key}: {exc}"
)
if response.get("IsTruncated"):
continuation_token = response.get("NextContinuationToken")
else:
break
if total_updated > 0:
self.stdout.write(
f"[INFO] -> Updated {total_updated} objects for Document {doc_id_str}."
)

View File

@@ -1,5 +1,7 @@
# Generated by Django 5.0.3 on 2024-05-28 20:29
import django.contrib.auth.models
import django.core.validators
import django.db.models.deletion
import timezone_field.fields
import uuid
@@ -143,7 +145,7 @@ class Migration(migrations.Migration):
),
migrations.AddConstraint(
model_name='documentaccess',
constraint=models.CheckConstraint(condition=models.Q(models.Q(('team', ''), ('user__isnull', False)), models.Q(('team__gt', ''), ('user__isnull', True)), _connector='OR'), name='check_document_access_either_user_or_team', violation_error_message='Either user or team must be set, not both.'),
constraint=models.CheckConstraint(check=models.Q(models.Q(('team', ''), ('user__isnull', False)), models.Q(('team__gt', ''), ('user__isnull', True)), _connector='OR'), name='check_document_access_either_user_or_team', violation_error_message='Either user or team must be set, not both.'),
),
migrations.AddConstraint(
model_name='invitation',
@@ -159,6 +161,6 @@ class Migration(migrations.Migration):
),
migrations.AddConstraint(
model_name='templateaccess',
constraint=models.CheckConstraint(condition=models.Q(models.Q(('team', ''), ('user__isnull', False)), models.Q(('team__gt', ''), ('user__isnull', True)), _connector='OR'), name='check_template_access_either_user_or_team', violation_error_message='Either user or team must be set, not both.'),
constraint=models.CheckConstraint(check=models.Q(models.Q(('team', ''), ('user__isnull', False)), models.Q(('team__gt', ''), ('user__isnull', True)), _connector='OR'), name='check_template_access_either_user_or_team', violation_error_message='Either user or team must be set, not both.'),
),
]

View File

@@ -0,0 +1,16 @@
# Generated by Django 5.1.4 on 2025-01-25 08:38
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('core', '0012_make_document_creator_and_invitation_issuer_optional'),
]
operations = [
migrations.RunSQL(
"CREATE EXTENSION IF NOT EXISTS fuzzystrmatch;",
reverse_sql="DROP EXTENSION IF EXISTS fuzzystrmatch;",
),
]

View File

@@ -1,17 +0,0 @@
# Generated by Django 5.1.4 on 2025-01-13 22:57
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0012_make_document_creator_and_invitation_issuer_optional'),
]
operations = [
migrations.AlterModelOptions(
name='user',
options={'ordering': ('-created_at',), 'verbose_name': 'user', 'verbose_name_plural': 'users'},
),
]

View File

@@ -0,0 +1,31 @@
# Generated by Django 5.1.2 on 2024-12-07 09:43
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0013_activate_fuzzystrmatch_extension'),
]
operations = [
migrations.AddField(
model_name='document',
name='depth',
field=models.PositiveIntegerField(default=0),
preserve_default=False,
),
migrations.AddField(
model_name='document',
name='numchild',
field=models.PositiveIntegerField(default=0),
),
migrations.AddField(
model_name='document',
name='path',
# Allow null values pending the next datamigration to populate the field
field=models.CharField(db_collation='C', max_length=252, null=True, unique=True),
preserve_default=False,
),
]

View File

@@ -0,0 +1,52 @@
# Generated by Django 5.1.2 on 2024-12-07 10:33
from django.db import migrations, models
from treebeard.numconv import NumConv
ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
STEPLEN = 7
def set_path_on_existing_documents(apps, schema_editor):
"""
Updates the `path` and `depth` fields for all existing Document records
to ensure valid materialized paths.
This function assigns a unique `path` to each Document as a root node
Note: After running this migration, we quickly modify the schema to make
the `path` field required as it should.
"""
Document = apps.get_model("core", "Document")
# Iterate over all existing documents and make them root nodes
documents = Document.objects.order_by("created_at").values_list("id", flat=True)
numconv = NumConv(len(ALPHABET), ALPHABET)
updates = []
for i, pk in enumerate(documents):
key = numconv.int2str(i)
path = "{0}{1}".format(
ALPHABET[0] * (STEPLEN - len(key)),
key
)
updates.append(Document(pk=pk, path=path, depth=1))
# Bulk update using the prepared updates list
Document.objects.bulk_update(updates, ['depth', 'path'])
class Migration(migrations.Migration):
dependencies = [
('core', '0014_add_tree_structure_to_documents'),
]
operations = [
migrations.RunPython(set_path_on_existing_documents, reverse_code=migrations.RunPython.noop),
migrations.AlterField(
model_name='document',
name='path',
field=models.CharField(db_collation='C', max_length=252, unique=True),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.1.4 on 2024-12-18 08:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0015_set_path_on_existing_documents'),
]
operations = [
migrations.AddField(
model_name='document',
name='excerpt',
field=models.TextField(blank=True, max_length=300, null=True, verbose_name='excerpt'),
),
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'),
),
]

View File

@@ -0,0 +1,36 @@
# Generated by Django 5.1.4 on 2025-01-12 14:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0016_add_document_excerpt'),
]
operations = [
migrations.AlterModelOptions(
name='document',
options={'ordering': ('path',), 'verbose_name': 'Document', 'verbose_name_plural': 'Documents'},
),
migrations.AddField(
model_name='document',
name='ancestors_deleted_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='document',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True),
),
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.AddConstraint(
model_name='document',
constraint=models.CheckConstraint(condition=models.Q(('deleted_at__isnull', True), ('deleted_at', models.F('ancestors_deleted_at')), _connector='OR'), name='check_deleted_at_matches_ancestors_deleted_at_when_set'),
),
]

View File

@@ -1,59 +1,51 @@
"""
Declare and configure the models for the impress core application
"""
# pylint: disable=too-many-lines
import hashlib
import smtplib
import tempfile
import textwrap
import uuid
from datetime import timedelta
from io import BytesIO
from logging import getLogger
from django.conf import settings
from django.contrib.auth import models as auth_models
from django.contrib.auth.base_user import AbstractBaseUser
from django.contrib.sites.models import Site
from django.core import exceptions, mail, validators
from django.core import mail, validators
from django.core.cache import cache
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
from django.core.mail import send_mail
from django.db import models
from django.http import FileResponse
from django.template.base import Template as DjangoTemplate
from django.template.context import Context
from django.db import models, transaction
from django.db.models.functions import Left, Length
from django.template.loader import render_to_string
from django.utils import html, timezone
from django.utils import timezone
from django.utils.functional import cached_property, lazy
from django.utils.translation import get_language, override
from django.utils.translation import gettext_lazy as _
import frontmatter
import markdown
import pypandoc
import weasyprint
from botocore.exceptions import ClientError
from rest_framework.exceptions import ValidationError
from timezone_field import TimeZoneField
from treebeard.mp_tree import MP_Node
logger = getLogger(__name__)
def get_resource_roles(resource, user):
"""Compute the roles a user has on a resource."""
if not user.is_authenticated:
return []
def get_trashbin_cutoff():
"""
Calculate the cutoff datetime for soft-deleted items based on the retention policy.
try:
roles = resource.user_roles or []
except AttributeError:
try:
roles = resource.accesses.filter(
models.Q(user=user) | models.Q(team__in=user.teams),
).values_list("role", flat=True)
except (models.ObjectDoesNotExist, IndexError):
roles = []
return roles
The function returns the current datetime minus the number of days specified in
the TRASHBIN_CUTOFF_DAYS setting, indicating the oldest date for items that can
remain in the trash bin.
Returns:
datetime: The cutoff datetime for soft-deleted items.
"""
return timezone.now() - timedelta(days=settings.TRASHBIN_CUTOFF_DAYS)
class LinkRoleChoices(models.TextChoices):
@@ -89,6 +81,16 @@ class LinkReachChoices(models.TextChoices):
PUBLIC = "public", _("Public") # Even anonymous users can access the document
class DuplicateEmailError(Exception):
"""Raised when an email is already associated with a pre-existing user."""
def __init__(self, message=None, email=None):
"""Set message and email to describe the exception."""
self.message = message
self.email = email
super().__init__(self.message)
class BaseModel(models.Model):
"""
Serves as an abstract base model for other models, ensuring that records are validated
@@ -126,6 +128,35 @@ class BaseModel(models.Model):
super().save(*args, **kwargs)
class UserManager(auth_models.UserManager):
"""Custom manager for User model with additional methods."""
def get_user_by_sub_or_email(self, sub, email):
"""Fetch existing user by sub or email."""
try:
return self.get(sub=sub)
except self.model.DoesNotExist as err:
if not email:
return None
if settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION:
try:
return self.get(email=email)
except self.model.DoesNotExist:
pass
elif (
self.filter(email=email).exists()
and not settings.OIDC_ALLOW_DUPLICATE_EMAILS
):
raise DuplicateEmailError(
_(
"We couldn't find a user with this sub but the email is already "
"associated with a registered user."
)
) from err
return None
class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
"""User model to work with OIDC only authentication."""
@@ -155,7 +186,7 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
email = models.EmailField(_("identity email address"), blank=True, null=True)
# Unlike the "email" field which stores the email coming from the OIDC token, this field
# stores the email used by staff users to log in to the admin site
# stores the email used by staff users to login to the admin site
admin_email = models.EmailField(
_("admin email address"), unique=True, blank=True, null=True
)
@@ -192,14 +223,13 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
),
)
objects = auth_models.UserManager()
objects = UserManager()
USERNAME_FIELD = "admin_email"
REQUIRED_FIELDS = []
class Meta:
db_table = "impress_user"
ordering = ("-created_at",)
verbose_name = _("user")
verbose_name_plural = _("users")
@@ -337,10 +367,11 @@ class BaseAccess(BaseModel):
}
class Document(BaseModel):
class Document(MP_Node, BaseModel):
"""Pad document carrying the content."""
title = models.CharField(_("title"), max_length=255, null=True, blank=True)
excerpt = models.TextField(_("excerpt"), max_length=300, null=True, blank=True)
link_reach = models.CharField(
max_length=20,
choices=LinkReachChoices.choices,
@@ -356,14 +387,32 @@ class Document(BaseModel):
blank=True,
null=True,
)
deleted_at = models.DateTimeField(null=True, blank=True)
ancestors_deleted_at = models.DateTimeField(null=True, blank=True)
_content = None
# Tree structure
alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
steplen = 7 # nb siblings max: 3,521,614,606,208
node_order_by = [] # Manual ordering
path = models.CharField(max_length=7 * 36, unique=True, db_collation="C")
class Meta:
db_table = "impress_document"
ordering = ("title",)
ordering = ("path",)
verbose_name = _("Document")
verbose_name_plural = _("Documents")
constraints = [
models.CheckConstraint(
check=(
models.Q(deleted_at__isnull=True)
| models.Q(deleted_at=models.F("ancestors_deleted_at"))
),
name="check_deleted_at_matches_ancestors_deleted_at_when_set",
),
]
def __str__(self):
return str(self.title) if self.title else str(_("Untitled Document"))
@@ -502,48 +551,124 @@ class Document(BaseModel):
Bucket=default_storage.bucket_name, Key=self.file_key, VersionId=version_id
)
def get_nb_accesses_cache_key(self):
"""Generate a unique cache key for each document."""
return f"document_{self.id!s}_nb_accesses"
@property
def nb_accesses(self):
"""Calculate the number of accesses."""
cache_key = self.get_nb_accesses_cache_key()
nb_accesses = cache.get(cache_key)
if nb_accesses is None:
nb_accesses = DocumentAccess.objects.filter(
document__path=Left(models.Value(self.path), Length("document__path")),
).count()
cache.set(cache_key, nb_accesses)
return nb_accesses
def invalidate_nb_accesses_cache(self):
"""
Invalidate the cache for number of accesses, including on affected descendants.
"""
for document in Document.objects.filter(path__startswith=self.path).only("id"):
cache_key = document.get_nb_accesses_cache_key()
cache.delete(cache_key)
def get_roles(self, user):
"""Return the roles a user has on a document."""
if not user.is_authenticated:
return []
try:
roles = self.user_roles or []
except AttributeError:
try:
roles = DocumentAccess.objects.filter(
models.Q(user=user) | models.Q(team__in=user.teams),
document__path=Left(
models.Value(self.path), Length("document__path")
),
).values_list("role", flat=True)
except (models.ObjectDoesNotExist, IndexError):
roles = []
return roles
@cached_property
def links_definitions(self):
"""Get links reach/role definitions for the current document and its ancestors."""
links_definitions = {self.link_reach: {self.link_role}}
# Ancestors links definitions are only interesting if the document is not the highest
# ancestor to which the current user has access. Look for the annotation:
if self.depth > 1 and not getattr(self, "is_highest_ancestor_for_user", False):
for ancestor in self.get_ancestors().values("link_reach", "link_role"):
links_definitions.setdefault(ancestor["link_reach"], set()).add(
ancestor["link_role"]
)
return links_definitions
def get_abilities(self, user):
"""
Compute and return abilities for a given user on the document.
"""
roles = set(get_resource_roles(self, user))
roles = set(
self.get_roles(user)
) # at this point only roles based on specific access
# Compute version roles before adding link roles because we don't
# Characteristics that are based only on specific access
is_owner = RoleChoices.OWNER in roles
is_deleted = self.ancestors_deleted_at and not is_owner
is_owner_or_admin = (is_owner or RoleChoices.ADMIN in roles) and not is_deleted
# Compute access 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)
# Anonymous users should also not see document accesses
has_role = bool(roles)
has_access_role = bool(roles) and not is_deleted
# Add role provided by the document link
if self.link_reach == LinkReachChoices.PUBLIC or (
self.link_reach == LinkReachChoices.AUTHENTICATED and user.is_authenticated
):
roles.add(self.link_role)
# Add roles provided by the document link, taking into account its ancestors
is_owner_or_admin = bool(
roles.intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
# Add roles provided by the document link
links_definitions = self.links_definitions
public_roles = links_definitions.get(LinkReachChoices.PUBLIC, set())
authenticated_roles = (
links_definitions.get(LinkReachChoices.AUTHENTICATED, set())
if user.is_authenticated
else set()
)
can_get = bool(roles)
can_update = is_owner_or_admin or RoleChoices.EDITOR in roles
roles = roles | public_roles | authenticated_roles
can_get = bool(roles) and not is_deleted
can_update = (
is_owner_or_admin or RoleChoices.EDITOR in roles
) and not is_deleted
return {
"accesses_manage": is_owner_or_admin,
"accesses_view": has_role,
"accesses_view": has_access_role,
"ai_transform": can_update,
"ai_translate": can_update,
"attachment_upload": can_update,
"children_list": can_get,
"children_create": can_update and user.is_authenticated,
"collaboration_auth": can_get,
"destroy": RoleChoices.OWNER in roles,
"destroy": is_owner,
"favorite": can_get and user.is_authenticated,
"link_configuration": is_owner_or_admin,
"invite_owner": RoleChoices.OWNER in roles,
"invite_owner": is_owner,
"move": is_owner_or_admin and not self.ancestors_deleted_at,
"partial_update": can_update,
"restore": is_owner,
"retrieve": can_get,
"media_auth": can_get,
"update": can_update,
"versions_destroy": is_owner_or_admin,
"versions_list": has_role,
"versions_retrieve": has_role,
"versions_list": has_access_role,
"versions_retrieve": has_access_role,
}
def send_email(self, subject, emails, context=None, language=None):
@@ -604,6 +729,77 @@ class Document(BaseModel):
self.send_email(subject, [email], context, language)
@transaction.atomic
def soft_delete(self):
"""
Soft delete the document, marking the deletion on descendants.
We still keep the .delete() method untouched for programmatic purposes.
"""
if self.deleted_at or self.ancestors_deleted_at:
raise RuntimeError(
"This document is already deleted or has deleted ancestors."
)
# Check if any ancestors are deleted
if self.get_ancestors().filter(deleted_at__isnull=False).exists():
raise RuntimeError(
"Cannot delete this document because one or more ancestors are already deleted."
)
self.ancestors_deleted_at = self.deleted_at = timezone.now()
self.save()
# Mark all descendants as soft deleted
self.get_descendants().filter(ancestors_deleted_at__isnull=True).update(
ancestors_deleted_at=self.ancestors_deleted_at
)
@transaction.atomic
def restore(self):
"""Cancelling a soft delete with checks."""
# This should not happen
if self.deleted_at is None:
raise ValidationError({"deleted_at": [_("This document is not deleted.")]})
if self.deleted_at < get_trashbin_cutoff():
raise ValidationError(
{
"deleted_at": [
_(
"This document was permanently deleted and cannot be restored."
)
]
}
)
# Restore the current document
self.deleted_at = None
# Calculate the minimum `deleted_at` among all ancestors
ancestors_deleted_at = (
self.get_ancestors()
.filter(deleted_at__isnull=False)
.values_list("deleted_at", flat=True)
)
self.ancestors_deleted_at = min(ancestors_deleted_at, default=None)
self.save()
# Update descendants excluding those who were deleted prior to the deletion of the
# current document (the ancestor_deleted_at date for those should already by good)
# The number of deleted descendants should not be too big so we can handcraft a union
# clause for them:
deleted_descendants_paths = (
self.get_descendants()
.filter(deleted_at__isnull=False)
.values_list("path", flat=True)
)
exclude_condition = models.Q(
*(models.Q(path__startswith=path) for path in deleted_descendants_paths)
)
self.get_descendants().exclude(exclude_condition).update(
ancestors_deleted_at=self.ancestors_deleted_at
)
class LinkTrace(BaseModel):
"""
@@ -696,7 +892,7 @@ class DocumentAccess(BaseAccess):
violation_error_message=_("This team is already in this document."),
),
models.CheckConstraint(
condition=models.Q(user__isnull=False, team="")
check=models.Q(user__isnull=False, team="")
| models.Q(user__isnull=True, team__gt=""),
name="check_document_access_either_user_or_team",
violation_error_message=_("Either user or team must be set, not both."),
@@ -706,6 +902,16 @@ class DocumentAccess(BaseAccess):
def __str__(self):
return f"{self.user!s} is {self.role:s} in document {self.document!s}"
def save(self, *args, **kwargs):
"""Override save to clear the document's cache for number of accesses."""
super().save(*args, **kwargs)
self.document.invalidate_nb_accesses_cache()
def delete(self, *args, **kwargs):
"""Override delete to clear the document's cache for number of accesses."""
super().delete(*args, **kwargs)
self.document.invalidate_nb_accesses_cache()
def get_abilities(self, user):
"""
Compute and return abilities for a given user on the document access.
@@ -735,11 +941,27 @@ class Template(BaseModel):
def __str__(self):
return self.title
def get_roles(self, user):
"""Return the roles a user has on a resource as an iterable."""
if not user.is_authenticated:
return []
try:
roles = self.user_roles or []
except AttributeError:
try:
roles = self.accesses.filter(
models.Q(user=user) | models.Q(team__in=user.teams),
).values_list("role", flat=True)
except (models.ObjectDoesNotExist, IndexError):
roles = []
return roles
def get_abilities(self, user):
"""
Compute and return abilities for a given user on the template.
"""
roles = get_resource_roles(self, user)
roles = self.get_roles(user)
is_owner_or_admin = bool(
set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
)
@@ -755,106 +977,6 @@ class Template(BaseModel):
"retrieve": can_get,
}
def generate_pdf(self, body_html, metadata):
"""
Generate and return a pdf document wrapped around the current template
"""
document_html = weasyprint.HTML(
string=DjangoTemplate(self.code).render(
Context({"body": html.format_html("{}", body_html), **metadata})
)
)
css = weasyprint.CSS(
string=self.css,
font_config=weasyprint.text.fonts.FontConfiguration(),
)
pdf_content = document_html.write_pdf(stylesheets=[css], zoom=1)
response = FileResponse(BytesIO(pdf_content), content_type="application/pdf")
response["Content-Disposition"] = f"attachment; filename={self.title}.pdf"
return response
def generate_word(self, body_html, metadata):
"""
Generate and return a docx document wrapped around the current template
"""
template_string = DjangoTemplate(self.code).render(
Context({"body": html.format_html("{}", body_html), **metadata})
)
html_string = f"""
<!DOCTYPE html>
<html>
<head>
<style>
{self.css}
</style>
</head>
<body>
{template_string}
</body>
</html>
"""
reference_docx = "core/static/reference.docx"
# Convert the HTML to a temporary docx file
with tempfile.NamedTemporaryFile(suffix=".docx", prefix="docx_") as tmp_file:
output_path = tmp_file.name
pypandoc.convert_text(
html_string,
"docx",
format="html",
outputfile=output_path,
extra_args=["--reference-doc", reference_docx],
)
# Create a BytesIO object to store the output of the temporary docx file
with open(output_path, "rb") as f:
output = BytesIO(f.read())
# Ensure the pointer is at the beginning
output.seek(0)
response = FileResponse(
output,
content_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
)
response["Content-Disposition"] = f"attachment; filename={self.title}.docx"
return response
def generate_document(self, body, body_type, export_format):
"""
Generate and return a document for this template around the
body passed as argument.
2 types of body are accepted:
- HTML: body_type = "html"
- Markdown: body_type = "markdown"
2 types of documents can be generated:
- PDF: export_format = "pdf"
- Docx: export_format = "docx"
"""
document = frontmatter.loads(body)
metadata = document.metadata
strip_body = document.content.strip()
if body_type == "html":
body_html = strip_body
else:
body_html = (
markdown.markdown(textwrap.dedent(strip_body)) if strip_body else ""
)
if export_format == "pdf":
return self.generate_pdf(body_html, metadata)
return self.generate_word(body_html, metadata)
class TemplateAccess(BaseAccess):
"""Relation model to give access to a template for a user or a team with a role."""
@@ -884,7 +1006,7 @@ class TemplateAccess(BaseAccess):
violation_error_message=_("This team is already in this template."),
),
models.CheckConstraint(
condition=models.Q(user__isnull=False, team="")
check=models.Q(user__isnull=False, team="")
| models.Q(user__isnull=True, team__gt=""),
name="check_template_access_either_user_or_team",
violation_error_message=_("Either user or team must be set, not both."),
@@ -939,9 +1061,12 @@ class Invitation(BaseModel):
super().clean()
# Check if an identity already exists for the provided email
if User.objects.filter(email=self.email).exists():
raise exceptions.ValidationError(
{"email": _("This email is already associated to a registered user.")}
if (
User.objects.filter(email=self.email).exists()
and not settings.OIDC_ALLOW_DUPLICATE_EMAILS
):
raise ValidationError(
{"email": [_("This email is already associated to a registered user.")]}
)
@property

View File

@@ -17,7 +17,7 @@ class CollaborationService:
def reset_connections(self, room, user_id=None):
"""
Reset connections of a room in the collaboration server.
Resetting a connection means that the user will be disconnected and will
Reseting a connection means that the user will be disconnected and will
have to reconnect to the collaboration server, with updated rights.
"""
endpoint = "reset-connections"

View File

@@ -1,5 +1,6 @@
"""Unit tests for the Authentication Backends."""
import random
import re
from logging import Logger
from unittest import mock
@@ -64,7 +65,33 @@ def test_authentication_getter_existing_user_via_email(
assert user == db_user
def test_authentication_getter_existing_user_no_fallback_to_email(
def test_authentication_getter_email_none(monkeypatch):
"""
If no user is found with the sub and no email is provided, a new user should be created.
"""
klass = OIDCAuthenticationBackend()
db_user = UserFactory(email=None)
def get_userinfo_mocked(*args):
user_info = {"sub": "123"}
if random.choice([True, False]):
user_info["email"] = None
return user_info
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
# Since the sub and email didn't match, it should create a new user
assert models.User.objects.count() == 2
assert user != db_user
assert user.sub == "123"
def test_authentication_getter_existing_user_no_fallback_to_email_allow_duplicate(
settings, monkeypatch
):
"""
@@ -77,6 +104,7 @@ def test_authentication_getter_existing_user_no_fallback_to_email(
# Set the setting to False
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = False
settings.OIDC_ALLOW_DUPLICATE_EMAILS = True
def get_userinfo_mocked(*args):
return {"sub": "123", "email": db_user.email}
@@ -93,6 +121,39 @@ def test_authentication_getter_existing_user_no_fallback_to_email(
assert user.sub == "123"
def test_authentication_getter_existing_user_no_fallback_to_email_no_duplicate(
settings, monkeypatch
):
"""
When the "OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION" setting is set to False,
the system should not match users by email, even if the email matches.
"""
klass = OIDCAuthenticationBackend()
db_user = UserFactory()
# Set the setting to False
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = False
settings.OIDC_ALLOW_DUPLICATE_EMAILS = False
def get_userinfo_mocked(*args):
return {"sub": "123", "email": db_user.email}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with pytest.raises(
SuspiciousOperation,
match=(
"We couldn't find a user with this sub but the email is already associated "
"with a registered user."
),
):
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
# Since the sub doesn't match, it should not create a new user
assert models.User.objects.count() == 1
def test_authentication_getter_existing_user_with_email(
django_assert_num_queries, monkeypatch
):

View File

@@ -0,0 +1,50 @@
"""
Unit test for `update_files_content_type_metadata` command.
"""
import uuid
from django.core.files.storage import default_storage
from django.core.management import call_command
import pytest
from core import factories
@pytest.mark.django_db
def test_update_files_content_type_metadata():
"""
Test that the command `update_files_content_type_metadata`
fixes the ContentType of attachment in the storage.
"""
s3_client = default_storage.connection.meta.client
bucket_name = default_storage.bucket_name
# Create files with a wrong ContentType
keys = []
for _ in range(10):
doc_id = uuid.uuid4()
factories.DocumentFactory(id=doc_id)
key = f"{doc_id}/attachments/testfile.png"
keys.append(key)
fake_png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR..."
s3_client.put_object(
Bucket=bucket_name,
Key=key,
Body=fake_png,
ContentType="text/plain",
Metadata={"owner": "None"},
)
# Call the command that fixes the ContentType
call_command("update_files_content_type_metadata")
for key in keys:
head_resp = s3_client.head_object(Bucket=bucket_name, Key=key)
assert head_resp["ContentType"] == "image/png", (
f"ContentType not fixed, got {head_resp['ContentType']!r}"
)
# Check that original metadata was preserved
assert head_resp["Metadata"].get("owner") == "None"

View File

@@ -76,14 +76,14 @@ def test_api_document_accesses_list_authenticated_related(via, mock_user_teams):
user_access = models.DocumentAccess.objects.create(
document=document,
user=user,
role=random.choice(models.RoleChoices.choices)[0],
role=random.choice(models.RoleChoices.values),
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
user_access = models.DocumentAccess.objects.create(
document=document,
team="lasuite",
role=random.choice(models.RoleChoices.choices)[0],
role=random.choice(models.RoleChoices.values),
)
access1 = factories.TeamDocumentAccessFactory(document=document)
@@ -227,7 +227,7 @@ def test_api_document_accesses_update_anonymous():
new_values = {
"id": uuid4(),
"user": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
"role": random.choice(models.RoleChoices.values),
}
api_client = APIClient()
@@ -260,7 +260,7 @@ def test_api_document_accesses_update_authenticated_unrelated():
new_values = {
"id": uuid4(),
"user": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
"role": random.choice(models.RoleChoices.values),
}
for field, value in new_values.items():
@@ -302,7 +302,7 @@ def test_api_document_accesses_update_authenticated_reader_or_editor(
new_values = {
"id": uuid4(),
"user": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
"role": random.choice(models.RoleChoices.values),
}
for field, value in new_values.items():
@@ -413,7 +413,7 @@ def test_api_document_accesses_update_administrator_from_owner(via, mock_user_te
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
"role": random.choice(models.RoleChoices.values),
}
for field, value in new_values.items():
@@ -527,7 +527,7 @@ def test_api_document_accesses_update_owner(
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
"role": random.choice(models.RoleChoices.values),
}
for field, value in new_values.items():
@@ -698,7 +698,7 @@ def test_api_document_accesses_delete_administrators_except_owners(
mock_reset_connections, # pylint: disable=redefined-outer-name
):
"""
Users who are administrators in a document should be allowed to delete access
Users who are administrators in a document should be allowed to delete an access
from the document provided it is not ownership.
"""
user = factories.UserFactory()

View File

@@ -26,7 +26,7 @@ def test_api_document_accesses_create_anonymous():
{
"user_id": str(other_user.id),
"document": str(document.id),
"role": random.choice(models.RoleChoices.choices)[0],
"role": random.choice(models.RoleChoices.values),
},
format="json",
)

View File

@@ -304,7 +304,7 @@ def test_api_document_invitations_create_anonymous():
document = factories.DocumentFactory()
invitation_values = {
"email": "guest@example.com",
"role": random.choice(models.RoleChoices.choices)[0],
"role": random.choice(models.RoleChoices.values),
}
response = APIClient().post(
@@ -325,7 +325,7 @@ def test_api_document_invitations_create_authenticated_outsider():
document = factories.DocumentFactory()
invitation_values = {
"email": "guest@example.com",
"role": random.choice(models.RoleChoices.choices)[0],
"role": random.choice(models.RoleChoices.values),
}
client = APIClient()
@@ -458,6 +458,10 @@ def test_api_document_invitations_create_email_from_content_language():
email_content = " ".join(email.body.split())
assert f"{user.full_name} a partagé un document avec vous!" in email_content
assert (
"Docs, votre nouvel outil incontournable pour organiser, partager et collaborer "
"sur vos documents en équipe." in email_content
)
def test_api_document_invitations_create_email_from_content_language_not_supported():
@@ -550,7 +554,7 @@ def test_api_document_invitations_create_issuer_and_document_override():
"document": str(other_document.id),
"issuer": str(factories.UserFactory().id),
"email": "guest@example.com",
"role": random.choice(models.RoleChoices.choices)[0],
"role": random.choice(models.RoleChoices.values),
}
client = APIClient()
@@ -611,7 +615,7 @@ def test_api_document_invitations_create_cannot_invite_existing_users():
# Build an invitation to the email of an exising identity in the db
invitation_values = {
"email": existing_user.email,
"role": random.choice(models.RoleChoices.choices)[0],
"role": random.choice(models.RoleChoices.values),
}
client = APIClient()
@@ -624,7 +628,9 @@ def test_api_document_invitations_create_cannot_invite_existing_users():
)
assert response.status_code == 400
assert response.json() == ["This email is already associated to a registered user."]
assert response.json() == {
"email": ["This email is already associated to a registered user."]
}
# Update

View File

@@ -75,14 +75,14 @@ def test_api_document_versions_list_authenticated_related_success(via, mock_user
models.DocumentAccess.objects.create(
document=document,
user=user,
role=random.choice(models.RoleChoices.choices)[0],
role=random.choice(models.RoleChoices.values),
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
models.DocumentAccess.objects.create(
document=document,
team="lasuite",
role=random.choice(models.RoleChoices.choices)[0],
role=random.choice(models.RoleChoices.values),
)
# Other versions of documents to which the user has access should not be listed
@@ -134,14 +134,14 @@ def test_api_document_versions_list_authenticated_related_pagination(
models.DocumentAccess.objects.create(
document=document,
user=user,
role=random.choice(models.RoleChoices.choices)[0],
role=random.choice(models.RoleChoices.values),
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
models.DocumentAccess.objects.create(
document=document,
team="lasuite",
role=random.choice(models.RoleChoices.choices)[0],
role=random.choice(models.RoleChoices.values),
)
for i in range(4):
@@ -185,6 +185,84 @@ def test_api_document_versions_list_authenticated_related_pagination(
assert content["versions"][0]["version_id"] == all_version_ids[2]
@pytest.mark.parametrize("via", VIA)
def test_api_document_versions_list_authenticated_related_pagination_parent(
via, mock_user_teams
):
"""
When a user gains access to a document's versions via an ancestor, the date of access
to the parent should be used to filter versions that were created prior to the
user gaining access to the document.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
grand_parent = factories.DocumentFactory()
parent = factories.DocumentFactory(parent=grand_parent)
document = factories.DocumentFactory(parent=parent)
for i in range(3):
document.content = f"before {i:d}"
document.save()
if via == USER:
models.DocumentAccess.objects.create(
document=grand_parent,
user=user,
role=random.choice(models.RoleChoices.values),
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
models.DocumentAccess.objects.create(
document=grand_parent,
team="lasuite",
role=random.choice(models.RoleChoices.values),
)
for i in range(4):
document.content = f"after {i:d}"
document.save()
response = client.get(
f"/api/v1.0/documents/{document.id!s}/versions/",
)
content = response.json()
assert response.status_code == 200
assert content["is_truncated"] is False
# The current version is not listed
assert content["count"] == 3
assert content["next_version_id_marker"] == ""
all_version_ids = [version["version_id"] for version in content["versions"]]
# - set page size
response = client.get(
f"/api/v1.0/documents/{document.id!s}/versions/?page_size=2",
)
content = response.json()
assert content["count"] == 2
assert content["is_truncated"] is True
marker = content["next_version_id_marker"]
assert marker == all_version_ids[1]
assert [
version["version_id"] for version in content["versions"]
] == all_version_ids[:2]
# - get page 2
response = client.get(
f"/api/v1.0/documents/{document.id!s}/versions/?page_size=2&version_id={marker:s}",
)
content = response.json()
assert content["count"] == 1
assert content["is_truncated"] is False
assert content["next_version_id_marker"] == ""
assert content["versions"][0]["version_id"] == all_version_ids[2]
def test_api_document_versions_list_exceeds_max_page_size():
"""Page size should not exceed the limit set on the serializer"""
user = factories.UserFactory()
@@ -285,7 +363,75 @@ def test_api_document_versions_retrieve_authenticated_related(via, mock_user_tea
assert response.status_code == 404
# Create a new version should not make it available to the user because
# only the current version is available to the user, but it is excluded
# only the current version is available to the user but it is excluded
# from the list
document.content = "new content 1"
document.save()
assert len(document.get_versions_slice()["versions"]) == 2
version_id = document.get_versions_slice()["versions"][0]["version_id"]
response = client.get(
f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/",
)
assert response.status_code == 404
# Adding one more version should make the previous version available to the user
document.content = "new content 2"
document.save()
assert len(document.get_versions_slice()["versions"]) == 3
version_id = document.get_versions_slice()["versions"][0]["version_id"]
response = client.get(
f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/",
)
assert response.status_code == 200
assert response.json()["content"] == "new content 1"
@pytest.mark.parametrize("via", VIA)
def test_api_document_versions_retrieve_authenticated_related_parent(
via, mock_user_teams
):
"""
A user who gains access to a document's versions via one of its ancestors, should be able to
retrieve the document versions. The date of access to the parent should be used to filter
versions that were created prior to the user gaining access to the document.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
grand_parent = factories.DocumentFactory()
parent = factories.DocumentFactory(parent=grand_parent)
document = factories.DocumentFactory(parent=parent)
document.content = "new content"
document.save()
assert len(document.get_versions_slice()["versions"]) == 1
version_id = document.get_versions_slice()["versions"][0]["version_id"]
if via == USER:
factories.UserDocumentAccessFactory(document=grand_parent, user=user)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(document=grand_parent, team="lasuite")
time.sleep(1) # minio stores datetimes with the precision of a second
# Versions created before the document was shared should not be seen by the user
response = client.get(
f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/",
)
assert response.status_code == 404
# Create a new version should not make it available to the user because
# only the current version is available to the user but it is excluded
# from the list
document.content = "new content 1"
document.save()
@@ -458,15 +604,19 @@ def test_api_document_versions_update_authenticated_related(via, mock_user_teams
# Delete
def test_api_document_versions_delete_anonymous():
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
def test_api_document_versions_delete_anonymous(reach):
"""Anonymous users should not be allowed to destroy a document version."""
access = factories.UserDocumentAccessFactory()
access = factories.UserDocumentAccessFactory(document__link_reach=reach)
response = APIClient().delete(
f"/api/v1.0/documents/{access.document_id!s}/versions/{access.id!s}/",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)

View File

@@ -134,7 +134,7 @@ def test_api_documents_ai_transform_authenticated_forbidden(reach, role):
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_transform_authenticated_success(mock_create, reach, role):
"""
Authenticated who are not related to a document should be able to request AI transform
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()

View File

@@ -154,7 +154,7 @@ def test_api_documents_ai_translate_authenticated_forbidden(reach, role):
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_translate_authenticated_success(mock_create, reach, role):
"""
Authenticated who are not related to a document should be able to request AI translate
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()

View File

@@ -64,12 +64,22 @@ def test_api_documents_attachment_upload_anonymous_success():
assert response.status_code == 201
pattern = re.compile(rf"^/media/{document.id!s}/attachments/(.*)\.png")
match = pattern.search(response.json()["file"])
file_path = response.json()["file"]
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": "None"}
assert file_head["ContentType"] == "image/png"
@pytest.mark.parametrize(
"reach, role",
@@ -111,7 +121,7 @@ def test_api_documents_attachment_upload_authenticated_forbidden(reach, role):
)
def test_api_documents_attachment_upload_authenticated_success(reach, role):
"""
Authenticated who are not related to a document should be able to upload a file
Autenticated who are not related to a document should be able to upload a file
if the link reach and role permit it.
"""
user = factories.UserFactory()
@@ -206,6 +216,7 @@ def test_api_documents_attachment_upload_success(via, role, mock_user_teams):
Bucket=default_storage.bucket_name, Key=key
)
assert file_head["Metadata"] == {"owner": str(user.id)}
assert file_head["ContentType"] == "image/png"
def test_api_documents_attachment_upload_invalid(client):
@@ -225,7 +236,7 @@ def test_api_documents_attachment_upload_invalid(client):
def test_api_documents_attachment_upload_size_limit_exceeded(settings):
"""The uploaded file should not exceed the maximum size in settings."""
"""The uploaded file should not exceeed the maximum size in settings."""
settings.DOCUMENT_IMAGE_MAX_SIZE = 1048576 # 1 MB for test
user = factories.UserFactory()
@@ -247,16 +258,18 @@ def test_api_documents_attachment_upload_size_limit_exceeded(settings):
@pytest.mark.parametrize(
"name,content,extension",
"name,content,extension,content_type",
[
("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"),
("test.exe", b"text", "exe", "text/plain"),
("test", b"text", "txt", "text/plain"),
("test.aaaaaa", b"test", "txt", "text/plain"),
("test.txt", PIXEL, "txt", "image/png"),
("test.py", b"#!/usr/bin/python", "py", "text/plain"),
],
)
def test_api_documents_attachment_upload_fix_extension(name, content, extension):
def test_api_documents_attachment_upload_fix_extension(
name, content, extension, content_type
):
"""
A file with no extension or a wrong extension is accepted and the extension
is corrected in storage.
@@ -287,6 +300,7 @@ def test_api_documents_attachment_upload_fix_extension(name, content, extension)
Bucket=default_storage.bucket_name, Key=key
)
assert file_head["Metadata"] == {"owner": str(user.id), "is_unsafe": "true"}
assert file_head["ContentType"] == content_type
def test_api_documents_attachment_upload_empty_file():
@@ -335,3 +349,4 @@ def test_api_documents_attachment_upload_unsafe():
Bucket=default_storage.bucket_name, Key=key
)
assert file_head["Metadata"] == {"owner": str(user.id), "is_unsafe": "true"}
assert file_head["ContentType"] == "application/octet-stream"

View File

@@ -0,0 +1,251 @@
"""
Tests for Documents API endpoint in impress's core app: create
"""
from uuid import uuid4
import pytest
from rest_framework.test import APIClient
from core import factories
from core.models import Document, LinkReachChoices, LinkRoleChoices
pytestmark = pytest.mark.django_db
@pytest.mark.parametrize("depth", [1, 2, 3])
@pytest.mark.parametrize("role", LinkRoleChoices.values)
@pytest.mark.parametrize("reach", LinkReachChoices.values)
def test_api_documents_children_create_anonymous(reach, role, depth):
"""Anonymous users should not be allowed to create children documents."""
for i in range(depth):
if i == 0:
document = factories.DocumentFactory(link_reach=reach, link_role=role)
else:
document = factories.DocumentFactory(parent=document)
response = APIClient().post(
f"/api/v1.0/documents/{document.id!s}/children/",
{
"title": "my document",
},
)
assert Document.objects.count() == depth
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
@pytest.mark.parametrize("depth", [1, 2, 3])
@pytest.mark.parametrize(
"reach,role",
[
["restricted", "editor"],
["restricted", "reader"],
["public", "reader"],
["authenticated", "reader"],
],
)
def test_api_documents_children_create_authenticated_forbidden(reach, role, depth):
"""
Authenticated users with no write access on a document should not be allowed
to create a nested document.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
for i in range(depth):
if i == 0:
document = factories.DocumentFactory(link_reach=reach, link_role=role)
else:
document = factories.DocumentFactory(parent=document, link_role="reader")
response = client.post(
f"/api/v1.0/documents/{document.id!s}/children/",
{
"title": "my document",
},
)
assert response.status_code == 403
assert Document.objects.count() == depth
@pytest.mark.parametrize("depth", [1, 2, 3])
@pytest.mark.parametrize(
"reach,role",
[
["public", "editor"],
["authenticated", "editor"],
],
)
def test_api_documents_children_create_authenticated_success(reach, role, depth):
"""
Authenticated users with write access on a document should be able
to create a nested document.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
for i in range(depth):
if i == 0:
document = factories.DocumentFactory(link_reach=reach, link_role=role)
else:
document = factories.DocumentFactory(parent=document, link_role="reader")
response = client.post(
f"/api/v1.0/documents/{document.id!s}/children/",
{
"title": "my child",
},
)
assert response.status_code == 201
child = Document.objects.get(id=response.json()["id"])
assert child.title == "my child"
assert child.link_reach == "restricted"
assert child.accesses.filter(role="owner", user=user).exists()
@pytest.mark.parametrize("depth", [1, 2, 3])
def test_api_documents_children_create_related_forbidden(depth):
"""
Authenticated users with a specific read access on a document should not be allowed
to create a nested document.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
for i in range(depth):
if i == 0:
document = factories.DocumentFactory(link_reach="restricted")
factories.UserDocumentAccessFactory(
user=user, document=document, role="reader"
)
else:
document = factories.DocumentFactory(
parent=document, link_reach="restricted"
)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/children/",
{
"title": "my document",
},
)
assert response.status_code == 403
assert Document.objects.count() == depth
@pytest.mark.parametrize("depth", [1, 2, 3])
@pytest.mark.parametrize("role", ["editor", "administrator", "owner"])
def test_api_documents_children_create_related_success(role, depth):
"""
Authenticated users with a specific write access on a document should be
able to create a nested document.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
for i in range(depth):
if i == 0:
document = factories.DocumentFactory(link_reach="restricted")
factories.UserDocumentAccessFactory(user=user, document=document, role=role)
else:
document = factories.DocumentFactory(
parent=document, link_reach="restricted"
)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/children/",
{
"title": "my child",
},
)
assert response.status_code == 201
child = Document.objects.get(id=response.json()["id"])
assert child.title == "my child"
assert child.link_reach == "restricted"
assert child.accesses.filter(role="owner", user=user).exists()
def test_api_documents_children_create_authenticated_title_null():
"""It should be possible to create several nested documents with a null title."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
parent = factories.DocumentFactory(
title=None, link_reach="authenticated", link_role="editor"
)
factories.DocumentFactory(title=None, parent=parent)
response = client.post(
f"/api/v1.0/documents/{parent.id!s}/children/", {}, format="json"
)
assert response.status_code == 201
assert Document.objects.filter(title__isnull=True).count() == 3
def test_api_documents_children_create_force_id_success():
"""It should be possible to force the document ID when creating a nested document."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
access = factories.UserDocumentAccessFactory(user=user, role="editor")
forced_id = uuid4()
response = client.post(
f"/api/v1.0/documents/{access.document.id!s}/children/",
{
"id": str(forced_id),
"title": "my document",
},
format="json",
)
assert response.status_code == 201
assert Document.objects.count() == 2
assert response.json()["id"] == str(forced_id)
def test_api_documents_children_create_force_id_existing():
"""
It should not be possible to use the ID of an existing document when forcing ID on creation.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
access = factories.UserDocumentAccessFactory(user=user, role="editor")
document = factories.DocumentFactory()
response = client.post(
f"/api/v1.0/documents/{access.document.id!s}/children/",
{
"id": str(document.id),
"title": "my document",
},
format="json",
)
assert response.status_code == 400
assert response.json() == {
"id": ["A document with this ID already exists. You cannot override it."]
}

View File

@@ -0,0 +1,541 @@
"""
Tests for Documents API endpoint in impress's core app: retrieve
"""
import random
from django.contrib.auth.models import AnonymousUser
import pytest
from rest_framework.test import APIClient
from core import factories
pytestmark = pytest.mark.django_db
def test_api_documents_children_list_anonymous_public_standalone():
"""Anonymous users should be allowed to retrieve the children of a public documents."""
document = factories.DocumentFactory(link_reach="public")
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
factories.UserDocumentAccessFactory(document=child1)
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/children/")
assert response.status_code == 200
assert response.json() == {
"count": 2,
"next": None,
"previous": None,
"results": [
{
"abilities": child1.get_abilities(AnonymousUser()),
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"depth": 2,
"excerpt": child1.excerpt,
"id": str(child1.id),
"is_favorite": False,
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 0,
"nb_accesses": 1,
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
},
{
"abilities": child2.get_abilities(AnonymousUser()),
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"depth": 2,
"excerpt": child2.excerpt,
"id": str(child2.id),
"is_favorite": False,
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
"nb_accesses": 0,
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
},
],
}
def test_api_documents_children_list_anonymous_public_parent():
"""
Anonymous users should be allowed to retrieve the children of a document who
has a public ancestor.
"""
grand_parent = factories.DocumentFactory(link_reach="public")
parent = factories.DocumentFactory(
parent=grand_parent, link_reach=random.choice(["authenticated", "restricted"])
)
document = factories.DocumentFactory(
link_reach=random.choice(["authenticated", "restricted"]), parent=parent
)
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
factories.UserDocumentAccessFactory(document=child1)
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/children/")
assert response.status_code == 200
assert response.json() == {
"count": 2,
"next": None,
"previous": None,
"results": [
{
"abilities": child1.get_abilities(AnonymousUser()),
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"depth": 4,
"excerpt": child1.excerpt,
"id": str(child1.id),
"is_favorite": False,
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 0,
"nb_accesses": 1,
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
},
{
"abilities": child2.get_abilities(AnonymousUser()),
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"depth": 4,
"excerpt": child2.excerpt,
"id": str(child2.id),
"is_favorite": False,
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
"nb_accesses": 0,
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
},
],
}
@pytest.mark.parametrize("reach", ["restricted", "authenticated"])
def test_api_documents_children_list_anonymous_restricted_or_authenticated(reach):
"""
Anonymous users should not be able to retrieve children of a document that is not public.
"""
document = factories.DocumentFactory(link_reach=reach)
factories.DocumentFactory.create_batch(2, parent=document)
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/children/")
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
@pytest.mark.parametrize("reach", ["public", "authenticated"])
def test_api_documents_children_list_authenticated_unrelated_public_or_authenticated(
reach,
):
"""
Authenticated users should be able to retrieve the children of a public/authenticated
document to which they are not related.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach)
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
factories.UserDocumentAccessFactory(document=child1)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/children/",
)
assert response.status_code == 200
assert response.json() == {
"count": 2,
"next": None,
"previous": None,
"results": [
{
"abilities": child1.get_abilities(user),
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"depth": 2,
"excerpt": child1.excerpt,
"id": str(child1.id),
"is_favorite": False,
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 0,
"nb_accesses": 1,
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
},
{
"abilities": child2.get_abilities(user),
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"depth": 2,
"excerpt": child2.excerpt,
"id": str(child2.id),
"is_favorite": False,
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
"nb_accesses": 0,
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
},
],
}
@pytest.mark.parametrize("reach", ["public", "authenticated"])
def test_api_documents_children_list_authenticated_public_or_authenticated_parent(
reach,
):
"""
Authenticated users should be allowed to retrieve the children of a document who
has a public or authenticated ancestor.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
grand_parent = factories.DocumentFactory(link_reach=reach)
parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted")
document = factories.DocumentFactory(link_reach="restricted", parent=parent)
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
factories.UserDocumentAccessFactory(document=child1)
response = client.get(f"/api/v1.0/documents/{document.id!s}/children/")
assert response.status_code == 200
assert response.json() == {
"count": 2,
"next": None,
"previous": None,
"results": [
{
"abilities": child1.get_abilities(user),
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"depth": 4,
"excerpt": child1.excerpt,
"id": str(child1.id),
"is_favorite": False,
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 0,
"nb_accesses": 1,
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
},
{
"abilities": child2.get_abilities(user),
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"depth": 4,
"excerpt": child2.excerpt,
"id": str(child2.id),
"is_favorite": False,
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
"nb_accesses": 0,
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
},
],
}
def test_api_documents_children_list_authenticated_unrelated_restricted():
"""
Authenticated users should not be allowed to retrieve the children of a document that is
restricted and to which they are not related.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="restricted")
child1, _child2 = factories.DocumentFactory.create_batch(2, parent=document)
factories.UserDocumentAccessFactory(document=child1)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/children/",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
def test_api_documents_children_list_authenticated_related_direct():
"""
Authenticated users should be allowed to retrieve the children of a document
to which they are directly related whatever the role.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
access = factories.UserDocumentAccessFactory(document=document, user=user)
factories.UserDocumentAccessFactory(document=document)
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
factories.UserDocumentAccessFactory(document=child1)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/children/",
)
assert response.status_code == 200
assert response.json() == {
"count": 2,
"next": None,
"previous": None,
"results": [
{
"abilities": child1.get_abilities(user),
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"depth": 2,
"excerpt": child1.excerpt,
"id": str(child1.id),
"is_favorite": False,
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 0,
"nb_accesses": 3,
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role],
},
{
"abilities": child2.get_abilities(user),
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"depth": 2,
"excerpt": child2.excerpt,
"id": str(child2.id),
"is_favorite": False,
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
"nb_accesses": 2,
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role],
},
],
}
def test_api_documents_children_list_authenticated_related_parent():
"""
Authenticated users should be allowed to retrieve the children of a document if they
are related to one of its ancestors whatever the role.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
grand_parent = factories.DocumentFactory(link_reach="restricted")
parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted")
document = factories.DocumentFactory(parent=parent, link_reach="restricted")
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
factories.UserDocumentAccessFactory(document=child1)
grand_parent_access = factories.UserDocumentAccessFactory(
document=grand_parent, user=user
)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/children/",
)
assert response.status_code == 200
assert response.json() == {
"count": 2,
"next": None,
"previous": None,
"results": [
{
"abilities": child1.get_abilities(user),
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"depth": 4,
"excerpt": child1.excerpt,
"id": str(child1.id),
"is_favorite": False,
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 0,
"nb_accesses": 2,
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [grand_parent_access.role],
},
{
"abilities": child2.get_abilities(user),
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"depth": 4,
"excerpt": child2.excerpt,
"id": str(child2.id),
"is_favorite": False,
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
"nb_accesses": 1,
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [grand_parent_access.role],
},
],
}
def test_api_documents_children_list_authenticated_related_child():
"""
Authenticated users should not be allowed to retrieve all the children of a document
as a result of being related to one of its children.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="restricted")
child1, _child2 = factories.DocumentFactory.create_batch(2, parent=document)
factories.UserDocumentAccessFactory(document=child1, user=user)
factories.UserDocumentAccessFactory(document=document)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/children/",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
def test_api_documents_children_list_authenticated_related_team_none(mock_user_teams):
"""
Authenticated users should not be able to retrieve the children of a restricted document
related to teams in which the user is not.
"""
mock_user_teams.return_value = []
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="restricted")
factories.DocumentFactory.create_batch(2, parent=document)
factories.TeamDocumentAccessFactory(document=document, team="myteam")
response = client.get(f"/api/v1.0/documents/{document.id!s}/children/")
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
def test_api_documents_children_list_authenticated_related_team_members(
mock_user_teams,
):
"""
Authenticated users should be allowed to retrieve the children of a document to which they
are related via a team whatever the role.
"""
mock_user_teams.return_value = ["myteam"]
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="restricted")
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
access = factories.TeamDocumentAccessFactory(document=document, team="myteam")
response = client.get(f"/api/v1.0/documents/{document.id!s}/children/")
# pylint: disable=R0801
assert response.status_code == 200
assert response.json() == {
"count": 2,
"next": None,
"previous": None,
"results": [
{
"abilities": child1.get_abilities(user),
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"depth": 2,
"excerpt": child1.excerpt,
"id": str(child1.id),
"is_favorite": False,
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 0,
"nb_accesses": 1,
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role],
},
{
"abilities": child2.get_abilities(user),
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"depth": 2,
"excerpt": child2.excerpt,
"id": str(child2.id),
"is_favorite": False,
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
"nb_accesses": 1,
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role],
},
],
}

View File

@@ -13,6 +13,7 @@ import pytest
from rest_framework.test import APIClient
from core import factories
from core.api.serializers import ServerCreateDocumentSerializer
from core.models import Document, Invitation, User
from core.services.converter_services import ConversionError, YdocConverter
@@ -20,7 +21,7 @@ pytestmark = pytest.mark.django_db
@pytest.fixture
def mock_convert_markdown():
def mock_convert_md():
"""Mock YdocConverter.convert_markdown to return a converted content."""
with patch.object(
YdocConverter,
@@ -169,8 +170,11 @@ def test_api_documents_create_for_owner_invalid_sub():
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"])
def test_api_documents_create_for_owner_existing(mock_convert_markdown):
"""It should be possible to create a document on behalf of a pre-existing user."""
def test_api_documents_create_for_owner_existing(mock_convert_md):
"""
It should be possible to create a document on behalf of a pre-existing user
by passing their sub and email.
"""
user = factories.UserFactory(language="en-us")
data = {
@@ -189,7 +193,7 @@ def test_api_documents_create_for_owner_existing(mock_convert_markdown):
assert response.status_code == 201
mock_convert_markdown.assert_called_once_with("Document content")
mock_convert_md.assert_called_once_with("Document content")
document = Document.objects.get()
assert response.json() == {"id": str(document.id)}
@@ -213,10 +217,10 @@ def test_api_documents_create_for_owner_existing(mock_convert_markdown):
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"])
def test_api_documents_create_for_owner_new_user(mock_convert_markdown):
def test_api_documents_create_for_owner_new_user(mock_convert_md):
"""
It should be possible to create a document on behalf of new users by
passing only their email address.
passing their unknown sub and email address.
"""
data = {
"title": "My Document",
@@ -234,7 +238,7 @@ def test_api_documents_create_for_owner_new_user(mock_convert_markdown):
assert response.status_code == 201
mock_convert_markdown.assert_called_once_with("Document content")
mock_convert_md.assert_called_once_with("Document content")
document = Document.objects.get()
assert response.json() == {"id": str(document.id)}
@@ -264,8 +268,190 @@ def test_api_documents_create_for_owner_new_user(mock_convert_markdown):
assert document.creator == user
@override_settings(
SERVER_TO_SERVER_API_TOKENS=["DummyToken"],
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION=True,
)
def test_api_documents_create_for_owner_existing_user_email_no_sub_with_fallback(
mock_convert_md,
):
"""
It should be possible to create a document on behalf of a pre-existing user for
who the sub was not found if the settings allow it. This edge case should not
happen in a healthy OIDC federation but can be usefull if an OIDC provider modifies
users sub on each login for example...
"""
user = factories.UserFactory(language="en-us")
data = {
"title": "My Document",
"content": "Document content",
"sub": "123",
"email": user.email,
}
response = APIClient().post(
"/api/v1.0/documents/create-for-owner/",
data,
format="json",
HTTP_AUTHORIZATION="Bearer DummyToken",
)
assert response.status_code == 201
mock_convert_md.assert_called_once_with("Document content")
document = Document.objects.get()
assert response.json() == {"id": str(document.id)}
assert document.title == "My Document"
assert document.content == "Converted document content"
assert document.creator == user
assert document.accesses.filter(user=user, role="owner").exists()
assert Invitation.objects.exists() is False
assert len(mail.outbox) == 1
email = mail.outbox[0]
assert email.to == [user.email]
assert email.subject == "A new document was created on your behalf!"
email_content = " ".join(email.body.split())
assert "A new document was created on your behalf!" in email_content
assert (
"You have been granted ownership of a new document: My Document"
) in email_content
@override_settings(
SERVER_TO_SERVER_API_TOKENS=["DummyToken"],
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION=False,
OIDC_ALLOW_DUPLICATE_EMAILS=False,
)
def test_api_documents_create_for_owner_existing_user_email_no_sub_no_fallback(
mock_convert_md,
):
"""
When a user does not match an existing sub and fallback to matching on email is
not allowed in settings, it should raise an error if the email is already used by
a registered user and duplicate emails are not allowed.
"""
user = factories.UserFactory()
data = {
"title": "My Document",
"content": "Document content",
"sub": "123",
"email": user.email,
}
response = APIClient().post(
"/api/v1.0/documents/create-for-owner/",
data,
format="json",
HTTP_AUTHORIZATION="Bearer DummyToken",
)
assert response.status_code == 400
assert response.json() == {
"email": [
(
"We couldn't find a user with this sub but the email is already "
"associated with a registered user."
)
]
}
assert mock_convert_md.called is False
assert Document.objects.exists() is False
assert Invitation.objects.exists() is False
assert len(mail.outbox) == 0
@override_settings(
SERVER_TO_SERVER_API_TOKENS=["DummyToken"],
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION=False,
OIDC_ALLOW_DUPLICATE_EMAILS=True,
)
def test_api_documents_create_for_owner_new_user_no_sub_no_fallback_allow_duplicate(
mock_convert_md,
):
"""
When a user does not match an existing sub and fallback to matching on email is
not allowed in settings, it should be possible to create a new user with the same
email as an existing user if the settings allow it (identification is still done
via the sub in this case).
"""
user = factories.UserFactory()
data = {
"title": "My Document",
"content": "Document content",
"sub": "123",
"email": user.email,
}
response = APIClient().post(
"/api/v1.0/documents/create-for-owner/",
data,
format="json",
HTTP_AUTHORIZATION="Bearer DummyToken",
)
assert response.status_code == 201
mock_convert_md.assert_called_once_with("Document content")
document = Document.objects.get()
assert response.json() == {"id": str(document.id)}
assert document.title == "My Document"
assert document.content == "Converted document content"
assert document.creator is None
assert document.accesses.exists() is False
invitation = Invitation.objects.get()
assert invitation.email == user.email
assert invitation.role == "owner"
assert len(mail.outbox) == 1
email = mail.outbox[0]
assert email.to == [user.email]
assert email.subject == "A new document was created on your behalf!"
email_content = " ".join(email.body.split())
assert "A new document was created on your behalf!" in email_content
assert (
"You have been granted ownership of a new document: My Document"
) in email_content
# The creator field on the document should be set when the user is created
user = User.objects.create(email=user.email, password="!")
document.refresh_from_db()
assert document.creator == user
@patch.object(ServerCreateDocumentSerializer, "_send_email_notification")
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"], LANGUAGE_CODE="de-de")
def test_api_documents_create_for_owner_with_default_language(
mock_send, mock_convert_md
):
"""The default language from settings should apply by default."""
data = {
"title": "My Document",
"content": "Document content",
"sub": "123",
"email": "john.doe@example.com",
}
response = APIClient().post(
"/api/v1.0/documents/create-for-owner/",
data,
format="json",
HTTP_AUTHORIZATION="Bearer DummyToken",
)
assert response.status_code == 201
mock_convert_md.assert_called_once_with("Document content")
assert mock_send.call_args[0][3] == "de-de"
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"])
def test_api_documents_create_for_owner_with_custom_language(mock_convert_markdown):
def test_api_documents_create_for_owner_with_custom_language(mock_convert_md):
"""
Test creating a document with a specific language.
Useful if the remote server knows the user's language.
@@ -287,7 +473,7 @@ def test_api_documents_create_for_owner_with_custom_language(mock_convert_markdo
assert response.status_code == 201
mock_convert_markdown.assert_called_once_with("Document content")
mock_convert_md.assert_called_once_with("Document content")
assert len(mail.outbox) == 1
email = mail.outbox[0]
@@ -302,7 +488,7 @@ def test_api_documents_create_for_owner_with_custom_language(mock_convert_markdo
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"])
def test_api_documents_create_for_owner_with_custom_subject_and_message(
mock_convert_markdown,
mock_convert_md,
):
"""It should be possible to customize the subject and message of the invitation email."""
data = {
@@ -323,7 +509,7 @@ def test_api_documents_create_for_owner_with_custom_subject_and_message(
assert response.status_code == 201
mock_convert_markdown.assert_called_once_with("Document content")
mock_convert_md.assert_called_once_with("Document content")
assert len(mail.outbox) == 1
email = mail.outbox[0]
@@ -336,11 +522,11 @@ def test_api_documents_create_for_owner_with_custom_subject_and_message(
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"])
def test_api_documents_create_for_owner_with_converter_exception(
mock_convert_markdown,
mock_convert_md,
):
"""It should be possible to customize the subject and message of the invitation email."""
"""In case of converter error, a 400 error should be raised."""
mock_convert_markdown.side_effect = ConversionError("Conversion failed")
mock_convert_md.side_effect = ConversionError("Conversion failed")
data = {
"title": "My Document",
@@ -357,8 +543,33 @@ def test_api_documents_create_for_owner_with_converter_exception(
format="json",
HTTP_AUTHORIZATION="Bearer DummyToken",
)
mock_convert_md.assert_called_once_with("Document content")
mock_convert_markdown.assert_called_once_with("Document content")
assert response.status_code == 400
assert response.json() == {"content": ["Could not convert content"]}
assert response.status_code == 500
assert response.json() == {"detail": "could not convert content"}
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"])
def test_api_documents_create_for_owner_with_empty_content():
"""The content should not be empty or a 400 error should be raised."""
data = {
"title": "My Document",
"content": " ",
"sub": "123",
"email": "john.doe@example.com",
}
response = APIClient().post(
"/api/v1.0/documents/create-for-owner/",
data,
format="json",
HTTP_AUTHORIZATION="Bearer DummyToken",
)
assert response.status_code == 400
assert response.json() == {
"content": [
"This field may not be blank.",
],
}

View File

@@ -77,6 +77,37 @@ def test_api_documents_delete_authenticated_not_owner(via, role, mock_user_teams
assert models.Document.objects.count() == 2
@pytest.mark.parametrize("depth", [1, 2, 3])
def test_api_documents_delete_authenticated_owner_of_ancestor(depth):
"""
Authenticated users should not be able to delete a document for which
they are only owner of an ancestor.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
documents = []
for i in range(depth):
documents.append(
factories.UserDocumentAccessFactory(role="owner", user=user).document
if i == 0
else factories.DocumentFactory(parent=documents[-1])
)
assert models.Document.objects.count() == depth
response = client.delete(
f"/api/v1.0/documents/{documents[-1].id}/",
)
assert response.status_code == 204
# Make sure it is only a soft delete
assert models.Document.objects.count() == depth
assert models.Document.objects.filter(deleted_at__isnull=True).count() == depth - 1
assert models.Document.objects.filter(deleted_at__isnull=False).count() == 1
@pytest.mark.parametrize("via", VIA)
def test_api_documents_delete_authenticated_owner(via, mock_user_teams):
"""
@@ -101,4 +132,8 @@ def test_api_documents_delete_authenticated_owner(via, mock_user_teams):
)
assert response.status_code == 204
assert models.Document.objects.exists() is False
# Make sure it is only a soft delete
assert models.Document.objects.count() == 1
assert models.Document.objects.filter(deleted_at__isnull=True).exists() is False
assert models.Document.objects.filter(deleted_at__isnull=False).count() == 1

View File

@@ -2,10 +2,11 @@
Tests for Documents API endpoint in impress's core app: list
"""
import operator
import random
from datetime import timedelta
from unittest import mock
from urllib.parse import urlencode
from django.utils import timezone
import pytest
from faker import Faker
@@ -23,7 +24,7 @@ pytestmark = pytest.mark.django_db
def test_api_documents_list_anonymous(reach, role):
"""
Anonymous users should not be allowed to list documents whatever the
link reach and the role
link reach and link role
"""
factories.DocumentFactory(link_reach=reach, link_role=role)
@@ -37,16 +38,16 @@ def test_api_documents_list_anonymous(reach, role):
def test_api_documents_list_format():
"""Validate the format of documents as returned by the list view."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
other_users = factories.UserFactory.create_batch(3)
document = factories.DocumentFactory(
users=[user, *factories.UserFactory.create_batch(2)],
users=factories.UserFactory.create_batch(2),
favorited_by=[user, *other_users],
link_traces=other_users,
)
access = factories.UserDocumentAccessFactory(document=document, user=user)
response = client.get("/api/v1.0/documents/")
@@ -62,18 +63,23 @@ def test_api_documents_list_format():
assert results[0] == {
"id": str(document.id),
"abilities": document.get_abilities(user),
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"depth": 1,
"excerpt": document.excerpt,
"is_favorite": True,
"link_reach": document.link_reach,
"link_role": document.link_role,
"nb_accesses": 3,
"numchild": 0,
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role],
}
# pylint: disable=too-many-locals
def test_api_documents_list_authenticated_direct(django_assert_num_queries):
"""
Authenticated users should be able to list documents they are a direct
@@ -81,11 +87,10 @@ def test_api_documents_list_authenticated_direct(django_assert_num_queries):
than restricted.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
documents = [
document1, document2 = [
access.document
for access in factories.UserDocumentAccessFactory.create_batch(2, user=user)
]
@@ -95,16 +100,64 @@ def test_api_documents_list_authenticated_direct(django_assert_num_queries):
for role in models.LinkRoleChoices:
factories.DocumentFactory(link_reach=reach, link_role=role)
expected_ids = {str(document.id) for document in documents}
# Children of visible documents should not get listed even with a specific access
factories.DocumentFactory(parent=document1)
with django_assert_num_queries(3):
child1_with_access = factories.DocumentFactory(parent=document1)
factories.UserDocumentAccessFactory(user=user, document=child1_with_access)
middle_document = factories.DocumentFactory(parent=document2)
child2_with_access = factories.DocumentFactory(parent=middle_document)
factories.UserDocumentAccessFactory(user=user, document=child2_with_access)
# Children of hidden documents should get listed when visible by the logged-in user
hidden_root = factories.DocumentFactory()
child3_with_access = factories.DocumentFactory(parent=hidden_root)
factories.UserDocumentAccessFactory(user=user, document=child3_with_access)
child4_with_access = factories.DocumentFactory(parent=hidden_root)
factories.UserDocumentAccessFactory(user=user, document=child4_with_access)
# Documents that are soft deleted and children of a soft deleted document should not be listed
soft_deleted_document = factories.DocumentFactory(users=[user])
child_of_soft_deleted_document = factories.DocumentFactory(
users=[user],
parent=soft_deleted_document,
)
factories.DocumentFactory(users=[user], parent=child_of_soft_deleted_document)
soft_deleted_document.soft_delete()
# Documents that are permanently deleted and children of a permanently deleted
# document should not be listed
permanently_deleted_document = factories.DocumentFactory(users=[user])
child_of_permanently_deleted_document = factories.DocumentFactory(
users=[user], parent=permanently_deleted_document
)
factories.DocumentFactory(
users=[user], parent=child_of_permanently_deleted_document
)
fourty_days_ago = timezone.now() - timedelta(days=40)
with mock.patch("django.utils.timezone.now", return_value=fourty_days_ago):
permanently_deleted_document.soft_delete()
expected_ids = {
str(document1.id),
str(document2.id),
str(child3_with_access.id),
str(child4_with_access.id),
}
with django_assert_num_queries(8):
response = client.get("/api/v1.0/documents/")
# nb_accesses should now be cached
with django_assert_num_queries(4):
response = client.get("/api/v1.0/documents/")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 2
results_id = {result["id"] for result in results}
assert expected_ids == results_id
results_ids = {result["id"] for result in results}
assert expected_ids == results_ids
def test_api_documents_list_authenticated_via_team(
@@ -132,7 +185,11 @@ def test_api_documents_list_authenticated_via_team(
expected_ids = {str(document.id) for document in documents_team1 + documents_team2}
with django_assert_num_queries(3):
with django_assert_num_queries(9):
response = client.get("/api/v1.0/documents/")
# nb_accesses should now be cached
with django_assert_num_queries(4):
response = client.get("/api/v1.0/documents/")
assert response.status_code == 200
@@ -161,10 +218,12 @@ 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)
with django_assert_num_queries(3):
response = client.get(
"/api/v1.0/documents/",
)
with django_assert_num_queries(5):
response = client.get("/api/v1.0/documents/")
# nb_accesses should now be cached
with django_assert_num_queries(4):
response = client.get("/api/v1.0/documents/")
assert response.status_code == 200
results = response.json()["results"]
@@ -186,21 +245,37 @@ def test_api_documents_list_authenticated_link_reach_public_or_authenticated(
client = APIClient()
client.force_login(user)
documents = [
document1, document2 = [
factories.DocumentFactory(link_traces=[user], link_reach=reach)
for reach in models.LinkReachChoices
if reach != "restricted"
]
expected_ids = {str(document.id) for document in documents}
factories.DocumentFactory(
link_reach=random.choice(["public", "authenticated"]),
link_traces=[user],
parent=document1,
)
with django_assert_num_queries(3):
response = client.get(
"/api/v1.0/documents/",
)
hidden_document = factories.DocumentFactory(
link_reach=random.choice(["public", "authenticated"])
)
visible_child = factories.DocumentFactory(
link_traces=[user],
link_reach=random.choice(["public", "authenticated"]),
parent=hidden_document,
)
expected_ids = {str(document1.id), str(document2.id), str(visible_child.id)}
with django_assert_num_queries(7):
response = client.get("/api/v1.0/documents/")
# nb_accesses should now be cached
with django_assert_num_queries(4):
response = client.get("/api/v1.0/documents/")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 2
results_id = {result["id"] for result in results}
assert expected_ids == results_id
@@ -287,7 +362,11 @@ def test_api_documents_list_favorites_no_extra_queries(django_assert_num_queries
factories.DocumentFactory.create_batch(2, users=[user])
url = "/api/v1.0/documents/"
with django_assert_num_queries(3):
with django_assert_num_queries(9):
response = client.get(url)
# nb_accesses should now be cached
with django_assert_num_queries(4):
response = client.get(url)
assert response.status_code == 200
@@ -300,7 +379,7 @@ def test_api_documents_list_favorites_no_extra_queries(django_assert_num_queries
for document in special_documents:
models.DocumentFavorite.objects.create(document=document, user=user)
with django_assert_num_queries(3):
with django_assert_num_queries(4):
response = client.get(url)
assert response.status_code == 200
@@ -314,361 +393,3 @@ def test_api_documents_list_favorites_no_extra_queries(django_assert_num_queries
assert result["is_favorite"] is True
else:
assert result["is_favorite"] is False
def test_api_documents_list_filter_and_access_rights():
"""Filtering on querystring parameters should respect access rights."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
other_user = factories.UserFactory()
def random_favorited_by():
return random.choice([[], [user], [other_user]])
# Documents that should be listed to this user
listed_documents = [
factories.DocumentFactory(
link_reach="public",
link_traces=[user],
favorited_by=random_favorited_by(),
creator=random.choice([user, other_user]),
),
factories.DocumentFactory(
link_reach="authenticated",
link_traces=[user],
favorited_by=random_favorited_by(),
creator=random.choice([user, other_user]),
),
factories.DocumentFactory(
link_reach="restricted",
users=[user],
favorited_by=random_favorited_by(),
creator=random.choice([user, other_user]),
),
]
listed_ids = [str(doc.id) for doc in listed_documents]
word_list = [word for doc in listed_documents for word in doc.title.split(" ")]
# Documents that should not be listed to this user
factories.DocumentFactory(
link_reach="public",
favorited_by=random_favorited_by(),
creator=random.choice([user, other_user]),
)
factories.DocumentFactory(
link_reach="authenticated",
favorited_by=random_favorited_by(),
creator=random.choice([user, other_user]),
)
factories.DocumentFactory(
link_reach="restricted",
favorited_by=random_favorited_by(),
creator=random.choice([user, other_user]),
)
factories.DocumentFactory(
link_reach="restricted",
link_traces=[user],
favorited_by=random_favorited_by(),
creator=random.choice([user, other_user]),
)
filters = {
"link_reach": random.choice([None, *models.LinkReachChoices.values]),
"title": random.choice([None, *word_list]),
"favorite": random.choice([None, True, False]),
"creator": random.choice([None, user, other_user]),
"ordering": random.choice(
[
None,
"created_at",
"-created_at",
"is_favorite",
"-is_favorite",
"nb_accesses",
"-nb_accesses",
"title",
"-title",
"updated_at",
"-updated_at",
]
),
}
query_params = {key: value for key, value in filters.items() if value is not None}
querystring = urlencode(query_params)
response = client.get(f"/api/v1.0/documents/?{querystring:s}")
assert response.status_code == 200
results = response.json()["results"]
# Ensure all documents in results respect expected access rights
for result in results:
assert result["id"] in listed_ids
# Filters: ordering
def test_api_documents_list_ordering_default():
"""Documents should be ordered by descending "updated_at" by default"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(5, users=[user])
response = client.get("/api/v1.0/documents/")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 5
# Check that results are sorted by descending "updated_at" as expected
for i in range(4):
assert operator.ge(results[i]["updated_at"], results[i + 1]["updated_at"])
def test_api_documents_list_ordering_by_fields():
"""It should be possible to order by several fields"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(5, users=[user])
for parameter in [
"created_at",
"-created_at",
"is_favorite",
"-is_favorite",
"nb_accesses",
"-nb_accesses",
"title",
"-title",
"updated_at",
"-updated_at",
]:
is_descending = parameter.startswith("-")
field = parameter.lstrip("-")
querystring = f"?ordering={parameter}"
response = client.get(f"/api/v1.0/documents/{querystring:s}")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 5
# Check that results are sorted by the field in querystring as expected
compare = operator.ge if is_descending else operator.le
for i in range(4):
assert compare(results[i][field], results[i + 1][field])
# Filters: is_creator_me
def test_api_documents_list_filter_is_creator_me_true():
"""
Authenticated users should be able to filter documents they created.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(3, users=[user], creator=user)
factories.DocumentFactory.create_batch(2, users=[user])
response = client.get("/api/v1.0/documents/?is_creator_me=true")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 3
# Ensure all results are created by the current user
for result in results:
assert result["creator"] == str(user.id)
def test_api_documents_list_filter_is_creator_me_false():
"""
Authenticated users should be able to filter documents created by others.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(3, users=[user], creator=user)
factories.DocumentFactory.create_batch(2, users=[user])
response = client.get("/api/v1.0/documents/?is_creator_me=false")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 2
# Ensure all results are created by other users
for result in results:
assert result["creator"] != str(user.id)
def test_api_documents_list_filter_is_creator_me_invalid():
"""Filtering with an invalid `is_creator_me` value should do nothing."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(3, users=[user], creator=user)
factories.DocumentFactory.create_batch(2, users=[user])
response = client.get("/api/v1.0/documents/?is_creator_me=invalid")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 5
# Filters: is_favorite
def test_api_documents_list_filter_is_favorite_true():
"""
Authenticated users should be able to filter documents they marked as favorite.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(3, users=[user], favorited_by=[user])
factories.DocumentFactory.create_batch(2, users=[user])
response = client.get("/api/v1.0/documents/?is_favorite=true")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 3
# Ensure all results are marked as favorite by the current user
for result in results:
assert result["is_favorite"] is True
def test_api_documents_list_filter_is_favorite_false():
"""
Authenticated users should be able to filter documents they didn't mark as favorite.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(3, users=[user], favorited_by=[user])
factories.DocumentFactory.create_batch(2, users=[user])
response = client.get("/api/v1.0/documents/?is_favorite=false")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 2
# Ensure all results are not marked as favorite by the current user
for result in results:
assert result["is_favorite"] is False
def test_api_documents_list_filter_is_favorite_invalid():
"""Filtering with an invalid `is_favorite` value should do nothing."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(3, users=[user], favorited_by=[user])
factories.DocumentFactory.create_batch(2, users=[user])
response = client.get("/api/v1.0/documents/?is_favorite=invalid")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 5
# Filters: link_reach
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
def test_api_documents_list_filter_link_reach(reach):
"""Authenticated users should be able to filter documents by link reach."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(5, users=[user])
response = client.get(f"/api/v1.0/documents/?link_reach={reach:s}")
assert response.status_code == 200
results = response.json()["results"]
# Ensure all results have the chosen link reach
for result in results:
assert result["link_reach"] == reach
def test_api_documents_list_filter_link_reach_invalid():
"""Filtering with an invalid `link_reach` value should raise an error."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(3, users=[user])
response = client.get("/api/v1.0/documents/?link_reach=invalid")
assert response.status_code == 400
assert response.json() == {
"link_reach": [
"Select a valid choice. invalid is not one of the available choices."
]
}
# Filters: title
@pytest.mark.parametrize(
"query,nb_results",
[
("Project Alpha", 1), # Exact match
("project", 2), # Partial match (case-insensitive)
("Guide", 1), # Word match within a title
("Special", 0), # No match (nonexistent keyword)
("2024", 2), # Match by numeric keyword
("", 5), # Empty string
],
)
def test_api_documents_list_filter_title(query, nb_results):
"""Authenticated users should be able to search documents by their title."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Create documents with predefined titles
titles = [
"Project Alpha Documentation",
"Project Beta Overview",
"User Guide",
"Financial Report 2024",
"Annual Review 2024",
]
for title in titles:
factories.DocumentFactory(title=title, users=[user])
# Perform the search query
response = client.get(f"/api/v1.0/documents/?title={query:s}")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == nb_results
# Ensure all results contain the query in their title
for result in results:
assert query.lower().strip() in result["title"].lower()

View File

@@ -0,0 +1,356 @@
"""
Tests for Documents API endpoint in impress's core app: list
"""
import operator
import random
from urllib.parse import urlencode
import pytest
from faker import Faker
from rest_framework.test import APIClient
from core import factories, models
fake = Faker()
pytestmark = pytest.mark.django_db
def test_api_documents_list_filter_and_access_rights():
"""Filtering on querystring parameters should respect access rights."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
other_user = factories.UserFactory()
def random_favorited_by():
return random.choice([[], [user], [other_user]])
# Documents that should be listed to this user
listed_documents = [
factories.DocumentFactory(
link_reach="public",
link_traces=[user],
favorited_by=random_favorited_by(),
creator=random.choice([user, other_user]),
),
factories.DocumentFactory(
link_reach="authenticated",
link_traces=[user],
favorited_by=random_favorited_by(),
creator=random.choice([user, other_user]),
),
factories.DocumentFactory(
link_reach="restricted",
users=[user],
favorited_by=random_favorited_by(),
creator=random.choice([user, other_user]),
),
]
listed_ids = [str(doc.id) for doc in listed_documents]
word_list = [word for doc in listed_documents for word in doc.title.split(" ")]
# Documents that should not be listed to this user
factories.DocumentFactory(
link_reach="public",
favorited_by=random_favorited_by(),
creator=random.choice([user, other_user]),
)
factories.DocumentFactory(
link_reach="authenticated",
favorited_by=random_favorited_by(),
creator=random.choice([user, other_user]),
)
factories.DocumentFactory(
link_reach="restricted",
favorited_by=random_favorited_by(),
creator=random.choice([user, other_user]),
)
factories.DocumentFactory(
link_reach="restricted",
link_traces=[user],
favorited_by=random_favorited_by(),
creator=random.choice([user, other_user]),
)
filters = {
"link_reach": random.choice([None, *models.LinkReachChoices.values]),
"title": random.choice([None, *word_list]),
"favorite": random.choice([None, True, False]),
"creator": random.choice([None, user, other_user]),
"ordering": random.choice(
[
None,
"created_at",
"-created_at",
"is_favorite",
"-is_favorite",
"title",
"-title",
"updated_at",
"-updated_at",
]
),
}
query_params = {key: value for key, value in filters.items() if value is not None}
querystring = urlencode(query_params)
response = client.get(f"/api/v1.0/documents/?{querystring:s}")
assert response.status_code == 200
results = response.json()["results"]
# Ensure all documents in results respect expected access rights
for result in results:
assert result["id"] in listed_ids
# Filters: ordering
def test_api_documents_list_ordering_default():
"""Documents should be ordered by descending "updated_at" by default"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(5, users=[user])
response = client.get("/api/v1.0/documents/")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 5
# Check that results are sorted by descending "updated_at" as expected
for i in range(4):
assert operator.ge(results[i]["updated_at"], results[i + 1]["updated_at"])
def test_api_documents_list_ordering_by_fields():
"""It should be possible to order by several fields"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(5, users=[user])
for parameter in [
"created_at",
"-created_at",
"is_favorite",
"-is_favorite",
"title",
"-title",
"updated_at",
"-updated_at",
]:
is_descending = parameter.startswith("-")
field = parameter.lstrip("-")
querystring = f"?ordering={parameter}"
response = client.get(f"/api/v1.0/documents/{querystring:s}")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 5
# Check that results are sorted by the field in querystring as expected
compare = operator.ge if is_descending else operator.le
for i in range(4):
assert compare(results[i][field], results[i + 1][field])
# Filters: unknown field
def test_api_documents_list_filter_unknown_field():
"""
Trying to filter by an unknown field should raise a 400 error.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory()
expected_ids = {
str(document.id)
for document in factories.DocumentFactory.create_batch(2, users=[user])
}
response = client.get("/api/v1.0/documents/?unknown=true")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 2
assert {result["id"] for result in results} == expected_ids
# Filters: is_creator_me
def test_api_documents_list_filter_is_creator_me_true():
"""
Authenticated users should be able to filter documents they created.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(3, users=[user], creator=user)
factories.DocumentFactory.create_batch(2, users=[user])
response = client.get("/api/v1.0/documents/?is_creator_me=true")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 3
# Ensure all results are created by the current user
for result in results:
assert result["creator"] == str(user.id)
def test_api_documents_list_filter_is_creator_me_false():
"""
Authenticated users should be able to filter documents created by others.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(3, users=[user], creator=user)
factories.DocumentFactory.create_batch(2, users=[user])
response = client.get("/api/v1.0/documents/?is_creator_me=false")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 2
# Ensure all results are created by other users
for result in results:
assert result["creator"] != str(user.id)
def test_api_documents_list_filter_is_creator_me_invalid():
"""Filtering with an invalid `is_creator_me` value should do nothing."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(3, users=[user], creator=user)
factories.DocumentFactory.create_batch(2, users=[user])
response = client.get("/api/v1.0/documents/?is_creator_me=invalid")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 5
# Filters: is_favorite
def test_api_documents_list_filter_is_favorite_true():
"""
Authenticated users should be able to filter documents they marked as favorite.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(3, users=[user], favorited_by=[user])
factories.DocumentFactory.create_batch(2, users=[user])
response = client.get("/api/v1.0/documents/?is_favorite=true")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 3
# Ensure all results are marked as favorite by the current user
for result in results:
assert result["is_favorite"] is True
def test_api_documents_list_filter_is_favorite_false():
"""
Authenticated users should be able to filter documents they didn't mark as favorite.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(3, users=[user], favorited_by=[user])
factories.DocumentFactory.create_batch(2, users=[user])
response = client.get("/api/v1.0/documents/?is_favorite=false")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 2
# Ensure all results are not marked as favorite by the current user
for result in results:
assert result["is_favorite"] is False
def test_api_documents_list_filter_is_favorite_invalid():
"""Filtering with an invalid `is_favorite` value should do nothing."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(3, users=[user], favorited_by=[user])
factories.DocumentFactory.create_batch(2, users=[user])
response = client.get("/api/v1.0/documents/?is_favorite=invalid")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 5
# Filters: title
@pytest.mark.parametrize(
"query,nb_results",
[
("Project Alpha", 1), # Exact match
("project", 2), # Partial match (case-insensitive)
("Guide", 1), # Word match within a title
("Special", 0), # No match (nonexistent keyword)
("2024", 2), # Match by numeric keyword
("", 5), # Empty string
],
)
def test_api_documents_list_filter_title(query, nb_results):
"""Authenticated users should be able to search documents by their title."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Create documents with predefined titles
titles = [
"Project Alpha Documentation",
"Project Beta Overview",
"User Guide",
"Financial Report 2024",
"Annual Review 2024",
]
for title in titles:
parent = factories.DocumentFactory() if random.choice([True, False]) else None
factories.DocumentFactory(title=title, users=[user], parent=parent)
# Perform the search query
response = client.get(f"/api/v1.0/documents/?title={query:s}")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == nb_results
# Ensure all results contain the query in their title
for result in results:
assert query.lower().strip() in result["title"].lower()

View File

@@ -160,7 +160,7 @@ def test_api_documents_media_auth_authenticated_restricted():
@pytest.mark.parametrize("via", VIA)
def test_api_documents_media_auth_related(via, mock_user_teams):
"""
Users who have specific access to a document, whatever the role, should be able to
Users who have a specific access to a document, whatever the role, should be able to
retrieve related attachments.
"""
user = factories.UserFactory()

View File

@@ -0,0 +1,339 @@
"""
Test moving documents within the document tree via an detail action API endpoint.
"""
import random
from uuid import uuid4
from django.utils import timezone
import pytest
from rest_framework.test import APIClient
from core import enums, factories, models
pytestmark = pytest.mark.django_db
def test_api_documents_move_anonymous_user():
"""Anonymous users should not be able to move documents."""
document = factories.DocumentFactory()
target = factories.DocumentFactory()
response = APIClient().post(
f"/api/v1.0/documents/{document.id!s}/move/",
data={"target_document_id": str(target.id)},
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
@pytest.mark.parametrize("role", [None, "reader", "editor"])
def test_api_documents_move_authenticated_document_no_permission(role):
"""
Authenticated users should not be able to move documents with insufficient
permissions on the origin document.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
target = factories.UserDocumentAccessFactory(user=user, role="owner").document
if role:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/move/",
data={"target_document_id": str(target.id)},
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
def test_api_documents_move_invalid_target_string():
"""Test for moving a document to an invalid target as a random string."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.UserDocumentAccessFactory(user=user, role="owner").document
response = client.post(
f"/api/v1.0/documents/{document.id!s}/move/",
data={"target_document_id": "non-existent-id"},
)
assert response.status_code == 400
assert response.json() == {"target_document_id": ["Must be a valid UUID."]}
def test_api_documents_move_invalid_target_uuid():
"""Test for moving a document to an invalid target that looks like a UUID."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.UserDocumentAccessFactory(user=user, role="owner").document
response = client.post(
f"/api/v1.0/documents/{document.id!s}/move/",
data={"target_document_id": str(uuid4())},
)
assert response.status_code == 400
assert response.json() == {
"target_document_id": "Target parent document does not exist."
}
def test_api_documents_move_invalid_position():
"""Test moving a document to an invalid position."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.UserDocumentAccessFactory(user=user, role="owner").document
target = factories.UserDocumentAccessFactory(user=user, role="owner").document
response = client.post(
f"/api/v1.0/documents/{document.id!s}/move/",
data={
"target_document_id": str(target.id),
"position": "invalid-position",
},
)
assert response.status_code == 400
assert response.json() == {
"position": ['"invalid-position" is not a valid choice.']
}
@pytest.mark.parametrize("position", enums.MoveNodePositionChoices.values)
@pytest.mark.parametrize("target_parent_role", models.RoleChoices.values)
@pytest.mark.parametrize("target_role", models.RoleChoices.values)
def test_api_documents_move_authenticated_target_roles_mocked(
target_role, target_parent_role, position
):
"""
Authenticated users with insufficient permissions on the target document (or its
parent depending on the position chosen), should not be allowed to move documents.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
power_roles = ["administrator", "owner"]
document = factories.DocumentFactory(users=[(user, random.choice(power_roles))])
children = factories.DocumentFactory.create_batch(3, parent=document)
target_parent = factories.DocumentFactory(users=[(user, target_parent_role)])
sibling1, target, sibling2 = factories.DocumentFactory.create_batch(
3, parent=target_parent
)
models.DocumentAccess.objects.create(document=target, user=user, role=target_role)
target_children = factories.DocumentFactory.create_batch(2, parent=target)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/move/",
data={"target_document_id": str(target.id), "position": position},
)
document.refresh_from_db()
if (
position in ["first-child", "last-child"]
and (target_role in power_roles or target_parent_role in power_roles)
) or (
position in ["first-sibling", "last-sibling", "left", "right"]
and target_parent_role in power_roles
):
assert response.status_code == 200
assert response.json() == {"message": "Document moved successfully."}
match position:
case "first-child":
assert list(target.get_children()) == [document, *target_children]
case "last-child":
assert list(target.get_children()) == [*target_children, document]
case "first-sibling":
assert list(target.get_siblings()) == [
document,
sibling1,
target,
sibling2,
]
case "last-sibling":
assert list(target.get_siblings()) == [
sibling1,
target,
sibling2,
document,
]
case "left":
assert list(target.get_siblings()) == [
sibling1,
document,
target,
sibling2,
]
case "right":
assert list(target.get_siblings()) == [
sibling1,
target,
document,
sibling2,
]
case _:
raise ValueError(f"Invalid position: {position}")
# Verify that the document's children have also been moved
assert list(document.get_children()) == children
else:
assert response.status_code == 400
assert (
"You do not have permission to move documents"
in response.json()["target_document_id"]
)
assert document.is_root() is True
def test_api_documents_move_authenticated_deleted_document():
"""
It should not be possible to move a deleted document or its descendants, even
for an owner.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(
users=[(user, "owner")], deleted_at=timezone.now()
)
child = factories.DocumentFactory(parent=document, users=[(user, "owner")])
target = factories.DocumentFactory(users=[(user, "owner")])
# Try moving the deleted document
response = client.post(
f"/api/v1.0/documents/{document.id!s}/move/",
data={"target_document_id": str(target.id)},
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
# Verify that the document has not moved
document.refresh_from_db()
assert document.is_root() is True
# Try moving the child of the deleted document
response = client.post(
f"/api/v1.0/documents/{child.id!s}/move/",
data={"target_document_id": str(target.id)},
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
# Verify that the child has not moved
child.refresh_from_db()
assert child.is_child_of(document) is True
@pytest.mark.parametrize(
"position",
enums.MoveNodePositionChoices.values,
)
def test_api_documents_move_authenticated_deleted_target_as_child(position):
"""
It should not be possible to move a document as a child of a deleted target
even for a owner.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[(user, "owner")])
target = factories.DocumentFactory(
users=[(user, "owner")], deleted_at=timezone.now()
)
child = factories.DocumentFactory(parent=target, users=[(user, "owner")])
# Try moving the document to the deleted target
response = client.post(
f"/api/v1.0/documents/{document.id!s}/move/",
data={"target_document_id": str(target.id), "position": position},
)
assert response.status_code == 400
assert response.json() == {
"target_document_id": "Target parent document does not exist."
}
# Verify that the document has not moved
document.refresh_from_db()
assert document.is_root() is True
# Try moving the document to the child of the deleted target
response = client.post(
f"/api/v1.0/documents/{document.id!s}/move/",
data={"target_document_id": str(child.id), "position": position},
)
assert response.status_code == 400
assert response.json() == {
"target_document_id": "Target parent document does not exist."
}
# Verify that the document has not moved
document.refresh_from_db()
assert document.is_root() is True
@pytest.mark.parametrize(
"position",
["first-sibling", "last-sibling", "left", "right"],
)
def test_api_documents_move_authenticated_deleted_target_as_sibling(position):
"""
It should not be possible to move a document as a sibling of a deleted target document
if the user has no rigths on its parent.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[(user, "owner")])
target_parent = factories.DocumentFactory(
users=[(user, "owner")], deleted_at=timezone.now()
)
target = factories.DocumentFactory(users=[(user, "owner")], parent=target_parent)
# Try moving the document as a sibling of the target
response = client.post(
f"/api/v1.0/documents/{document.id!s}/move/",
data={"target_document_id": str(target.id), "position": position},
)
assert response.status_code == 400
assert response.json() == {
"target_document_id": "Target parent document does not exist."
}
# Verify that the document has not moved
document.refresh_from_db()
assert document.is_root() is True

View File

@@ -0,0 +1,126 @@
"""
Test restoring documents after a soft delete via the detail action API endpoint.
"""
from datetime import timedelta
from django.utils import timezone
import pytest
from rest_framework.test import APIClient
from core import factories
pytestmark = pytest.mark.django_db
def test_api_documents_restore_anonymous_user():
"""Anonymous users should not be able to restore deleted documents."""
now = timezone.now() - timedelta(days=15)
document = factories.DocumentFactory(deleted_at=now)
response = APIClient().post(f"/api/v1.0/documents/{document.id!s}/restore/")
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
document.refresh_from_db()
assert document.deleted_at == now
assert document.ancestors_deleted_at == now
@pytest.mark.parametrize("role", [None, "reader", "editor", "administrator"])
def test_api_documents_restore_authenticated_no_permission(role):
"""
Authenticated users who are not owners of a deleted document should
not be allowed to restore it.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
now = timezone.now() - timedelta(days=15)
document = factories.DocumentFactory(
deleted_at=now, link_reach="public", link_role="editor"
)
if role:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
response = client.post(f"/api/v1.0/documents/{document.id!s}/restore/")
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
document.refresh_from_db()
assert document.deleted_at == now
assert document.ancestors_deleted_at == now
def test_api_documents_restore_authenticated_owner_success():
"""The owner of a deleted document should be able to restore it."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
now = timezone.now() - timedelta(days=15)
document = factories.DocumentFactory(deleted_at=now)
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
response = client.post(f"/api/v1.0/documents/{document.id!s}/restore/")
assert response.status_code == 200
assert response.json() == {"detail": "Document has been successfully restored."}
document.refresh_from_db()
assert document.deleted_at is None
assert document.ancestors_deleted_at is None
def test_api_documents_restore_authenticated_owner_ancestor_deleted():
"""
The restored document should still be marked as deleted if one of its
ancestors is soft deleted as well.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
grand_parent = factories.DocumentFactory()
parent = factories.DocumentFactory(parent=grand_parent)
document = factories.DocumentFactory(parent=parent)
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
document.soft_delete()
document_deleted_at = document.deleted_at
assert document_deleted_at is not None
grand_parent.soft_delete()
grand_parent_deleted_at = grand_parent.deleted_at
assert grand_parent_deleted_at is not None
response = client.post(f"/api/v1.0/documents/{document.id!s}/restore/")
assert response.status_code == 200
assert response.json() == {"detail": "Document has been successfully restored."}
document.refresh_from_db()
assert document.deleted_at is None
# document is still marked as deleted
assert document.ancestors_deleted_at == grand_parent_deleted_at
assert grand_parent_deleted_at > document_deleted_at
def test_api_documents_restore_authenticated_owner_expired():
"""It should not be possible to restore a document beyond the allowed time limit."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
now = timezone.now() - timedelta(days=40)
document = factories.DocumentFactory(deleted_at=now)
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
response = client.post(f"/api/v1.0/documents/{document.id!s}/restore/")
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}

View File

@@ -2,16 +2,21 @@
Tests for Documents API endpoint in impress's core app: retrieve
"""
import random
from datetime import timedelta
from unittest import mock
from django.utils import timezone
import pytest
from rest_framework.test import APIClient
from core import factories, models
from core.api import serializers
pytestmark = pytest.mark.django_db
def test_api_documents_retrieve_anonymous_public():
def test_api_documents_retrieve_anonymous_public_standalone():
"""Anonymous users should be allowed to retrieve public documents."""
document = factories.DocumentFactory(link_reach="public")
@@ -26,6 +31,8 @@ def test_api_documents_retrieve_anonymous_public():
"ai_transform": document.link_role == "editor",
"ai_translate": document.link_role == "editor",
"attachment_upload": document.link_role == "editor",
"children_create": False,
"children_list": True,
"collaboration_auth": True,
"destroy": False,
# Anonymous user can't favorite a document even with read access
@@ -33,7 +40,9 @@ def test_api_documents_retrieve_anonymous_public():
"invite_owner": False,
"link_configuration": False,
"media_auth": True,
"move": False,
"partial_update": document.link_role == "editor",
"restore": False,
"retrieve": True,
"update": document.link_role == "editor",
"versions_destroy": False,
@@ -43,12 +52,90 @@ def test_api_documents_retrieve_anonymous_public():
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"depth": 1,
"excerpt": document.excerpt,
"is_favorite": False,
"link_reach": "public",
"link_role": document.link_role,
"nb_accesses": 0,
"numchild": 0,
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
}
def test_api_documents_retrieve_anonymous_public_parent():
"""Anonymous users should be allowed to retrieve a document who has a public ancestor."""
grand_parent = factories.DocumentFactory(link_reach="public")
parent = factories.DocumentFactory(
parent=grand_parent, link_reach=random.choice(["authenticated", "restricted"])
)
document = factories.DocumentFactory(
link_reach=random.choice(["authenticated", "restricted"]), parent=parent
)
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/")
assert response.status_code == 200
assert response.json() == {
"id": str(document.id),
"abilities": {
"accesses_manage": False,
"accesses_view": False,
"ai_transform": grand_parent.link_role == "editor",
"ai_translate": grand_parent.link_role == "editor",
"attachment_upload": grand_parent.link_role == "editor",
"children_create": False,
"children_list": True,
"collaboration_auth": True,
"destroy": False,
# Anonymous user can't favorite a document even with read access
"favorite": False,
"invite_owner": False,
"link_configuration": False,
"media_auth": True,
"move": False,
"partial_update": grand_parent.link_role == "editor",
"restore": False,
"retrieve": True,
"update": grand_parent.link_role == "editor",
"versions_destroy": False,
"versions_list": False,
"versions_retrieve": False,
},
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"depth": 3,
"excerpt": document.excerpt,
"is_favorite": False,
"link_reach": document.link_reach,
"link_role": document.link_role,
"nb_accesses": 0,
"numchild": 0,
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
}
def test_api_documents_retrieve_anonymous_public_child():
"""
Anonymous users having access to a document should not gain access to a parent document.
"""
document = factories.DocumentFactory(
link_reach=random.choice(["authenticated", "restricted"])
)
factories.DocumentFactory(link_reach="public", parent=document)
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/")
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
@@ -68,8 +155,8 @@ def test_api_documents_retrieve_anonymous_restricted_or_authenticated(reach):
@pytest.mark.parametrize("reach", ["public", "authenticated"])
def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(reach):
"""
Authenticated users should be able to retrieve a public document to which they are
not related.
Authenticated users should be able to retrieve a public/authenticated document to
which they are not related.
"""
user = factories.UserFactory()
@@ -90,13 +177,17 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
"ai_transform": document.link_role == "editor",
"ai_translate": document.link_role == "editor",
"attachment_upload": document.link_role == "editor",
"children_create": document.link_role == "editor",
"children_list": True,
"collaboration_auth": True,
"destroy": False,
"favorite": True,
"invite_owner": False,
"media_auth": True,
"link_configuration": False,
"media_auth": True,
"move": False,
"partial_update": document.link_role == "editor",
"restore": False,
"retrieve": True,
"update": document.link_role == "editor",
"versions_destroy": False,
@@ -106,18 +197,104 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"depth": 1,
"excerpt": document.excerpt,
"is_favorite": False,
"link_reach": reach,
"link_role": document.link_role,
"nb_accesses": 0,
"numchild": 0,
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
}
assert (
models.LinkTrace.objects.filter(document=document, user=user).exists() is True
)
@pytest.mark.parametrize("reach", ["public", "authenticated"])
def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(reach):
"""
Authenticated users should be allowed to retrieve a document who has a public or
authenticated ancestor.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
grand_parent = factories.DocumentFactory(link_reach=reach)
parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted")
document = factories.DocumentFactory(link_reach="restricted", parent=parent)
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
assert response.status_code == 200
assert response.json() == {
"id": str(document.id),
"abilities": {
"accesses_manage": False,
"accesses_view": False,
"ai_transform": grand_parent.link_role == "editor",
"ai_translate": grand_parent.link_role == "editor",
"attachment_upload": grand_parent.link_role == "editor",
"children_create": grand_parent.link_role == "editor",
"children_list": True,
"collaboration_auth": True,
"destroy": False,
"favorite": True,
"invite_owner": False,
"link_configuration": False,
"move": False,
"media_auth": True,
"partial_update": grand_parent.link_role == "editor",
"restore": False,
"retrieve": True,
"update": grand_parent.link_role == "editor",
"versions_destroy": False,
"versions_list": False,
"versions_retrieve": False,
},
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"depth": 3,
"excerpt": document.excerpt,
"is_favorite": False,
"link_reach": document.link_reach,
"link_role": document.link_role,
"nb_accesses": 0,
"numchild": 0,
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
}
@pytest.mark.parametrize("reach", ["public", "authenticated"])
def test_api_documents_retrieve_authenticated_public_or_authenticated_child(reach):
"""
Authenticated users having access to a document should not gain access to a parent document.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="restricted")
factories.DocumentFactory(link_reach=reach, parent=document)
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
@pytest.mark.parametrize("reach", ["public", "authenticated"])
def test_api_documents_retrieve_authenticated_trace_twice(reach):
"""
@@ -179,10 +356,8 @@ def test_api_documents_retrieve_authenticated_related_direct():
client.force_login(user)
document = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=document, user=user)
access2 = factories.UserDocumentAccessFactory(document=document)
serializers.UserSerializer(instance=user)
serializers.UserSerializer(instance=access2.user)
access = factories.UserDocumentAccessFactory(document=document, user=user)
factories.UserDocumentAccessFactory(document=document)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/",
@@ -194,12 +369,135 @@ def test_api_documents_retrieve_authenticated_related_direct():
"content": document.content,
"creator": str(document.creator.id),
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"depth": 1,
"excerpt": document.excerpt,
"is_favorite": False,
"link_reach": document.link_reach,
"link_role": document.link_role,
"nb_accesses": 2,
"numchild": 0,
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role],
}
def test_api_documents_retrieve_authenticated_related_parent():
"""
Authenticated users should be allowed to retrieve a document if they are related
to one of its ancestors whatever the role.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
grand_parent = factories.DocumentFactory(link_reach="restricted")
parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted")
document = factories.DocumentFactory(parent=parent, link_reach="restricted")
access = factories.UserDocumentAccessFactory(document=grand_parent, user=user)
factories.UserDocumentAccessFactory(document=grand_parent)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/",
)
assert response.status_code == 200
assert response.json() == {
"id": str(document.id),
"abilities": {
"accesses_manage": access.role in ["administrator", "owner"],
"accesses_view": True,
"ai_transform": access.role != "reader",
"ai_translate": access.role != "reader",
"attachment_upload": access.role != "reader",
"children_create": access.role != "reader",
"children_list": True,
"collaboration_auth": True,
"destroy": access.role == "owner",
"favorite": True,
"invite_owner": access.role == "owner",
"link_configuration": access.role in ["administrator", "owner"],
"media_auth": True,
"move": access.role in ["administrator", "owner"],
"partial_update": access.role != "reader",
"restore": access.role == "owner",
"retrieve": True,
"update": access.role != "reader",
"versions_destroy": access.role in ["administrator", "owner"],
"versions_list": True,
"versions_retrieve": True,
},
"content": document.content,
"creator": str(document.creator.id),
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"depth": 3,
"excerpt": document.excerpt,
"is_favorite": False,
"link_reach": "restricted",
"link_role": document.link_role,
"nb_accesses": 2,
"numchild": 0,
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role],
}
def test_api_documents_retrieve_authenticated_related_nb_accesses():
"""Validate computation of number of accesses."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
grand_parent = factories.DocumentFactory(link_reach="restricted")
parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted")
document = factories.DocumentFactory(parent=parent, link_reach="restricted")
factories.UserDocumentAccessFactory(document=grand_parent, user=user)
factories.UserDocumentAccessFactory(document=parent)
factories.UserDocumentAccessFactory(document=document)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/",
)
assert response.status_code == 200
assert response.json()["nb_accesses"] == 3
factories.UserDocumentAccessFactory(document=grand_parent)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/",
)
assert response.status_code == 200
assert response.json()["nb_accesses"] == 4
def test_api_documents_retrieve_authenticated_related_child():
"""
Authenticated users should not be allowed to retrieve a document as a result of being
related to one of its children.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="restricted")
child = factories.DocumentFactory(parent=document)
factories.UserDocumentAccessFactory(document=child, user=user)
factories.UserDocumentAccessFactory(document=document)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
@@ -238,16 +536,16 @@ def test_api_documents_retrieve_authenticated_related_team_none(mock_user_teams)
@pytest.mark.parametrize(
"teams",
"teams,roles",
[
["readers"],
["unknown", "readers"],
["editors"],
["unknown", "editors"],
[["readers"], ["reader"]],
[["unknown", "readers"], ["reader"]],
[["editors"], ["editor"]],
[["unknown", "editors"], ["editor"]],
],
)
def test_api_documents_retrieve_authenticated_related_team_members(
teams, mock_user_teams
teams, roles, mock_user_teams
):
"""
Authenticated users should be allowed to retrieve a document to which they
@@ -285,25 +583,30 @@ def test_api_documents_retrieve_authenticated_related_team_members(
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"depth": 1,
"excerpt": document.excerpt,
"is_favorite": False,
"link_reach": "restricted",
"link_role": document.link_role,
"nb_accesses": 5,
"numchild": 0,
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": roles,
}
@pytest.mark.parametrize(
"teams",
"teams,roles",
[
["administrators"],
["editors", "administrators"],
["unknown", "administrators"],
[["administrators"], ["administrator"]],
[["editors", "administrators"], ["administrator", "editor"]],
[["unknown", "administrators"], ["administrator"]],
],
)
def test_api_documents_retrieve_authenticated_related_team_administrators(
teams, mock_user_teams
teams, roles, mock_user_teams
):
"""
Authenticated users should be allowed to retrieve a document to which they
@@ -341,26 +644,31 @@ def test_api_documents_retrieve_authenticated_related_team_administrators(
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"depth": 1,
"excerpt": document.excerpt,
"is_favorite": False,
"link_reach": "restricted",
"link_role": document.link_role,
"nb_accesses": 5,
"numchild": 0,
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": roles,
}
@pytest.mark.parametrize(
"teams",
"teams,roles",
[
["owners"],
["owners", "administrators"],
["members", "administrators", "owners"],
["unknown", "owners"],
[["owners"], ["owner"]],
[["owners", "administrators"], ["owner", "administrator"]],
[["members", "administrators", "owners"], ["owner", "administrator"]],
[["unknown", "owners"], ["owner"]],
],
)
def test_api_documents_retrieve_authenticated_related_team_owners(
teams, mock_user_teams
teams, roles, mock_user_teams
):
"""
Authenticated users should be allowed to retrieve a restricted document to which
@@ -369,7 +677,6 @@ def test_api_documents_retrieve_authenticated_related_team_owners(
mock_user_teams.return_value = teams
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -398,10 +705,231 @@ def test_api_documents_retrieve_authenticated_related_team_owners(
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"depth": 1,
"excerpt": document.excerpt,
"is_favorite": False,
"link_reach": "restricted",
"link_role": document.link_role,
"nb_accesses": 5,
"numchild": 0,
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": roles,
}
def test_api_documents_retrieve_user_roles(django_assert_num_queries):
"""
Roles should be annotated on querysets taking into account all documents ancestors.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
grand_parent = factories.DocumentFactory(
users=factories.UserFactory.create_batch(2)
)
parent = factories.DocumentFactory(
parent=grand_parent, users=factories.UserFactory.create_batch(2)
)
document = factories.DocumentFactory(
parent=parent, users=factories.UserFactory.create_batch(2)
)
accesses = (
factories.UserDocumentAccessFactory(document=grand_parent, user=user),
factories.UserDocumentAccessFactory(document=parent, user=user),
factories.UserDocumentAccessFactory(document=document, user=user),
)
expected_roles = {access.role for access in accesses}
with django_assert_num_queries(10):
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
assert response.status_code == 200
user_roles = response.json()["user_roles"]
assert set(user_roles) == expected_roles
def test_api_documents_retrieve_numqueries_with_link_trace(django_assert_num_queries):
"""If the link traced already exists, the number of queries should be minimal."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[user], link_traces=[user])
with django_assert_num_queries(4):
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
with django_assert_num_queries(3):
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
assert response.status_code == 200
assert response.json()["id"] == str(document.id)
# Soft/permanent delete
@pytest.mark.parametrize("depth", [1, 2, 3])
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
def test_api_documents_retrieve_soft_deleted_anonymous(reach, depth):
"""
A soft/permanently deleted public document should not be accessible via its
detail endpoint for anonymous users, and should return a 404.
"""
documents = []
for i in range(depth):
documents.append(
factories.DocumentFactory(link_reach=reach)
if i == 0
else factories.DocumentFactory(parent=documents[-1])
)
assert models.Document.objects.count() == depth
response = APIClient().get(f"/api/v1.0/documents/{documents[-1].id!s}/")
assert response.status_code == 200 if reach == "public" else 401
# Delete any one of the documents...
deleted_document = random.choice(documents)
deleted_document.soft_delete()
response = APIClient().get(f"/api/v1.0/documents/{documents[-1].id!s}/")
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
fourty_days_ago = timezone.now() - timedelta(days=40)
deleted_document.deleted_at = fourty_days_ago
deleted_document.ancestors_deleted_at = fourty_days_ago
deleted_document.save()
response = APIClient().get(f"/api/v1.0/documents/{documents[-1].id!s}/")
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
@pytest.mark.parametrize("depth", [1, 2, 3])
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
def test_api_documents_retrieve_soft_deleted_authenticated(reach, depth):
"""
A soft/permanently deleted document should not be accessible via its detail endpoint for
authenticated users not related to the document.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
documents = []
for i in range(depth):
documents.append(
factories.DocumentFactory(link_reach=reach)
if i == 0
else factories.DocumentFactory(parent=documents[-1])
)
assert models.Document.objects.count() == depth
response = client.get(f"/api/v1.0/documents/{documents[-1].id!s}/")
assert response.status_code == 200 if reach in ["public", "authenticated"] else 403
# Delete any one of the documents...
deleted_document = random.choice(documents)
deleted_document.soft_delete()
response = client.get(f"/api/v1.0/documents/{documents[-1].id!s}/")
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
fourty_days_ago = timezone.now() - timedelta(days=40)
deleted_document.deleted_at = fourty_days_ago
deleted_document.ancestors_deleted_at = fourty_days_ago
deleted_document.save()
response = client.get(f"/api/v1.0/documents/{documents[-1].id!s}/")
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
@pytest.mark.parametrize("depth", [1, 2, 3])
@pytest.mark.parametrize("role", models.RoleChoices.values)
def test_api_documents_retrieve_soft_deleted_related(role, depth):
"""
A soft deleted document should only be accessible via its detail endpoint by
users with specific "owner" access rights.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
documents = []
for i in range(depth):
documents.append(
factories.UserDocumentAccessFactory(role=role, user=user).document
if i == 0
else factories.DocumentFactory(parent=documents[-1])
)
assert models.Document.objects.count() == depth
document = documents[-1]
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
assert response.status_code == 200
# Delete any one of the documents
deleted_document = random.choice(documents)
deleted_document.soft_delete()
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
if role == "owner":
assert response.status_code == 200
assert response.json()["id"] == str(document.id)
else:
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
@pytest.mark.parametrize("depth", [1, 2, 3])
@pytest.mark.parametrize("role", models.RoleChoices.values)
def test_api_documents_retrieve_permanently_deleted_related(role, depth):
"""
A permanently deleted document should not be accessible via its detail endpoint for
authenticated users with specific access rights whatever their role.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
documents = []
for i in range(depth):
documents.append(
factories.UserDocumentAccessFactory(role=role, user=user).document
if i == 0
else factories.DocumentFactory(parent=documents[-1])
)
assert models.Document.objects.count() == depth
document = documents[-1]
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
assert response.status_code == 200
# Delete any one of the documents
deleted_document = random.choice(documents)
fourty_days_ago = timezone.now() - timedelta(days=40)
with mock.patch("django.utils.timezone.now", return_value=fourty_days_ago):
deleted_document.soft_delete()
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}

View File

@@ -0,0 +1,276 @@
"""
Tests for Documents API endpoint in impress's core app: list
"""
from datetime import timedelta
from unittest import mock
from django.utils import timezone
import pytest
from faker import Faker
from rest_framework.pagination import PageNumberPagination
from rest_framework.test import APIClient
from core import factories, models
fake = Faker()
pytestmark = pytest.mark.django_db
@pytest.mark.parametrize("role", models.LinkRoleChoices.values)
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
def test_api_documents_trashbin_anonymous(reach, role):
"""
Anonymous users should not be allowed to list documents from the trashbin
whatever the link reach and link role
"""
factories.DocumentFactory(
link_reach=reach, link_role=role, deleted_at=timezone.now()
)
response = APIClient().get("/api/v1.0/documents/trashbin/")
assert response.status_code == 200
assert response.json() == {
"count": 0,
"next": None,
"previous": None,
"results": [],
}
def test_api_documents_trashbin_format():
"""Validate the format of documents as returned by the trashbin view."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
other_users = factories.UserFactory.create_batch(3)
document = factories.DocumentFactory(
deleted_at=timezone.now(),
users=factories.UserFactory.create_batch(2),
favorited_by=[user, *other_users],
link_traces=other_users,
)
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
response = client.get("/api/v1.0/documents/trashbin/")
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": {
"accesses_manage": True,
"accesses_view": True,
"ai_transform": True,
"ai_translate": True,
"attachment_upload": True,
"children_create": True,
"children_list": True,
"collaboration_auth": True,
"destroy": True,
"favorite": True,
"invite_owner": True,
"link_configuration": True,
"media_auth": True,
"move": False, # Can't move a deleted document
"partial_update": True,
"restore": True,
"retrieve": True,
"update": True,
"versions_destroy": True,
"versions_list": True,
"versions_retrieve": True,
},
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"depth": 1,
"excerpt": document.excerpt,
"link_reach": document.link_reach,
"link_role": document.link_role,
"nb_accesses": 3,
"numchild": 0,
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": ["owner"],
}
def test_api_documents_trashbin_authenticated_direct(django_assert_num_queries):
"""
The trashbin should only list deleted documents for which the current user is owner.
"""
now = timezone.now()
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document1, document2 = factories.DocumentFactory.create_batch(2, deleted_at=now)
models.DocumentAccess.objects.create(document=document1, user=user, role="owner")
models.DocumentAccess.objects.create(document=document2, user=user, role="owner")
# Unrelated documents
for reach in models.LinkReachChoices:
for role in models.LinkRoleChoices:
factories.DocumentFactory(link_reach=reach, link_role=role, deleted_at=now)
# Role other than "owner"
for role in models.RoleChoices.values:
if role == "owner":
continue
document_not_owner = factories.DocumentFactory(deleted_at=now)
models.DocumentAccess.objects.create(
document=document_not_owner, user=user, role=role
)
# Nested documents should also get listed
parent = factories.DocumentFactory(parent=document1)
document3 = factories.DocumentFactory(parent=parent, deleted_at=now)
models.DocumentAccess.objects.create(document=parent, user=user, role="owner")
# Permanently deleted documents should not be listed
fourty_days_ago = timezone.now() - timedelta(days=40)
permanently_deleted_document = factories.DocumentFactory(users=[(user, "owner")])
with mock.patch("django.utils.timezone.now", return_value=fourty_days_ago):
permanently_deleted_document.soft_delete()
expected_ids = {str(document1.id), str(document2.id), str(document3.id)}
with django_assert_num_queries(7):
response = client.get("/api/v1.0/documents/trashbin/")
with django_assert_num_queries(4):
response = client.get("/api/v1.0/documents/trashbin/")
assert response.status_code == 200
results = response.json()["results"]
results_ids = {result["id"] for result in results}
assert len(results) == 3
assert expected_ids == results_ids
def test_api_documents_trashbin_authenticated_via_team(
django_assert_num_queries, mock_user_teams
):
"""
Authenticated users should be able to list trashbin documents they own via a team.
"""
now = timezone.now()
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
mock_user_teams.return_value = ["team1", "team2", "unknown"]
deleted_document_team1 = factories.DocumentFactory(
teams=[("team1", "owner")], deleted_at=now
)
factories.DocumentFactory(teams=[("team1", "owner")])
factories.DocumentFactory(teams=[("team1", "administrator")], deleted_at=now)
factories.DocumentFactory(teams=[("team1", "administrator")])
deleted_document_team2 = factories.DocumentFactory(
teams=[("team2", "owner")], deleted_at=now
)
factories.DocumentFactory(teams=[("team2", "owner")])
factories.DocumentFactory(teams=[("team2", "administrator")], deleted_at=now)
factories.DocumentFactory(teams=[("team2", "administrator")])
expected_ids = {str(deleted_document_team1.id), str(deleted_document_team2.id)}
with django_assert_num_queries(5):
response = client.get("/api/v1.0/documents/trashbin/")
with django_assert_num_queries(3):
response = client.get("/api/v1.0/documents/trashbin/")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 2
results_id = {result["id"] for result in results}
assert expected_ids == results_id
@mock.patch.object(PageNumberPagination, "get_page_size", return_value=2)
def test_api_documents_trashbin_pagination(
_mock_page_size,
):
"""Pagination should work as expected."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document_ids = [
str(document.id)
for document in factories.DocumentFactory.create_batch(
3, deleted_at=timezone.now()
)
]
for document_id in document_ids:
models.DocumentAccess.objects.create(
document_id=document_id, user=user, role="owner"
)
# Get page 1
response = client.get("/api/v1.0/documents/trashbin/")
assert response.status_code == 200
content = response.json()
assert content["count"] == 3
assert content["next"] == "http://testserver/api/v1.0/documents/trashbin/?page=2"
assert content["previous"] is None
assert len(content["results"]) == 2
for item in content["results"]:
document_ids.remove(item["id"])
# Get page 2
response = client.get(
"/api/v1.0/documents/trashbin/?page=2",
)
assert response.status_code == 200
content = response.json()
assert content["count"] == 3
assert content["next"] is None
assert content["previous"] == "http://testserver/api/v1.0/documents/trashbin/"
assert len(content["results"]) == 1
document_ids.remove(content["results"][0]["id"])
assert document_ids == []
def test_api_documents_trashbin_distinct():
"""A document with several related users should only be listed once."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
other_user = factories.UserFactory()
document = factories.DocumentFactory(
users=[(user, "owner"), other_user], deleted_at=timezone.now()
)
response = client.get(
"/api/v1.0/documents/trashbin/",
)
assert response.status_code == 200
content = response.json()
assert len(content["results"]) == 1
assert content["results"][0]["id"] == str(document.id)

View File

@@ -16,6 +16,7 @@ from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
@pytest.mark.parametrize("via_parent", [True, False])
@pytest.mark.parametrize(
"reach, role",
[
@@ -26,12 +27,18 @@ pytestmark = pytest.mark.django_db
("public", "reader"),
],
)
def test_api_documents_update_anonymous_forbidden(reach, role):
def test_api_documents_update_anonymous_forbidden(reach, role, via_parent):
"""
Anonymous users should not be allowed to update a document when link
configuration does not allow it.
"""
document = factories.DocumentFactory(link_reach=reach, link_role=role)
if via_parent:
grand_parent = factories.DocumentFactory(link_reach=reach, link_role=role)
parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted")
document = factories.DocumentFactory(parent=parent, link_reach="restricted")
else:
document = factories.DocumentFactory(link_reach=reach, link_role=role)
old_document_values = serializers.DocumentSerializer(instance=document).data
new_document_values = serializers.DocumentSerializer(
@@ -52,6 +59,7 @@ def test_api_documents_update_anonymous_forbidden(reach, role):
assert document_values == old_document_values
@pytest.mark.parametrize("via_parent", [True, False])
@pytest.mark.parametrize(
"reach,role",
[
@@ -61,7 +69,9 @@ def test_api_documents_update_anonymous_forbidden(reach, role):
("restricted", "editor"),
],
)
def test_api_documents_update_authenticated_unrelated_forbidden(reach, role):
def test_api_documents_update_authenticated_unrelated_forbidden(
reach, role, via_parent
):
"""
Authenticated users should not be allowed to update a document to which
they are not related if the link configuration does not allow it.
@@ -71,7 +81,12 @@ def test_api_documents_update_authenticated_unrelated_forbidden(reach, role):
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach, link_role=role)
if via_parent:
grand_parent = factories.DocumentFactory(link_reach=reach, link_role=role)
parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted")
document = factories.DocumentFactory(parent=parent, link_reach="restricted")
else:
document = factories.DocumentFactory(link_reach=reach, link_role=role)
old_document_values = serializers.DocumentSerializer(instance=document).data
new_document_values = serializers.DocumentSerializer(
@@ -93,6 +108,7 @@ def test_api_documents_update_authenticated_unrelated_forbidden(reach, role):
assert document_values == old_document_values
@pytest.mark.parametrize("via_parent", [True, False])
@pytest.mark.parametrize(
"is_authenticated,reach,role",
[
@@ -102,10 +118,10 @@ def test_api_documents_update_authenticated_unrelated_forbidden(reach, role):
],
)
def test_api_documents_update_anonymous_or_authenticated_unrelated(
is_authenticated, reach, role
is_authenticated, reach, role, via_parent
):
"""
Authenticated users should be able to update a document to which
Anonymous and authenticated users should be able to update a document to which
they are not related if the link configuration allows it.
"""
client = APIClient()
@@ -116,7 +132,12 @@ def test_api_documents_update_anonymous_or_authenticated_unrelated(
else:
user = AnonymousUser()
document = factories.DocumentFactory(link_reach=reach, link_role=role)
if via_parent:
grand_parent = factories.DocumentFactory(link_reach=reach, link_role=role)
parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted")
document = factories.DocumentFactory(parent=parent, link_reach="restricted")
else:
document = factories.DocumentFactory(link_reach=reach, link_role=role)
old_document_values = serializers.DocumentSerializer(instance=document).data
new_document_values = serializers.DocumentSerializer(
@@ -137,8 +158,11 @@ def test_api_documents_update_anonymous_or_authenticated_unrelated(
"accesses",
"created_at",
"creator",
"depth",
"link_reach",
"link_role",
"numchild",
"path",
]:
assert value == old_document_values[key]
elif key == "updated_at":
@@ -147,24 +171,34 @@ def test_api_documents_update_anonymous_or_authenticated_unrelated(
assert value == new_document_values[key]
@pytest.mark.parametrize("via_parent", [True, False])
@pytest.mark.parametrize("via", VIA)
def test_api_documents_update_authenticated_reader(via, mock_user_teams):
def test_api_documents_update_authenticated_reader(via, via_parent, mock_user_teams):
"""
Users who are reader of a document but not administrators should
not be allowed to update it.
Users who are reader of a document should not be allowed to update it.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_role="reader")
if via_parent:
grand_parent = factories.DocumentFactory(link_reach="restricted")
parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted")
document = factories.DocumentFactory(parent=parent, link_reach="restricted")
access_document = grand_parent
else:
document = factories.DocumentFactory(link_reach="restricted")
access_document = document
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role="reader")
factories.UserDocumentAccessFactory(
document=access_document, user=user, role="reader"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="reader"
document=access_document, team="lasuite", role="reader"
)
old_document_values = serializers.DocumentSerializer(instance=document).data
@@ -188,10 +222,11 @@ def test_api_documents_update_authenticated_reader(via, mock_user_teams):
assert document_values == old_document_values
@pytest.mark.parametrize("via_parent", [True, False])
@pytest.mark.parametrize("role", ["editor", "administrator", "owner"])
@pytest.mark.parametrize("via", VIA)
def test_api_documents_update_authenticated_editor_administrator_or_owner(
via, role, mock_user_teams
via, role, via_parent, mock_user_teams
):
"""A user who is editor, administrator or owner of a document should be allowed to update it."""
user = factories.UserFactory(with_owned_document=True)
@@ -199,13 +234,23 @@ def test_api_documents_update_authenticated_editor_administrator_or_owner(
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
if via_parent:
grand_parent = factories.DocumentFactory(link_reach="restricted")
parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted")
document = factories.DocumentFactory(parent=parent, link_reach="restricted")
access_document = grand_parent
else:
document = factories.DocumentFactory(link_reach="restricted")
access_document = document
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
factories.UserDocumentAccessFactory(
document=access_document, user=user, role=role
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
document=access_document, team="lasuite", role=role
)
old_document_values = serializers.DocumentSerializer(instance=document).data
@@ -227,55 +272,12 @@ def test_api_documents_update_authenticated_editor_administrator_or_owner(
"id",
"created_at",
"creator",
"depth",
"link_reach",
"link_role",
"nb_accesses",
]:
assert value == old_document_values[key]
elif key == "updated_at":
assert value > old_document_values[key]
else:
assert value == new_document_values[key]
@pytest.mark.parametrize("via", VIA)
def test_api_documents_update_authenticated_owners(via, mock_user_teams):
"""Administrators of a document should be allowed to update it."""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="owner"
)
old_document_values = serializers.DocumentSerializer(instance=document).data
new_document_values = serializers.DocumentSerializer(
instance=factories.DocumentFactory()
).data
response = client.put(
f"/api/v1.0/documents/{document.id!s}/", new_document_values, format="json"
)
assert response.status_code == 200
document = models.Document.objects.get(pk=document.pk)
document_values = serializers.DocumentSerializer(instance=document).data
for key, value in document_values.items():
if key in [
"id",
"created_at",
"creator",
"link_reach",
"link_role",
"nb_accesses",
"numchild",
"path",
]:
assert value == old_document_values[key]
elif key == "updated_at":

View File

@@ -73,14 +73,14 @@ def test_api_template_accesses_list_authenticated_related(via, mock_user_teams):
user_access = models.TemplateAccess.objects.create(
template=template,
user=user,
role=random.choice(models.RoleChoices.choices)[0],
role=random.choice(models.RoleChoices.values),
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
user_access = models.TemplateAccess.objects.create(
template=template,
team="lasuite",
role=random.choice(models.RoleChoices.choices)[0],
role=random.choice(models.RoleChoices.values),
)
access1 = factories.TeamTemplateAccessFactory(template=template)
@@ -219,7 +219,7 @@ def test_api_template_accesses_update_anonymous():
new_values = {
"id": uuid4(),
"user": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
"role": random.choice(models.RoleChoices.values),
}
api_client = APIClient()
@@ -252,7 +252,7 @@ def test_api_template_accesses_update_authenticated_unrelated():
new_values = {
"id": uuid4(),
"user": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
"role": random.choice(models.RoleChoices.values),
}
for field, value in new_values.items():
@@ -294,7 +294,7 @@ def test_api_template_accesses_update_authenticated_editor_or_reader(
new_values = {
"id": uuid4(),
"user": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
"role": random.choice(models.RoleChoices.values),
}
for field, value in new_values.items():
@@ -398,7 +398,7 @@ def test_api_template_accesses_update_administrator_from_owner(via, mock_user_te
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
"role": random.choice(models.RoleChoices.values),
}
for field, value in new_values.items():
@@ -497,7 +497,7 @@ def test_api_template_accesses_update_owner(via, mock_user_teams):
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.choices)[0],
"role": random.choice(models.RoleChoices.values),
}
for field, value in new_values.items():
@@ -647,7 +647,7 @@ def test_api_template_accesses_delete_administrators_except_owners(
via, mock_user_teams
):
"""
Users who are administrators in a template should be allowed to delete access
Users who are administrators in a template should be allowed to delete an access
from the template provided it is not ownership.
"""
user = factories.UserFactory()

View File

@@ -23,7 +23,7 @@ def test_api_template_accesses_create_anonymous():
{
"user": str(other_user.id),
"template": str(template.id),
"role": random.choice(models.RoleChoices.choices)[0],
"role": random.choice(models.RoleChoices.values),
},
format="json",
)

View File

@@ -1,208 +0,0 @@
"""
Test users API endpoints in the impress core app.
"""
import pytest
from rest_framework.test import APIClient
from core import factories
from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
def test_api_templates_generate_document_anonymous_public():
"""Anonymous users can generate pdf document with public templates."""
template = factories.TemplateFactory(is_public=True)
data = {
"body": "# Test markdown body",
}
response = APIClient().post(
f"/api/v1.0/templates/{template.id!s}/generate-document/",
data,
format="json",
)
assert response.status_code == 200
assert response.headers["content-type"] == "application/pdf"
def test_api_templates_generate_document_anonymous_not_public():
"""
Anonymous users should not be allowed to generate pdf document with templates
that are not marked as public.
"""
template = factories.TemplateFactory(is_public=False)
data = {
"body": "# Test markdown body",
}
response = APIClient().post(
f"/api/v1.0/templates/{template.id!s}/generate-document/",
data,
format="json",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_templates_generate_document_authenticated_public():
"""Authenticated users can generate pdf document with public templates."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory(is_public=True)
data = {"body": "# Test markdown body"}
response = client.post(
f"/api/v1.0/templates/{template.id!s}/generate-document/",
data,
format="json",
)
assert response.status_code == 200
assert response.headers["content-type"] == "application/pdf"
def test_api_templates_generate_document_authenticated_not_public():
"""
Authenticated users should not be allowed to generate pdf document with templates
that are not marked as public.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory(is_public=False)
data = {"body": "# Test markdown body"}
response = client.post(
f"/api/v1.0/templates/{template.id!s}/generate-document/",
data,
format="json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
@pytest.mark.parametrize("via", VIA)
def test_api_templates_generate_document_related(via, mock_user_teams):
"""Users related to a template can generate pdf document."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
access = None
if via == USER:
access = factories.UserTemplateAccessFactory(user=user)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
access = factories.TeamTemplateAccessFactory(team="lasuite")
data = {"body": "# Test markdown body"}
response = client.post(
f"/api/v1.0/templates/{access.template_id!s}/generate-document/",
data,
format="json",
)
assert response.status_code == 200
assert response.headers["content-type"] == "application/pdf"
def test_api_templates_generate_document_type_html():
"""Generate pdf document with the body type html."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory(is_public=True)
data = {"body": "<p>Test body</p>", "body_type": "html"}
response = client.post(
f"/api/v1.0/templates/{template.id!s}/generate-document/",
data,
format="json",
)
assert response.status_code == 200
assert response.headers["content-type"] == "application/pdf"
def test_api_templates_generate_document_type_markdown():
"""Generate pdf document with the body type markdown."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory(is_public=True)
data = {"body": "# Test markdown body", "body_type": "markdown"}
response = client.post(
f"/api/v1.0/templates/{template.id!s}/generate-document/",
data,
format="json",
)
assert response.status_code == 200
assert response.headers["content-type"] == "application/pdf"
def test_api_templates_generate_document_type_unknown():
"""Generate pdf document with the body type unknown."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory(is_public=True)
data = {"body": "# Test markdown body", "body_type": "unknown"}
response = client.post(
f"/api/v1.0/templates/{template.id!s}/generate-document/",
data,
format="json",
)
assert response.status_code == 400
assert response.json() == {
"body_type": [
'"unknown" is not a valid choice.',
]
}
def test_api_templates_generate_document_export_docx():
"""Generate pdf document with the body type html."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory(is_public=True)
data = {"body": "<p>Test body</p>", "body_type": "html", "format": "docx"}
response = client.post(
f"/api/v1.0/templates/{template.id!s}/generate-document/",
data,
format="json",
)
assert response.status_code == 200
assert (
response.headers["content-type"]
== "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
)

View File

@@ -187,9 +187,9 @@ def test_api_templates_list_order_default():
response_template_ids = [template["id"] for template in response_data["results"]]
template_ids.reverse()
assert (
response_template_ids == template_ids
), "created_at values are not sorted from newest to oldest"
assert response_template_ids == template_ids, (
"created_at values are not sorted from newest to oldest"
)
def test_api_templates_list_order_param():
@@ -215,6 +215,6 @@ def test_api_templates_list_order_param():
response_template_ids = [template["id"] for template in response_data["results"]]
assert (
response_template_ids == templates_ids
), "created_at values are not sorted from oldest to newest"
assert response_template_ids == templates_ids, (
"created_at values are not sorted from oldest to newest"
)

View File

@@ -20,6 +20,7 @@ pytestmark = pytest.mark.django_db
CRISP_WEBSITE_ID="123",
FRONTEND_THEME="test-theme",
MEDIA_BASE_URL="http://testserver/",
POSTHOG_KEY={"id": "132456", "host": "https://eu.i.posthog-test.com"},
SENTRY_DSN="https://sentry.test/123",
)
@pytest.mark.parametrize("is_authenticated", [False, True])
@@ -41,5 +42,6 @@ def test_api_config(is_authenticated):
"LANGUAGES": [["en-us", "English"], ["fr-fr", "French"], ["de-de", "German"]],
"LANGUAGE_CODE": "en-us",
"MEDIA_BASE_URL": "http://testserver/",
"POSTHOG_KEY": {"id": "132456", "host": "https://eu.i.posthog-test.com"},
"SENTRY_DSN": "https://sentry.test/123",
}

View File

@@ -42,8 +42,9 @@ def test_api_users_list_authenticated():
def test_api_users_list_query_email():
"""
Authenticated users should be able to list users
and filter by email.
Authenticated users should be able to list users and filter by email.
Only results with a Levenstein distance less than 3 with the query should be returned.
We want to match by Levenstein distance because we want to prevent typing errors.
"""
user = factories.UserFactory()
@@ -51,9 +52,7 @@ def test_api_users_list_query_email():
client.force_login(user)
dave = factories.UserFactory(email="david.bowman@work.com")
nicole = factories.UserFactory(email="nicole_foole@work.com")
frank = factories.UserFactory(email="frank_poole@work.com")
factories.UserFactory(email="heywood_floyd@work.com")
factories.UserFactory(email="nicole.bowman@work.com")
response = client.get(
"/api/v1.0/users/?q=david.bowman@work.com",
@@ -62,59 +61,53 @@ def test_api_users_list_query_email():
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(dave.id)]
response = client.get("/api/v1.0/users/?q=oole")
response = client.get(
"/api/v1.0/users/?q=davig.bovman@worm.com",
)
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(nicole.id), str(frank.id)]
assert user_ids == [str(dave.id)]
response = client.get(
"/api/v1.0/users/?q=davig.bovman@worm.cop",
)
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == []
def test_api_users_list_query_email_matching():
"""While filtering by email, results should be filtered and sorted by similarity"""
"""While filtering by email, results should be filtered and sorted by Levenstein distance."""
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")
user1 = factories.UserFactory(email="alice.johnson@example.gouv.fr")
user2 = factories.UserFactory(email="alice.johnnson@example.gouv.fr")
user3 = factories.UserFactory(email="alice.kohlson@example.gouv.fr")
user4 = factories.UserFactory(email="alicia.johnnson@example.gouv.fr")
user5 = factories.UserFactory(email="alicia.johnnson@example.gov.uk")
factories.UserFactory(email="alice.thomson@example.gouv.fr")
response = client.get(
"/api/v1.0/users/?q=michael.johnson@example.gouv.f",
"/api/v1.0/users/?q=alice.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)]
assert user_ids == [str(user1.id), str(user2.id), str(user3.id), str(user4.id)]
response = client.get("/api/v1.0/users/?q=michael.johnson@example.gouv.fr")
response = client.get("/api/v1.0/users/?q=alicia.johnnson@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)]
assert user_ids == [str(user4.id), str(user2.id), str(user1.id), str(user5.id)]
def test_api_users_list_query_email_exclude_doc_user():
"""
Authenticated users should be able to list users
and filter by email and exclude users who have access to a document.
Authenticated users should be able to list users while filtering by email
and excluding users who have access to a document.
"""
user = factories.UserFactory()
document = factories.DocumentFactory()
@@ -122,17 +115,19 @@ def test_api_users_list_query_email_exclude_doc_user():
client = APIClient()
client.force_login(user)
nicole = factories.UserFactory(email="nicole_foole@work.com")
frank = factories.UserFactory(email="frank_poole@work.com")
nicole_fool = factories.UserFactory(email="nicole_fool@work.com")
nicole_pool = factories.UserFactory(email="nicole_pool@work.com")
factories.UserFactory(email="heywood_floyd@work.com")
factories.UserDocumentAccessFactory(document=document, user=frank)
factories.UserDocumentAccessFactory(document=document, user=nicole_pool)
response = client.get("/api/v1.0/users/?q=oole&document_id=" + str(document.id))
response = client.get(
"/api/v1.0/users/?q=nicole_fool@work.com&document_id=" + str(document.id)
)
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(nicole.id)]
assert user_ids == [str(nicole_fool.id)]
def test_api_users_retrieve_me_anonymous():

View File

@@ -0,0 +1,94 @@
"""
Unit tests for the filter_root_paths utility function.
"""
from core.api.utils import filter_root_paths
def test_api_utils_filter_root_paths_success():
"""
The `filter_root_paths` function should correctly identify root paths
from a given list of paths.
This test uses a list of paths with missing intermediate paths to ensure that
only the minimal set of root paths is returned.
"""
paths = [
"0001",
"00010001",
"000100010001",
"000100010002",
# missing 00010002
"000100020001",
"000100020002",
"0002",
"00020001",
"00020002",
# missing 0003
"00030001",
"000300010001",
"00030002",
# missing 0004
# missing 00040001
# missing 000400010001
# missing 000400010002
"000400010003",
"0004000100030001",
"000400010004",
]
filtered_paths = filter_root_paths(paths, skip_sorting=True)
assert filtered_paths == [
"0001",
"0002",
"00030001",
"00030002",
"000400010003",
"000400010004",
]
def test_api_utils_filter_root_paths_sorting():
"""
The `filter_root_paths` function should fail is sorting is skipped and paths are not sorted.
This test verifies that when sorting is skipped, the function respects the input order, and
when sorting is enabled, the result is correctly ordered and minimal.
"""
paths = [
"0001",
"00010001",
"000100010001",
"000100020002",
"000100010002",
"000100020001",
"00020001",
"0002",
"00020002",
"000300010001",
"00030001",
"00030002",
"0004000100030001",
"000400010003",
"000400010004",
]
filtered_paths = filter_root_paths(paths, skip_sorting=True)
assert filtered_paths == [
"0001",
"00020001",
"0002",
"000300010001",
"00030001",
"00030002",
"0004000100030001",
"000400010003",
"000400010004",
]
filtered_paths = filter_root_paths(paths)
assert filtered_paths == [
"0001",
"0002",
"00030001",
"00030002",
"000400010003",
"000400010004",
]

View File

@@ -2,12 +2,14 @@
Unit tests for the Document model
"""
import random
import smtplib
from logging import Logger
from unittest import mock
from django.contrib.auth.models import AnonymousUser
from django.core import mail
from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.core.files.storage import default_storage
from django.utils import timezone
@@ -34,20 +36,18 @@ def test_models_documents_id_unique():
def test_models_documents_creator_required():
"""No field should be required on the Document model."""
models.Document.objects.create()
models.Document.add_root()
def test_models_documents_title_null():
"""The "title" field can be null."""
document = models.Document.objects.create(
title=None, creator=factories.UserFactory()
)
document = models.Document.add_root(title=None, creator=factories.UserFactory())
assert document.title is None
def test_models_documents_title_empty():
"""The "title" field can be empty."""
document = models.Document.objects.create(title="", creator=factories.UserFactory())
document = models.Document.add_root(title="", creator=factories.UserFactory())
assert document.title == ""
@@ -67,6 +67,60 @@ def test_models_documents_file_key():
assert document.file_key == "9531a5f1-42b1-496c-b3f4-1c09ed139b3c/file"
def test_models_documents_tree_alphabet():
"""Test the creation of documents with treebeard methods."""
models.Document.load_bulk(
[
{
"data": {
"title": f"document-{i}",
}
}
for i in range(len(models.Document.alphabet) * 2)
]
)
assert models.Document.objects.count() == 124
@pytest.mark.parametrize("depth", range(5))
def test_models_documents_soft_delete(depth):
"""Trying to delete a document that is already deleted or is a descendant of
a deleted document should raise an error.
"""
documents = []
for i in range(depth + 1):
documents.append(
factories.DocumentFactory()
if i == 0
else factories.DocumentFactory(parent=documents[-1])
)
assert models.Document.objects.count() == depth + 1
# Delete any one of the documents...
deleted_document = random.choice(documents)
deleted_document.soft_delete()
with pytest.raises(RuntimeError):
documents[-1].soft_delete()
assert deleted_document.deleted_at is not None
assert deleted_document.ancestors_deleted_at == deleted_document.deleted_at
descendants = deleted_document.get_descendants()
for child in descendants:
assert child.deleted_at is None
assert child.ancestors_deleted_at is not None
assert child.ancestors_deleted_at == deleted_document.deleted_at
ancestors = deleted_document.get_ancestors()
for parent in ancestors:
assert parent.deleted_at is None
assert parent.ancestors_deleted_at is None
assert len(ancestors) + len(descendants) == depth
# get_abilities
@@ -81,33 +135,44 @@ def test_models_documents_file_key():
(False, "authenticated", "editor"),
],
)
def test_models_documents_get_abilities_forbidden(is_authenticated, reach, role):
def test_models_documents_get_abilities_forbidden(
is_authenticated, reach, role, django_assert_num_queries
):
"""
Check abilities returned for a document giving insufficient roles to link holders
i.e. anonymous users or authenticated users who have no specific role on the document.
i.e anonymous users or authenticated users who have no specific role on the document.
"""
document = factories.DocumentFactory(link_reach=reach, link_role=role)
user = factories.UserFactory() if is_authenticated else AnonymousUser()
abilities = document.get_abilities(user)
assert abilities == {
expected_abilities = {
"accesses_manage": False,
"accesses_view": False,
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
"children_create": False,
"children_list": False,
"collaboration_auth": False,
"destroy": False,
"favorite": False,
"invite_owner": False,
"media_auth": False,
"move": False,
"link_configuration": False,
"partial_update": False,
"restore": False,
"retrieve": False,
"update": False,
"versions_destroy": False,
"versions_list": False,
"versions_retrieve": False,
}
nb_queries = 1 if is_authenticated else 0
with django_assert_num_queries(nb_queries):
assert document.get_abilities(user) == expected_abilities
document.soft_delete()
document.refresh_from_db()
assert document.get_abilities(user) == expected_abilities
@pytest.mark.parametrize(
@@ -118,33 +183,44 @@ def test_models_documents_get_abilities_forbidden(is_authenticated, reach, role)
(True, "authenticated"),
],
)
def test_models_documents_get_abilities_reader(is_authenticated, reach):
def test_models_documents_get_abilities_reader(
is_authenticated, reach, django_assert_num_queries
):
"""
Check abilities returned for a document giving reader role to link holders
i.e. anonymous users or authenticated users who have no specific role on the document.
i.e anonymous users or authenticated users who have no specific role on the document.
"""
document = factories.DocumentFactory(link_reach=reach, link_role="reader")
user = factories.UserFactory() if is_authenticated else AnonymousUser()
abilities = document.get_abilities(user)
assert abilities == {
expected_abilities = {
"accesses_manage": False,
"accesses_view": False,
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
"children_create": False,
"children_list": True,
"collaboration_auth": True,
"destroy": False,
"favorite": is_authenticated,
"invite_owner": False,
"link_configuration": False,
"media_auth": True,
"move": False,
"partial_update": False,
"restore": False,
"retrieve": True,
"update": False,
"versions_destroy": False,
"versions_list": False,
"versions_retrieve": False,
}
nb_queries = 1 if is_authenticated else 0
with django_assert_num_queries(nb_queries):
assert document.get_abilities(user) == expected_abilities
document.soft_delete()
document.refresh_from_db()
assert all(value is False for value in document.get_abilities(user).values())
@pytest.mark.parametrize(
@@ -155,142 +231,184 @@ def test_models_documents_get_abilities_reader(is_authenticated, reach):
(True, "authenticated"),
],
)
def test_models_documents_get_abilities_editor(is_authenticated, reach):
def test_models_documents_get_abilities_editor(
is_authenticated, reach, django_assert_num_queries
):
"""
Check abilities returned for a document giving editor role to link holders
i.e. anonymous users or authenticated users who have no specific role on the document.
i.e anonymous users or authenticated users who have no specific role on the document.
"""
document = factories.DocumentFactory(link_reach=reach, link_role="editor")
user = factories.UserFactory() if is_authenticated else AnonymousUser()
abilities = document.get_abilities(user)
assert abilities == {
expected_abilities = {
"accesses_manage": False,
"accesses_view": False,
"ai_transform": True,
"ai_translate": True,
"attachment_upload": True,
"children_create": is_authenticated,
"children_list": True,
"collaboration_auth": True,
"destroy": False,
"favorite": is_authenticated,
"invite_owner": False,
"link_configuration": False,
"media_auth": True,
"move": False,
"partial_update": True,
"restore": False,
"retrieve": True,
"update": True,
"versions_destroy": False,
"versions_list": False,
"versions_retrieve": False,
}
nb_queries = 1 if is_authenticated else 0
with django_assert_num_queries(nb_queries):
assert document.get_abilities(user) == expected_abilities
document.soft_delete()
document.refresh_from_db()
assert all(value is False for value in document.get_abilities(user).values())
def test_models_documents_get_abilities_owner():
def test_models_documents_get_abilities_owner(django_assert_num_queries):
"""Check abilities returned for the owner of a document."""
user = factories.UserFactory()
access = factories.UserDocumentAccessFactory(role="owner", user=user)
abilities = access.document.get_abilities(access.user)
assert abilities == {
document = factories.DocumentFactory(users=[(user, "owner")])
expected_abilities = {
"accesses_manage": True,
"accesses_view": True,
"ai_transform": True,
"ai_translate": True,
"attachment_upload": True,
"children_create": True,
"children_list": True,
"collaboration_auth": True,
"destroy": True,
"favorite": True,
"invite_owner": True,
"link_configuration": True,
"media_auth": True,
"move": True,
"partial_update": True,
"restore": True,
"retrieve": True,
"update": True,
"versions_destroy": True,
"versions_list": True,
"versions_retrieve": True,
}
with django_assert_num_queries(1):
assert document.get_abilities(user) == expected_abilities
document.soft_delete()
document.refresh_from_db()
expected_abilities["move"] = False
assert document.get_abilities(user) == expected_abilities
def test_models_documents_get_abilities_administrator():
def test_models_documents_get_abilities_administrator(django_assert_num_queries):
"""Check abilities returned for the administrator of a document."""
access = factories.UserDocumentAccessFactory(role="administrator")
abilities = access.document.get_abilities(access.user)
assert abilities == {
user = factories.UserFactory()
document = factories.DocumentFactory(users=[(user, "administrator")])
expected_abilities = {
"accesses_manage": True,
"accesses_view": True,
"ai_transform": True,
"ai_translate": True,
"attachment_upload": True,
"children_create": True,
"children_list": True,
"collaboration_auth": True,
"destroy": False,
"favorite": True,
"invite_owner": False,
"link_configuration": True,
"media_auth": True,
"move": True,
"partial_update": True,
"restore": False,
"retrieve": True,
"update": True,
"versions_destroy": True,
"versions_list": True,
"versions_retrieve": True,
}
with django_assert_num_queries(1):
assert document.get_abilities(user) == expected_abilities
document.soft_delete()
document.refresh_from_db()
assert all(value is False for value in document.get_abilities(user).values())
def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
"""Check abilities returned for the editor of a document."""
access = factories.UserDocumentAccessFactory(role="editor")
with django_assert_num_queries(1):
abilities = access.document.get_abilities(access.user)
assert abilities == {
user = factories.UserFactory()
document = factories.DocumentFactory(users=[(user, "editor")])
expected_abilities = {
"accesses_manage": False,
"accesses_view": True,
"ai_transform": True,
"ai_translate": True,
"attachment_upload": True,
"children_create": True,
"children_list": True,
"collaboration_auth": True,
"destroy": False,
"favorite": True,
"invite_owner": False,
"link_configuration": False,
"media_auth": True,
"move": False,
"partial_update": True,
"restore": False,
"retrieve": True,
"update": True,
"versions_destroy": False,
"versions_list": True,
"versions_retrieve": True,
}
with django_assert_num_queries(1):
assert document.get_abilities(user) == expected_abilities
document.soft_delete()
document.refresh_from_db()
assert all(value is False for value in document.get_abilities(user).values())
def test_models_documents_get_abilities_reader_user(django_assert_num_queries):
"""Check abilities returned for the reader of a document."""
access = factories.UserDocumentAccessFactory(
role="reader", document__link_role="reader"
user = factories.UserFactory()
document = factories.DocumentFactory(users=[(user, "reader")])
access_from_link = (
document.link_reach != "restricted" and document.link_role == "editor"
)
with django_assert_num_queries(1):
abilities = access.document.get_abilities(access.user)
assert abilities == {
expected_abilities = {
"accesses_manage": False,
"accesses_view": True,
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
"ai_transform": access_from_link,
"ai_translate": access_from_link,
"attachment_upload": access_from_link,
"children_create": access_from_link,
"children_list": True,
"collaboration_auth": True,
"destroy": False,
"favorite": True,
"invite_owner": False,
"link_configuration": False,
"media_auth": True,
"partial_update": False,
"move": False,
"partial_update": access_from_link,
"restore": False,
"retrieve": True,
"update": False,
"update": access_from_link,
"versions_destroy": False,
"versions_list": True,
"versions_retrieve": True,
}
with django_assert_num_queries(1):
assert document.get_abilities(user) == expected_abilities
document.soft_delete()
document.refresh_from_db()
assert all(value is False for value in document.get_abilities(user).values())
def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
@@ -309,13 +427,17 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
"children_create": False,
"children_list": True,
"collaboration_auth": True,
"destroy": False,
"favorite": True,
"invite_owner": False,
"link_configuration": False,
"media_auth": True,
"move": False,
"partial_update": False,
"restore": False,
"retrieve": True,
"update": False,
"versions_destroy": False,
@@ -449,7 +571,7 @@ def test_models_documents__email_invitation__success():
def test_models_documents__email_invitation__success_fr():
"""
The email invitation is sent successfully in French.
The email invitation is sent successfully in french.
"""
document = factories.DocumentFactory()
@@ -517,3 +639,62 @@ def test_models_documents__email_invitation__failed(mock_logger, _mock_send_mail
assert emails == ["guest3@example.com"]
assert isinstance(exception, smtplib.SMTPException)
# Document number of accesses
def test_models_documents_nb_accesses_cache_is_set_and_retrieved(
django_assert_num_queries,
):
"""Test that nb_accesses is cached after the first computation."""
document = factories.DocumentFactory()
key = f"document_{document.id!s}_nb_accesses"
nb_accesses = random.randint(1, 4)
factories.UserDocumentAccessFactory.create_batch(nb_accesses, document=document)
factories.UserDocumentAccessFactory() # An unrelated access should not be counted
# Initially, the nb_accesses should not be cached
assert cache.get(key) is None
# Compute the nb_accesses for the first time (this should set the cache)
with django_assert_num_queries(1):
assert document.nb_accesses == nb_accesses
# Ensure that the nb_accesses is now cached
with django_assert_num_queries(0):
assert document.nb_accesses == nb_accesses
assert cache.get(key) == nb_accesses
# The cache value should be invalidated when a document access is created
models.DocumentAccess.objects.create(
document=document, user=factories.UserFactory(), role="reader"
)
assert cache.get(key) is None # Cache should be invalidated
with django_assert_num_queries(1):
new_nb_accesses = document.nb_accesses
assert new_nb_accesses == nb_accesses + 1
assert cache.get(key) == new_nb_accesses # Cache should now contain the new value
def test_models_documents_nb_accesses_cache_is_invalidated_on_access_removal(
django_assert_num_queries,
):
"""Test that the cache is invalidated when a document access is deleted."""
document = factories.DocumentFactory()
key = f"document_{document.id!s}_nb_accesses"
access = factories.UserDocumentAccessFactory(document=document)
# Initially, the nb_accesses should be cached
assert document.nb_accesses == 1
assert cache.get(key) == 1
# Remove the access and check if cache is invalidated
access.delete()
assert cache.get(key) is None # Cache should be invalidated
# Recompute the nb_accesses (this should trigger a cache set)
with django_assert_num_queries(1):
new_nb_accesses = document.nb_accesses
assert new_nb_accesses == 0
assert cache.get(key) == 0 # Cache should now contain the new value

View File

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

View File

@@ -27,7 +27,7 @@ def test_models_users_id_unique():
def test_models_users_send_mail_main_existing():
"""The 'email_user' method should send mail to the user's email address."""
"""The "email_user' method should send mail to the user's email address."""
user = factories.UserFactory()
with mock.patch("django.core.mail.send_mail") as mock_send:
@@ -37,7 +37,7 @@ def test_models_users_send_mail_main_existing():
def test_models_users_send_mail_main_missing():
"""The 'email_user' method should fail if the user has no email address."""
"""The "email_user' method should fail if the user has no email address."""
user = factories.UserFactory(email=None)
with pytest.raises(ValueError) as excinfo:

View File

@@ -1,5 +1,5 @@
"""
Test AI API endpoints in the impress core app.
Test ai API endpoints in the impress core app.
"""
import json

View File

@@ -55,9 +55,9 @@ def mock_reset_connections(settings):
)
yield
assert (
len(rsps.calls) == 1
), "Expected one call to reset-connections endpoint"
assert len(rsps.calls) == 1, (
"Expected one call to reset-connections endpoint"
)
request = rsps.calls[0].request
assert request.url == endpoint_url, f"Unexpected URL called: {request.url}"
assert (
@@ -66,9 +66,9 @@ def mock_reset_connections(settings):
), "Incorrect Authorization header"
if user_id:
assert (
request.headers.get("X-User-Id") == user_id
), "Incorrect X-User-Id header"
assert request.headers.get("X-User-Id") == user_id, (
"Incorrect X-User-Id header"
)
return _mock_reset_connections

View File

@@ -0,0 +1,30 @@
"""
Unit tests for the User model
"""
import pytest
from impress.settings import Base
def test_invalid_settings_oidc_email_configuration():
"""
The OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION and OIDC_ALLOW_DUPLICATE_EMAILS settings
should not be both set to True simultaneously.
"""
class TestSettings(Base):
"""Fake test settings."""
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
OIDC_ALLOW_DUPLICATE_EMAILS = True
# The validation is performed during post_setup
with pytest.raises(ValueError) as excinfo:
TestSettings().post_setup()
# Check the exception message
assert str(excinfo.value) == (
"Both OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION and "
"OIDC_ALLOW_DUPLICATE_EMAILS cannot be set to True simultaneously. "
)

View File

@@ -1,10 +1,2 @@
<page size="A4">
<div class="header">
<img width="200"
src="https://impress-staging.beta.numerique.gouv.fr/assets/logo-gouv.png"
/>
</div>
<div class="content">
<div class="body">{{ body }}</div>
</div>
</page>
<img width="200" src="https://impress-staging.beta.numerique.gouv.fr/assets/logo-gouv.png" />
<br/>

View File

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

View File

@@ -135,9 +135,14 @@ def create_demo(stdout):
users_ids = list(models.User.objects.values_list("id", flat=True))
with Timeit(stdout, "Creating documents"):
for _ in range(defaults.NB_OBJECTS["docs"]):
for i in range(defaults.NB_OBJECTS["docs"]):
# pylint: disable=protected-access
key = models.Document._int2str(i) # noqa: SLF001
padding = models.Document.alphabet[0] * (models.Document.steplen - len(key))
queue.push(
models.Document(
depth=1,
path=f"{padding}{key}",
creator_id=random.choice(users_ids),
title=fake.sentence(nb_words=4),
link_reach=models.LinkReachChoices.AUTHENTICATED

View File

@@ -15,7 +15,7 @@ class Command(BaseCommand):
"""Define required arguments "email" and "password"."""
parser.add_argument(
"--email",
help="Email for the user.",
help=("Email for the user."),
)
parser.add_argument(
"--password",

View File

@@ -1,5 +1,7 @@
"""Test the `create_demo` management command"""
from unittest import mock
from django.core.management import call_command
from django.test import override_settings
@@ -10,15 +12,23 @@ from core import models
pytestmark = pytest.mark.django_db
@mock.patch(
"demo.defaults.NB_OBJECTS",
{
"users": 10,
"docs": 10,
"max_users_per_document": 5,
},
)
@override_settings(DEBUG=True)
def test_commands_create_demo():
"""The create_demo management command should create objects as expected."""
call_command("create_demo")
assert models.Template.objects.count() == 1
assert models.User.objects.count() >= 50
assert models.Document.objects.count() >= 50
assert models.DocumentAccess.objects.count() > 50
assert models.User.objects.count() >= 10
assert models.Document.objects.count() >= 10
assert models.DocumentAccess.objects.count() > 10
# assert dev users have doc accesses
user = models.User.objects.get(email="impress@impress.world")

View File

@@ -296,9 +296,11 @@ class Base(Configuration):
"drf_spectacular",
# Third party apps
"corsheaders",
"django_filters",
"dockerflow.django",
"rest_framework",
"parler",
"treebeard",
"easy_thumbnails",
# Django
"django.contrib.admin",
@@ -350,6 +352,10 @@ class Base(Configuration):
"REDOC_DIST": "SIDECAR",
}
TRASHBIN_CUTOFF_DAYS = values.Value(
30, environ_name="TRASHBIN_CUTOFF_DAYS", environ_prefix=None
)
# Mail
EMAIL_BACKEND = values.Value("django.core.mail.backends.smtp.EmailBackend")
EMAIL_BRAND_NAME = values.Value(None)
@@ -390,6 +396,11 @@ class Base(Configuration):
None, environ_name="FRONTEND_THEME", environ_prefix=None
)
# Posthog
POSTHOG_KEY = values.DictValue(
None, environ_name="POSTHOG_KEY", environ_prefix=None
)
# Crisp
CRISP_WEBSITE_ID = values.Value(
None, environ_name="CRISP_WEBSITE_ID", environ_prefix=None
@@ -474,6 +485,15 @@ class Base(Configuration):
environ_prefix=None,
)
# WARNING: Enabling this setting allows multiple user accounts to share the same email
# address. This may cause security issues and is not recommended for production use when
# email is activated as fallback for identification (see previous setting).
OIDC_ALLOW_DUPLICATE_EMAILS = values.BooleanValue(
default=False,
environ_name="OIDC_ALLOW_DUPLICATE_EMAILS",
environ_prefix=None,
)
USER_OIDC_ESSENTIAL_CLAIMS = values.ListValue(
default=[], environ_name="USER_OIDC_ESSENTIAL_CLAIMS", environ_prefix=None
)
@@ -622,9 +642,17 @@ class Base(Configuration):
release=get_release(),
integrations=[DjangoIntegration()],
)
# Add the application name to the Sentry scope
scope = sentry_sdk.get_global_scope()
scope.set_tag("application", "backend")
with sentry_sdk.configure_scope() as scope:
scope.set_extra("application", "backend")
if (
cls.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION
and cls.OIDC_ALLOW_DUPLICATE_EMAILS
):
raise ValueError(
"Both OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION and "
"OIDC_ALLOW_DUPLICATE_EMAILS cannot be set to True simultaneously. "
)
class Build(Base):
@@ -748,6 +776,11 @@ class Production(Base):
environ_name="REDIS_URL",
environ_prefix=None,
),
"TIMEOUT": values.IntegerValue(
30, # timeout in seconds
environ_name="CACHES_DEFAULT_TIMEOUT",
environ_prefix=None,
),
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
},

View File

@@ -1,9 +1,9 @@
msgid ""
msgstr ""
"Project-Id-Version: lasuite-people\n"
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-12-17 15:50+0000\n"
"PO-Revision-Date: 2024-12-17 15:53\n"
"POT-Creation-Date: 2025-02-06 15:30+0000\n"
"PO-Revision-Date: 2025-02-06 15:59\n"
"Last-Translator: \n"
"Language-Team: German\n"
"Language: de_DE\n"
@@ -11,384 +11,404 @@ msgstr ""
"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-Project: lasuite-docs\n"
"X-Crowdin-Project-ID: 754523\n"
"X-Crowdin-Language: de\n"
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 8\n"
"X-Crowdin-File-ID: 18\n"
#: core/admin.py:33
#: build/lib/core/admin.py:37 core/admin.py:37
msgid "Personal info"
msgstr "Persönliche Daten"
#: core/admin.py:46
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
msgid "Permissions"
msgstr "Berechtigungen"
#: core/admin.py:58
#: build/lib/core/admin.py:62 core/admin.py:62
msgid "Important dates"
msgstr "Wichtige Daten"
#: core/api/filters.py:16
#: build/lib/core/admin.py:148 core/admin.py:148
msgid "Tree structure"
msgstr ""
#: build/lib/core/api/filters.py:16 core/api/filters.py:16
msgid "Creator is me"
msgstr ""
msgstr "Ersteller bin ich"
#: core/api/filters.py:19
#: build/lib/core/api/filters.py:19 core/api/filters.py:19
msgid "Favorite"
msgstr ""
msgstr "Favorit"
#: core/api/filters.py:22
#: build/lib/core/api/filters.py:22 core/api/filters.py:22
msgid "Title"
msgstr ""
msgstr "Titel"
#: core/api/serializers.py:307
#: build/lib/core/api/serializers.py:346 core/api/serializers.py:346
msgid "A new document was created on your behalf!"
msgstr ""
msgstr "Ein neues Dokument wurde in Ihrem Namen erstellt!"
#: core/api/serializers.py:311
#: build/lib/core/api/serializers.py:350 core/api/serializers.py:350
msgid "You have been granted ownership of a new document:"
msgstr ""
msgstr "Sie sind Besitzer eines neuen Dokuments:"
#: core/api/serializers.py:414
#: build/lib/core/api/serializers.py:453 core/api/serializers.py:453
msgid "Body"
msgstr "Inhalt"
#: core/api/serializers.py:417
#: build/lib/core/api/serializers.py:456 core/api/serializers.py:456
msgid "Body type"
msgstr "Typ"
#: core/api/serializers.py:423
#: build/lib/core/api/serializers.py:462 core/api/serializers.py:462
msgid "Format"
msgstr "Format"
msgstr ""
#: core/authentication/backends.py:57
#: build/lib/core/authentication/backends.py:61
#: core/authentication/backends.py:61
msgid "Invalid response format or token verification failed"
msgstr ""
msgstr "Ungültiges Antwortformat oder Token-Verifizierung fehlgeschlagen"
#: core/authentication/backends.py:81
msgid "User info contained no recognizable user identification"
msgstr ""
#: core/authentication/backends.py:88
#: build/lib/core/authentication/backends.py:108
#: core/authentication/backends.py:108
msgid "User account is disabled"
msgstr "Benutzerkonto ist deaktiviert"
#: build/lib/core/enums.py:19 core/enums.py:19
msgid "First child"
msgstr ""
#: core/models.py:62 core/models.py:69
#: build/lib/core/enums.py:20 core/enums.py:20
msgid "Last child"
msgstr ""
#: build/lib/core/enums.py:21 core/enums.py:21
msgid "First sibling"
msgstr ""
#: build/lib/core/enums.py:22 core/enums.py:22
msgid "Last sibling"
msgstr ""
#: build/lib/core/enums.py:23 core/enums.py:23
msgid "Left"
msgstr ""
#: build/lib/core/enums.py:24 core/enums.py:24
msgid "Right"
msgstr ""
#: build/lib/core/models.py:54 build/lib/core/models.py:61 core/models.py:54
#: core/models.py:61
msgid "Reader"
msgstr "Lesen"
#: core/models.py:63 core/models.py:70
#: build/lib/core/models.py:55 build/lib/core/models.py:62 core/models.py:55
#: core/models.py:62
msgid "Editor"
msgstr "Bearbeiten"
#: core/models.py:71
#: build/lib/core/models.py:63 core/models.py:63
msgid "Administrator"
msgstr "Administrator"
msgstr ""
#: core/models.py:72
#: build/lib/core/models.py:64 core/models.py:64
msgid "Owner"
msgstr "Besitzer"
#: core/models.py:83
#: build/lib/core/models.py:75 core/models.py:75
msgid "Restricted"
msgstr "Beschränkt"
#: core/models.py:87
#: build/lib/core/models.py:79 core/models.py:79
msgid "Authenticated"
msgstr "Authentifiziert"
#: core/models.py:89
#: build/lib/core/models.py:81 core/models.py:81
msgid "Public"
msgstr "Öffentlich"
#: core/models.py:101
#: build/lib/core/models.py:103 core/models.py:103
msgid "id"
msgstr ""
#: core/models.py:102
#: build/lib/core/models.py:104 core/models.py:104
msgid "primary key for the record as UUID"
msgstr ""
#: core/models.py:108
#: build/lib/core/models.py:110 core/models.py:110
msgid "created on"
msgstr "Erstellt"
#: core/models.py:109
#: build/lib/core/models.py:111 core/models.py:111
msgid "date and time at which a record was created"
msgstr "Datum und Uhrzeit, an dem ein Datensatz erstellt wurde"
#: core/models.py:114
#: build/lib/core/models.py:116 core/models.py:116
msgid "updated on"
msgstr "Aktualisiert"
#: core/models.py:115
#: build/lib/core/models.py:117 core/models.py:117
msgid "date and time at which a record was last updated"
msgstr "Datum und Uhrzeit, an dem zuletzt aktualisiert wurde"
#: core/models.py:135
#: build/lib/core/models.py:153 core/models.py:153
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
msgstr ""
#: build/lib/core/models.py:166 core/models.py:166
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
msgstr ""
msgstr "Geben Sie eine gültige Unterseite ein. Dieser Wert darf nur Buchstaben, Zahlen und die @/./+/-/_/: Zeichen enthalten."
#: core/models.py:141
#: build/lib/core/models.py:172 core/models.py:172
msgid "sub"
msgstr ""
msgstr "unter"
#: core/models.py:143
#: build/lib/core/models.py:174 core/models.py:174
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
msgstr ""
msgstr "Erforderlich. 255 Zeichen oder weniger. Buchstaben, Zahlen und die Zeichen @/./+/-/_/:"
#: core/models.py:152
#: build/lib/core/models.py:183 core/models.py:183
msgid "full name"
msgstr ""
msgstr "Name"
#: core/models.py:153
#: build/lib/core/models.py:184 core/models.py:184
msgid "short name"
msgstr ""
msgstr "Kurzbezeichnung"
#: core/models.py:155
#: build/lib/core/models.py:186 core/models.py:186
msgid "identity email address"
msgstr ""
msgstr "Identitäts-E-Mail-Adresse"
#: core/models.py:160
#: build/lib/core/models.py:191 core/models.py:191
msgid "admin email address"
msgstr ""
msgstr "Admin E-Mail-Adresse"
#: core/models.py:167
#: build/lib/core/models.py:198 core/models.py:198
msgid "language"
msgstr "Sprache"
#: core/models.py:168
#: build/lib/core/models.py:199 core/models.py:199
msgid "The language in which the user wants to see the interface."
msgstr ""
msgstr "Die Sprache, in der der Benutzer die Benutzeroberfläche sehen möchte."
#: core/models.py:174
#: build/lib/core/models.py:205 core/models.py:205
msgid "The timezone in which the user wants to see times."
msgstr ""
msgstr "Die Zeitzone, in der der Nutzer Zeiten sehen möchte."
#: core/models.py:177
#: build/lib/core/models.py:208 core/models.py:208
msgid "device"
msgstr ""
msgstr "Gerät"
#: core/models.py:179
#: build/lib/core/models.py:210 core/models.py:210
msgid "Whether the user is a device or a real user."
msgstr ""
msgstr "Ob der Benutzer ein Gerät oder ein echter Benutzer ist."
#: core/models.py:182
#: build/lib/core/models.py:213 core/models.py:213
msgid "staff status"
msgstr ""
msgstr "Status des Teammitgliedes"
#: core/models.py:184
#: build/lib/core/models.py:215 core/models.py:215
msgid "Whether the user can log into this admin site."
msgstr ""
msgstr "Gibt an, ob der Benutzer sich in diese Admin-Seite einloggen kann."
#: core/models.py:187
#: build/lib/core/models.py:218 core/models.py:218
msgid "active"
msgstr ""
msgstr "aktiviert"
#: core/models.py:190
#: build/lib/core/models.py:221 core/models.py:221
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
msgstr "Ob dieser Benutzer als aktiviert behandelt werden soll. Deaktivieren Sie diese Option, anstatt Konten zu löschen."
#: core/models.py:202
#: build/lib/core/models.py:233 core/models.py:233
msgid "user"
msgstr "Benutzer"
#: core/models.py:203
#: build/lib/core/models.py:234 core/models.py:234
msgid "users"
msgstr "Benutzer"
#: core/models.py:342 core/models.py:718
#: build/lib/core/models.py:373 build/lib/core/models.py:925 core/models.py:373
#: core/models.py:925
msgid "title"
msgstr "Titel"
#: core/models.py:364
#: build/lib/core/models.py:374 core/models.py:374
msgid "excerpt"
msgstr ""
#: build/lib/core/models.py:405 core/models.py:405
msgid "Document"
msgstr "Dokument"
#: core/models.py:365
#: build/lib/core/models.py:406 core/models.py:406
msgid "Documents"
msgstr "Dokumente"
#: core/models.py:368
#: build/lib/core/models.py:418 core/models.py:418
msgid "Untitled Document"
msgstr "Unbenanntes Dokument"
#: core/models.py:593
#: build/lib/core/models.py:719 core/models.py:719
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
msgstr "{name} hat ein Dokument mit Ihnen geteilt!"
#: core/models.py:597
#: build/lib/core/models.py:723 core/models.py:723
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr ""
msgstr "{name} hat Sie mit der Rolle \"{role}\" zu folgendem Dokument eingeladen:"
#: core/models.py:600
#: build/lib/core/models.py:726 core/models.py:726
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} hat ein Dokument mit Ihnen geteilt: {title}"
#: build/lib/core/models.py:762 core/models.py:762
msgid "This document is not deleted."
msgstr ""
#: core/models.py:623
#: build/lib/core/models.py:769 core/models.py:769
msgid "This document was permanently deleted and cannot be restored."
msgstr ""
#: build/lib/core/models.py:820 core/models.py:820
msgid "Document/user link trace"
msgstr ""
msgstr "Dokument/Benutzer Linkverfolgung"
#: core/models.py:624
#: build/lib/core/models.py:821 core/models.py:821
msgid "Document/user link traces"
msgstr ""
msgstr "Dokument/Benutzer Linkverfolgung"
#: core/models.py:630
#: build/lib/core/models.py:827 core/models.py:827
msgid "A link trace already exists for this document/user."
msgstr ""
#: core/models.py:653
#: build/lib/core/models.py:850 core/models.py:850
msgid "Document favorite"
msgstr ""
msgstr "Dokumentenfavorit"
#: core/models.py:654
#: build/lib/core/models.py:851 core/models.py:851
msgid "Document favorites"
msgstr ""
msgstr "Dokumentfavoriten"
#: core/models.py:660
#: build/lib/core/models.py:857 core/models.py:857
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
msgstr "Dieses Dokument ist bereits durch den gleichen Benutzer favorisiert worden."
#: core/models.py:682
#: build/lib/core/models.py:879 core/models.py:879
msgid "Document/user relation"
msgstr ""
msgstr "Dokument/Benutzerbeziehung"
#: core/models.py:683
#: build/lib/core/models.py:880 core/models.py:880
msgid "Document/user relations"
msgstr ""
msgstr "Dokument/Benutzerbeziehungen"
#: core/models.py:689
#: build/lib/core/models.py:886 core/models.py:886
msgid "This user is already in this document."
msgstr "Dieser Benutzer befindet sich bereits in diesem Dokument."
#: core/models.py:695
#: build/lib/core/models.py:892 core/models.py:892
msgid "This team is already in this document."
msgstr "Dieses Team befindet sich bereits in diesem Dokument."
#: core/models.py:701 core/models.py:890
#: build/lib/core/models.py:898 build/lib/core/models.py:1012
#: core/models.py:898 core/models.py:1012
msgid "Either user or team must be set, not both."
msgstr "Benutzer oder Team müssen gesetzt werden, nicht beides."
#: core/models.py:719
#: build/lib/core/models.py:926 core/models.py:926
msgid "description"
msgstr "Beschreibung"
#: core/models.py:720
#: build/lib/core/models.py:927 core/models.py:927
msgid "code"
msgstr "Code"
#: core/models.py:721
#: build/lib/core/models.py:928 core/models.py:928
msgid "css"
msgstr "CSS"
#: core/models.py:723
#: build/lib/core/models.py:930 core/models.py:930
msgid "public"
msgstr "öffentlich"
#: core/models.py:725
#: build/lib/core/models.py:932 core/models.py:932
msgid "Whether this template is public for anyone to use."
msgstr "Ob diese Vorlage für jedermann öffentlich ist."
#: core/models.py:731
#: build/lib/core/models.py:938 core/models.py:938
msgid "Template"
msgstr ""
msgstr "Vorlage"
#: core/models.py:732
#: build/lib/core/models.py:939 core/models.py:939
msgid "Templates"
msgstr ""
msgstr "Vorlagen"
#: core/models.py:871
#: build/lib/core/models.py:993 core/models.py:993
msgid "Template/user relation"
msgstr ""
msgstr "Vorlage/Benutzer-Beziehung"
#: core/models.py:872
#: build/lib/core/models.py:994 core/models.py:994
msgid "Template/user relations"
msgstr ""
msgstr "Vorlage/Benutzerbeziehungen"
#: core/models.py:878
#: build/lib/core/models.py:1000 core/models.py:1000
msgid "This user is already in this template."
msgstr ""
msgstr "Dieser Benutzer ist bereits in dieser Vorlage."
#: core/models.py:884
#: build/lib/core/models.py:1006 core/models.py:1006
msgid "This team is already in this template."
msgstr ""
msgstr "Dieses Team ist bereits in diesem Template."
#: core/models.py:907
#: build/lib/core/models.py:1029 core/models.py:1029
msgid "email address"
msgstr ""
msgstr "E-Mail-Adresse"
#: core/models.py:926
#: build/lib/core/models.py:1048 core/models.py:1048
msgid "Document invitation"
msgstr ""
msgstr "Einladung zum Dokument"
#: core/models.py:927
#: build/lib/core/models.py:1049 core/models.py:1049
msgid "Document invitations"
msgstr ""
msgstr "Dokumenteinladungen"
#: core/models.py:944
#: build/lib/core/models.py:1069 core/models.py:1069
msgid "This email is already associated to a registered user."
msgstr ""
msgstr "Diese E-Mail ist bereits einem registrierten Benutzer zugeordnet."
#: core/templates/mail/html/hello.html:159 core/templates/mail/text/hello.txt:3
msgid "Company logo"
msgstr ""
#: build/lib/impress/settings.py:236 impress/settings.py:236
msgid "English"
msgstr "Englisch"
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
#, python-format
msgid "Hello %(name)s"
msgstr ""
#: build/lib/impress/settings.py:237 impress/settings.py:237
msgid "French"
msgstr "Französisch"
#: 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 ""
#: build/lib/impress/settings.py:238 impress/settings.py:238
msgid "German"
msgstr "Deutsch"
#: core/templates/mail/html/invitation.html:162
#: core/templates/mail/text/invitation.txt:3
msgid "Logo email"
msgstr ""
msgstr "Logo-E-Mail"
#: core/templates/mail/html/invitation.html:209
#: core/templates/mail/text/invitation.txt:10
msgid "Open"
msgstr ""
msgstr "Öffnen"
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr ""
msgstr " Docs, Ihr neues unentbehrliches Werkzeug für die Organisation, den Austausch und die Zusammenarbeit in Ihren Dokumenten als Team. "
#: core/templates/mail/html/invitation.html:233
#: core/templates/mail/text/invitation.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
msgstr ""
#: core/templates/mail/text/hello.txt:8
#, python-format
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
msgstr ""
#: impress/settings.py:236
msgid "English"
msgstr ""
#: impress/settings.py:237
msgid "French"
msgstr ""
#: impress/settings.py:238
msgid "German"
msgstr ""
msgstr " Erstellt von %(brandname)s "

View File

@@ -1,9 +1,9 @@
msgid ""
msgstr ""
"Project-Id-Version: lasuite-people\n"
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-12-17 15:50+0000\n"
"PO-Revision-Date: 2024-12-17 15:53\n"
"POT-Creation-Date: 2025-02-06 15:30+0000\n"
"PO-Revision-Date: 2025-02-06 15:57\n"
"Last-Translator: \n"
"Language-Team: English\n"
"Language: en_US\n"
@@ -11,347 +11,384 @@ msgstr ""
"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-Project: lasuite-docs\n"
"X-Crowdin-Project-ID: 754523\n"
"X-Crowdin-Language: en\n"
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 8\n"
"X-Crowdin-File-ID: 18\n"
#: core/admin.py:33
#: build/lib/core/admin.py:37 core/admin.py:37
msgid "Personal info"
msgstr ""
#: core/admin.py:46
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
msgid "Permissions"
msgstr ""
#: core/admin.py:58
#: build/lib/core/admin.py:62 core/admin.py:62
msgid "Important dates"
msgstr ""
#: core/api/filters.py:16
#: build/lib/core/admin.py:148 core/admin.py:148
msgid "Tree structure"
msgstr ""
#: build/lib/core/api/filters.py:16 core/api/filters.py:16
msgid "Creator is me"
msgstr ""
#: core/api/filters.py:19
#: build/lib/core/api/filters.py:19 core/api/filters.py:19
msgid "Favorite"
msgstr ""
#: core/api/filters.py:22
#: build/lib/core/api/filters.py:22 core/api/filters.py:22
msgid "Title"
msgstr ""
#: core/api/serializers.py:307
#: build/lib/core/api/serializers.py:346 core/api/serializers.py:346
msgid "A new document was created on your behalf!"
msgstr ""
#: core/api/serializers.py:311
#: build/lib/core/api/serializers.py:350 core/api/serializers.py:350
msgid "You have been granted ownership of a new document:"
msgstr ""
#: core/api/serializers.py:414
#: build/lib/core/api/serializers.py:453 core/api/serializers.py:453
msgid "Body"
msgstr ""
#: core/api/serializers.py:417
#: build/lib/core/api/serializers.py:456 core/api/serializers.py:456
msgid "Body type"
msgstr ""
#: core/api/serializers.py:423
#: build/lib/core/api/serializers.py:462 core/api/serializers.py:462
msgid "Format"
msgstr ""
#: core/authentication/backends.py:57
#: build/lib/core/authentication/backends.py:61
#: core/authentication/backends.py:61
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:88
#: build/lib/core/authentication/backends.py:108
#: core/authentication/backends.py:108
msgid "User account is disabled"
msgstr ""
#: core/models.py:62 core/models.py:69
#: build/lib/core/enums.py:19 core/enums.py:19
msgid "First child"
msgstr ""
#: build/lib/core/enums.py:20 core/enums.py:20
msgid "Last child"
msgstr ""
#: build/lib/core/enums.py:21 core/enums.py:21
msgid "First sibling"
msgstr ""
#: build/lib/core/enums.py:22 core/enums.py:22
msgid "Last sibling"
msgstr ""
#: build/lib/core/enums.py:23 core/enums.py:23
msgid "Left"
msgstr ""
#: build/lib/core/enums.py:24 core/enums.py:24
msgid "Right"
msgstr ""
#: build/lib/core/models.py:54 build/lib/core/models.py:61 core/models.py:54
#: core/models.py:61
msgid "Reader"
msgstr ""
#: core/models.py:63 core/models.py:70
#: build/lib/core/models.py:55 build/lib/core/models.py:62 core/models.py:55
#: core/models.py:62
msgid "Editor"
msgstr ""
#: core/models.py:71
#: build/lib/core/models.py:63 core/models.py:63
msgid "Administrator"
msgstr ""
#: core/models.py:72
#: build/lib/core/models.py:64 core/models.py:64
msgid "Owner"
msgstr ""
#: core/models.py:83
#: build/lib/core/models.py:75 core/models.py:75
msgid "Restricted"
msgstr ""
#: core/models.py:87
#: build/lib/core/models.py:79 core/models.py:79
msgid "Authenticated"
msgstr ""
#: core/models.py:89
#: build/lib/core/models.py:81 core/models.py:81
msgid "Public"
msgstr ""
#: core/models.py:101
#: build/lib/core/models.py:103 core/models.py:103
msgid "id"
msgstr ""
#: core/models.py:102
#: build/lib/core/models.py:104 core/models.py:104
msgid "primary key for the record as UUID"
msgstr ""
#: core/models.py:108
#: build/lib/core/models.py:110 core/models.py:110
msgid "created on"
msgstr ""
#: core/models.py:109
#: build/lib/core/models.py:111 core/models.py:111
msgid "date and time at which a record was created"
msgstr ""
#: core/models.py:114
#: build/lib/core/models.py:116 core/models.py:116
msgid "updated on"
msgstr ""
#: core/models.py:115
#: build/lib/core/models.py:117 core/models.py:117
msgid "date and time at which a record was last updated"
msgstr ""
#: core/models.py:135
#: build/lib/core/models.py:153 core/models.py:153
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
msgstr ""
#: build/lib/core/models.py:166 core/models.py:166
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
msgstr ""
#: core/models.py:141
#: build/lib/core/models.py:172 core/models.py:172
msgid "sub"
msgstr ""
#: core/models.py:143
#: build/lib/core/models.py:174 core/models.py:174
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
msgstr ""
#: core/models.py:152
#: build/lib/core/models.py:183 core/models.py:183
msgid "full name"
msgstr ""
#: core/models.py:153
#: build/lib/core/models.py:184 core/models.py:184
msgid "short name"
msgstr ""
#: core/models.py:155
#: build/lib/core/models.py:186 core/models.py:186
msgid "identity email address"
msgstr ""
#: core/models.py:160
#: build/lib/core/models.py:191 core/models.py:191
msgid "admin email address"
msgstr ""
#: core/models.py:167
#: build/lib/core/models.py:198 core/models.py:198
msgid "language"
msgstr ""
#: core/models.py:168
#: build/lib/core/models.py:199 core/models.py:199
msgid "The language in which the user wants to see the interface."
msgstr ""
#: core/models.py:174
#: build/lib/core/models.py:205 core/models.py:205
msgid "The timezone in which the user wants to see times."
msgstr ""
#: core/models.py:177
#: build/lib/core/models.py:208 core/models.py:208
msgid "device"
msgstr ""
#: core/models.py:179
#: build/lib/core/models.py:210 core/models.py:210
msgid "Whether the user is a device or a real user."
msgstr ""
#: core/models.py:182
#: build/lib/core/models.py:213 core/models.py:213
msgid "staff status"
msgstr ""
#: core/models.py:184
#: build/lib/core/models.py:215 core/models.py:215
msgid "Whether the user can log into this admin site."
msgstr ""
#: core/models.py:187
#: build/lib/core/models.py:218 core/models.py:218
msgid "active"
msgstr ""
#: core/models.py:190
#: build/lib/core/models.py:221 core/models.py:221
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: core/models.py:202
#: build/lib/core/models.py:233 core/models.py:233
msgid "user"
msgstr ""
#: core/models.py:203
#: build/lib/core/models.py:234 core/models.py:234
msgid "users"
msgstr ""
#: core/models.py:342 core/models.py:718
#: build/lib/core/models.py:373 build/lib/core/models.py:925 core/models.py:373
#: core/models.py:925
msgid "title"
msgstr ""
#: core/models.py:364
#: build/lib/core/models.py:374 core/models.py:374
msgid "excerpt"
msgstr ""
#: build/lib/core/models.py:405 core/models.py:405
msgid "Document"
msgstr ""
#: core/models.py:365
#: build/lib/core/models.py:406 core/models.py:406
msgid "Documents"
msgstr ""
#: core/models.py:368
#: build/lib/core/models.py:418 core/models.py:418
msgid "Untitled Document"
msgstr ""
#: core/models.py:593
#: build/lib/core/models.py:719 core/models.py:719
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
#: core/models.py:597
#: build/lib/core/models.py:723 core/models.py:723
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr ""
#: core/models.py:600
#: build/lib/core/models.py:726 core/models.py:726
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr ""
#: core/models.py:623
#: build/lib/core/models.py:762 core/models.py:762
msgid "This document is not deleted."
msgstr ""
#: build/lib/core/models.py:769 core/models.py:769
msgid "This document was permanently deleted and cannot be restored."
msgstr ""
#: build/lib/core/models.py:820 core/models.py:820
msgid "Document/user link trace"
msgstr ""
#: core/models.py:624
#: build/lib/core/models.py:821 core/models.py:821
msgid "Document/user link traces"
msgstr ""
#: core/models.py:630
#: build/lib/core/models.py:827 core/models.py:827
msgid "A link trace already exists for this document/user."
msgstr ""
#: core/models.py:653
#: build/lib/core/models.py:850 core/models.py:850
msgid "Document favorite"
msgstr ""
#: core/models.py:654
#: build/lib/core/models.py:851 core/models.py:851
msgid "Document favorites"
msgstr ""
#: core/models.py:660
#: build/lib/core/models.py:857 core/models.py:857
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: core/models.py:682
#: build/lib/core/models.py:879 core/models.py:879
msgid "Document/user relation"
msgstr ""
#: core/models.py:683
#: build/lib/core/models.py:880 core/models.py:880
msgid "Document/user relations"
msgstr ""
#: core/models.py:689
#: build/lib/core/models.py:886 core/models.py:886
msgid "This user is already in this document."
msgstr ""
#: core/models.py:695
#: build/lib/core/models.py:892 core/models.py:892
msgid "This team is already in this document."
msgstr ""
#: core/models.py:701 core/models.py:890
#: build/lib/core/models.py:898 build/lib/core/models.py:1012
#: core/models.py:898 core/models.py:1012
msgid "Either user or team must be set, not both."
msgstr ""
#: core/models.py:719
#: build/lib/core/models.py:926 core/models.py:926
msgid "description"
msgstr ""
#: core/models.py:720
#: build/lib/core/models.py:927 core/models.py:927
msgid "code"
msgstr ""
#: core/models.py:721
#: build/lib/core/models.py:928 core/models.py:928
msgid "css"
msgstr ""
#: core/models.py:723
#: build/lib/core/models.py:930 core/models.py:930
msgid "public"
msgstr ""
#: core/models.py:725
#: build/lib/core/models.py:932 core/models.py:932
msgid "Whether this template is public for anyone to use."
msgstr ""
#: core/models.py:731
#: build/lib/core/models.py:938 core/models.py:938
msgid "Template"
msgstr ""
#: core/models.py:732
#: build/lib/core/models.py:939 core/models.py:939
msgid "Templates"
msgstr ""
#: core/models.py:871
#: build/lib/core/models.py:993 core/models.py:993
msgid "Template/user relation"
msgstr ""
#: core/models.py:872
#: build/lib/core/models.py:994 core/models.py:994
msgid "Template/user relations"
msgstr ""
#: core/models.py:878
#: build/lib/core/models.py:1000 core/models.py:1000
msgid "This user is already in this template."
msgstr ""
#: core/models.py:884
#: build/lib/core/models.py:1006 core/models.py:1006
msgid "This team is already in this template."
msgstr ""
#: core/models.py:907
#: build/lib/core/models.py:1029 core/models.py:1029
msgid "email address"
msgstr ""
#: core/models.py:926
#: build/lib/core/models.py:1048 core/models.py:1048
msgid "Document invitation"
msgstr ""
#: core/models.py:927
#: build/lib/core/models.py:1049 core/models.py:1049
msgid "Document invitations"
msgstr ""
#: core/models.py:944
#: build/lib/core/models.py:1069 core/models.py:1069
msgid "This email is already associated to a registered user."
msgstr ""
#: core/templates/mail/html/hello.html:159 core/templates/mail/text/hello.txt:3
msgid "Company logo"
#: build/lib/impress/settings.py:236 impress/settings.py:236
msgid "English"
msgstr ""
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
#, python-format
msgid "Hello %(name)s"
#: build/lib/impress/settings.py:237 impress/settings.py:237
msgid "French"
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>"
#: build/lib/impress/settings.py:238 impress/settings.py:238
msgid "German"
msgstr ""
#: core/templates/mail/html/invitation.html:162
@@ -375,20 +412,3 @@ msgstr ""
msgid " Brought to you by %(brandname)s "
msgstr ""
#: core/templates/mail/text/hello.txt:8
#, python-format
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
msgstr ""
#: impress/settings.py:236
msgid "English"
msgstr ""
#: impress/settings.py:237
msgid "French"
msgstr ""
#: impress/settings.py:238
msgid "German"
msgstr ""

View File

@@ -1,9 +1,9 @@
msgid ""
msgstr ""
"Project-Id-Version: lasuite-people\n"
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-12-17 15:50+0000\n"
"PO-Revision-Date: 2024-12-17 15:53\n"
"POT-Creation-Date: 2025-02-06 15:30+0000\n"
"PO-Revision-Date: 2025-02-06 15:59\n"
"Last-Translator: \n"
"Language-Team: French\n"
"Language: fr_FR\n"
@@ -11,353 +11,390 @@ msgstr ""
"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-Project: lasuite-docs\n"
"X-Crowdin-Project-ID: 754523\n"
"X-Crowdin-Language: fr\n"
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 8\n"
"X-Crowdin-File-ID: 18\n"
#: core/admin.py:33
#: build/lib/core/admin.py:37 core/admin.py:37
msgid "Personal info"
msgstr "Infos Personnelles"
#: core/admin.py:46
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
msgid "Permissions"
msgstr "Permissions"
msgstr ""
#: core/admin.py:58
#: build/lib/core/admin.py:62 core/admin.py:62
msgid "Important dates"
msgstr "Dates importantes"
#: core/api/filters.py:16
#: build/lib/core/admin.py:148 core/admin.py:148
msgid "Tree structure"
msgstr ""
#: build/lib/core/api/filters.py:16 core/api/filters.py:16
msgid "Creator is me"
msgstr ""
#: core/api/filters.py:19
#: build/lib/core/api/filters.py:19 core/api/filters.py:19
msgid "Favorite"
msgstr ""
#: core/api/filters.py:22
#: build/lib/core/api/filters.py:22 core/api/filters.py:22
msgid "Title"
msgstr ""
#: core/api/serializers.py:307
#: build/lib/core/api/serializers.py:346 core/api/serializers.py:346
msgid "A new document was created on your behalf!"
msgstr "Un nouveau document a été créé pour vous !"
#: core/api/serializers.py:311
#: build/lib/core/api/serializers.py:350 core/api/serializers.py:350
msgid "You have been granted ownership of a new document:"
msgstr "Vous avez été déclaré propriétaire d'un nouveau document :"
#: core/api/serializers.py:414
#: build/lib/core/api/serializers.py:453 core/api/serializers.py:453
msgid "Body"
msgstr ""
#: core/api/serializers.py:417
#: build/lib/core/api/serializers.py:456 core/api/serializers.py:456
msgid "Body type"
msgstr ""
#: core/api/serializers.py:423
#: build/lib/core/api/serializers.py:462 core/api/serializers.py:462
msgid "Format"
msgstr ""
#: core/authentication/backends.py:57
#: build/lib/core/authentication/backends.py:61
#: core/authentication/backends.py:61
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:88
#: build/lib/core/authentication/backends.py:108
#: core/authentication/backends.py:108
msgid "User account is disabled"
msgstr ""
#: core/models.py:62 core/models.py:69
#: build/lib/core/enums.py:19 core/enums.py:19
msgid "First child"
msgstr ""
#: build/lib/core/enums.py:20 core/enums.py:20
msgid "Last child"
msgstr ""
#: build/lib/core/enums.py:21 core/enums.py:21
msgid "First sibling"
msgstr ""
#: build/lib/core/enums.py:22 core/enums.py:22
msgid "Last sibling"
msgstr ""
#: build/lib/core/enums.py:23 core/enums.py:23
msgid "Left"
msgstr ""
#: build/lib/core/enums.py:24 core/enums.py:24
msgid "Right"
msgstr ""
#: build/lib/core/models.py:54 build/lib/core/models.py:61 core/models.py:54
#: core/models.py:61
msgid "Reader"
msgstr "Lecteur"
#: core/models.py:63 core/models.py:70
#: build/lib/core/models.py:55 build/lib/core/models.py:62 core/models.py:55
#: core/models.py:62
msgid "Editor"
msgstr "Éditeur"
#: core/models.py:71
#: build/lib/core/models.py:63 core/models.py:63
msgid "Administrator"
msgstr "Administrateur"
#: core/models.py:72
#: build/lib/core/models.py:64 core/models.py:64
msgid "Owner"
msgstr "Propriétaire"
#: core/models.py:83
#: build/lib/core/models.py:75 core/models.py:75
msgid "Restricted"
msgstr "Restreint"
#: core/models.py:87
#: build/lib/core/models.py:79 core/models.py:79
msgid "Authenticated"
msgstr "Authentifié"
#: core/models.py:89
#: build/lib/core/models.py:81 core/models.py:81
msgid "Public"
msgstr "Public"
msgstr ""
#: core/models.py:101
#: build/lib/core/models.py:103 core/models.py:103
msgid "id"
msgstr ""
#: core/models.py:102
#: build/lib/core/models.py:104 core/models.py:104
msgid "primary key for the record as UUID"
msgstr ""
#: core/models.py:108
#: build/lib/core/models.py:110 core/models.py:110
msgid "created on"
msgstr ""
#: core/models.py:109
#: build/lib/core/models.py:111 core/models.py:111
msgid "date and time at which a record was created"
msgstr ""
#: core/models.py:114
#: build/lib/core/models.py:116 core/models.py:116
msgid "updated on"
msgstr ""
#: core/models.py:115
#: build/lib/core/models.py:117 core/models.py:117
msgid "date and time at which a record was last updated"
msgstr ""
#: core/models.py:135
#: build/lib/core/models.py:153 core/models.py:153
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
msgstr ""
#: build/lib/core/models.py:166 core/models.py:166
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
msgstr ""
#: core/models.py:141
#: build/lib/core/models.py:172 core/models.py:172
msgid "sub"
msgstr ""
#: core/models.py:143
#: build/lib/core/models.py:174 core/models.py:174
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
msgstr ""
#: core/models.py:152
#: build/lib/core/models.py:183 core/models.py:183
msgid "full name"
msgstr ""
#: core/models.py:153
#: build/lib/core/models.py:184 core/models.py:184
msgid "short name"
msgstr ""
#: core/models.py:155
#: build/lib/core/models.py:186 core/models.py:186
msgid "identity email address"
msgstr ""
#: core/models.py:160
#: build/lib/core/models.py:191 core/models.py:191
msgid "admin email address"
msgstr ""
#: core/models.py:167
#: build/lib/core/models.py:198 core/models.py:198
msgid "language"
msgstr ""
#: core/models.py:168
#: build/lib/core/models.py:199 core/models.py:199
msgid "The language in which the user wants to see the interface."
msgstr ""
#: core/models.py:174
#: build/lib/core/models.py:205 core/models.py:205
msgid "The timezone in which the user wants to see times."
msgstr ""
#: core/models.py:177
#: build/lib/core/models.py:208 core/models.py:208
msgid "device"
msgstr ""
#: core/models.py:179
#: build/lib/core/models.py:210 core/models.py:210
msgid "Whether the user is a device or a real user."
msgstr ""
#: core/models.py:182
#: build/lib/core/models.py:213 core/models.py:213
msgid "staff status"
msgstr ""
#: core/models.py:184
#: build/lib/core/models.py:215 core/models.py:215
msgid "Whether the user can log into this admin site."
msgstr ""
#: core/models.py:187
#: build/lib/core/models.py:218 core/models.py:218
msgid "active"
msgstr ""
#: core/models.py:190
#: build/lib/core/models.py:221 core/models.py:221
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: core/models.py:202
#: build/lib/core/models.py:233 core/models.py:233
msgid "user"
msgstr ""
#: core/models.py:203
#: build/lib/core/models.py:234 core/models.py:234
msgid "users"
msgstr ""
#: core/models.py:342 core/models.py:718
#: build/lib/core/models.py:373 build/lib/core/models.py:925 core/models.py:373
#: core/models.py:925
msgid "title"
msgstr ""
#: core/models.py:364
#: build/lib/core/models.py:374 core/models.py:374
msgid "excerpt"
msgstr ""
#: build/lib/core/models.py:405 core/models.py:405
msgid "Document"
msgstr ""
#: core/models.py:365
#: build/lib/core/models.py:406 core/models.py:406
msgid "Documents"
msgstr ""
#: core/models.py:368
#: build/lib/core/models.py:418 core/models.py:418
msgid "Untitled Document"
msgstr ""
#: core/models.py:593
#: build/lib/core/models.py:719 core/models.py:719
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} a partagé un document avec vous!"
#: core/models.py:597
#: build/lib/core/models.py:723 core/models.py:723
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} vous a invité avec le rôle \"{role}\" sur le document suivant:"
#: core/models.py:600
#: build/lib/core/models.py:726 core/models.py:726
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} a partagé un document avec vous: {title}"
#: core/models.py:623
#: build/lib/core/models.py:762 core/models.py:762
msgid "This document is not deleted."
msgstr ""
#: build/lib/core/models.py:769 core/models.py:769
msgid "This document was permanently deleted and cannot be restored."
msgstr ""
#: build/lib/core/models.py:820 core/models.py:820
msgid "Document/user link trace"
msgstr ""
#: core/models.py:624
#: build/lib/core/models.py:821 core/models.py:821
msgid "Document/user link traces"
msgstr ""
#: core/models.py:630
#: build/lib/core/models.py:827 core/models.py:827
msgid "A link trace already exists for this document/user."
msgstr ""
#: core/models.py:653
#: build/lib/core/models.py:850 core/models.py:850
msgid "Document favorite"
msgstr ""
#: core/models.py:654
#: build/lib/core/models.py:851 core/models.py:851
msgid "Document favorites"
msgstr ""
#: core/models.py:660
#: build/lib/core/models.py:857 core/models.py:857
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: core/models.py:682
#: build/lib/core/models.py:879 core/models.py:879
msgid "Document/user relation"
msgstr ""
#: core/models.py:683
#: build/lib/core/models.py:880 core/models.py:880
msgid "Document/user relations"
msgstr ""
#: core/models.py:689
#: build/lib/core/models.py:886 core/models.py:886
msgid "This user is already in this document."
msgstr ""
#: core/models.py:695
#: build/lib/core/models.py:892 core/models.py:892
msgid "This team is already in this document."
msgstr ""
#: core/models.py:701 core/models.py:890
#: build/lib/core/models.py:898 build/lib/core/models.py:1012
#: core/models.py:898 core/models.py:1012
msgid "Either user or team must be set, not both."
msgstr ""
#: core/models.py:719
#: build/lib/core/models.py:926 core/models.py:926
msgid "description"
msgstr ""
#: core/models.py:720
#: build/lib/core/models.py:927 core/models.py:927
msgid "code"
msgstr ""
#: core/models.py:721
#: build/lib/core/models.py:928 core/models.py:928
msgid "css"
msgstr ""
#: core/models.py:723
#: build/lib/core/models.py:930 core/models.py:930
msgid "public"
msgstr ""
#: core/models.py:725
#: build/lib/core/models.py:932 core/models.py:932
msgid "Whether this template is public for anyone to use."
msgstr ""
#: core/models.py:731
#: build/lib/core/models.py:938 core/models.py:938
msgid "Template"
msgstr ""
#: core/models.py:732
#: build/lib/core/models.py:939 core/models.py:939
msgid "Templates"
msgstr ""
#: core/models.py:871
#: build/lib/core/models.py:993 core/models.py:993
msgid "Template/user relation"
msgstr ""
#: core/models.py:872
#: build/lib/core/models.py:994 core/models.py:994
msgid "Template/user relations"
msgstr ""
#: core/models.py:878
#: build/lib/core/models.py:1000 core/models.py:1000
msgid "This user is already in this template."
msgstr ""
#: core/models.py:884
#: build/lib/core/models.py:1006 core/models.py:1006
msgid "This team is already in this template."
msgstr ""
#: core/models.py:907
#: build/lib/core/models.py:1029 core/models.py:1029
msgid "email address"
msgstr ""
#: core/models.py:926
#: build/lib/core/models.py:1048 core/models.py:1048
msgid "Document invitation"
msgstr ""
#: core/models.py:927
#: build/lib/core/models.py:1049 core/models.py:1049
msgid "Document invitations"
msgstr ""
#: core/models.py:944
#: build/lib/core/models.py:1069 core/models.py:1069
msgid "This email is already associated to a registered user."
msgstr ""
#: core/templates/mail/html/hello.html:159 core/templates/mail/text/hello.txt:3
msgid "Company logo"
#: build/lib/impress/settings.py:236 impress/settings.py:236
msgid "English"
msgstr ""
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
#, python-format
msgid "Hello %(name)s"
#: build/lib/impress/settings.py:237 impress/settings.py:237
msgid "French"
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>"
#: build/lib/impress/settings.py:238 impress/settings.py:238
msgid "German"
msgstr ""
#: core/templates/mail/html/invitation.html:162
#: core/templates/mail/text/invitation.txt:3
msgid "Logo email"
msgstr ""
msgstr "Logo de l'e-mail"
#: core/templates/mail/html/invitation.html:209
#: core/templates/mail/text/invitation.txt:10
@@ -375,20 +412,3 @@ msgstr " Docs, votre nouvel outil incontournable pour organiser, partager et col
msgid " Brought to you by %(brandname)s "
msgstr " Proposé par %(brandname)s "
#: core/templates/mail/text/hello.txt:8
#, python-format
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
msgstr ""
#: impress/settings.py:236
msgid "English"
msgstr ""
#: impress/settings.py:237
msgid "French"
msgstr ""
#: impress/settings.py:238
msgid "German"
msgstr ""

View File

@@ -0,0 +1,414 @@
msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-02-06 15:30+0000\n"
"PO-Revision-Date: 2025-02-06 15:57\n"
"Last-Translator: \n"
"Language-Team: Dutch\n"
"Language: nl_NL\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-docs\n"
"X-Crowdin-Project-ID: 754523\n"
"X-Crowdin-Language: nl\n"
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:37 core/admin.py:37
msgid "Personal info"
msgstr ""
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
msgid "Permissions"
msgstr ""
#: build/lib/core/admin.py:62 core/admin.py:62
msgid "Important dates"
msgstr ""
#: build/lib/core/admin.py:148 core/admin.py:148
msgid "Tree structure"
msgstr ""
#: build/lib/core/api/filters.py:16 core/api/filters.py:16
msgid "Creator is me"
msgstr ""
#: build/lib/core/api/filters.py:19 core/api/filters.py:19
msgid "Favorite"
msgstr ""
#: build/lib/core/api/filters.py:22 core/api/filters.py:22
msgid "Title"
msgstr ""
#: build/lib/core/api/serializers.py:346 core/api/serializers.py:346
msgid "A new document was created on your behalf!"
msgstr ""
#: build/lib/core/api/serializers.py:350 core/api/serializers.py:350
msgid "You have been granted ownership of a new document:"
msgstr ""
#: build/lib/core/api/serializers.py:453 core/api/serializers.py:453
msgid "Body"
msgstr ""
#: build/lib/core/api/serializers.py:456 core/api/serializers.py:456
msgid "Body type"
msgstr ""
#: build/lib/core/api/serializers.py:462 core/api/serializers.py:462
msgid "Format"
msgstr ""
#: build/lib/core/authentication/backends.py:61
#: core/authentication/backends.py:61
msgid "Invalid response format or token verification failed"
msgstr ""
#: build/lib/core/authentication/backends.py:108
#: core/authentication/backends.py:108
msgid "User account is disabled"
msgstr ""
#: build/lib/core/enums.py:19 core/enums.py:19
msgid "First child"
msgstr ""
#: build/lib/core/enums.py:20 core/enums.py:20
msgid "Last child"
msgstr ""
#: build/lib/core/enums.py:21 core/enums.py:21
msgid "First sibling"
msgstr ""
#: build/lib/core/enums.py:22 core/enums.py:22
msgid "Last sibling"
msgstr ""
#: build/lib/core/enums.py:23 core/enums.py:23
msgid "Left"
msgstr ""
#: build/lib/core/enums.py:24 core/enums.py:24
msgid "Right"
msgstr ""
#: build/lib/core/models.py:54 build/lib/core/models.py:61 core/models.py:54
#: core/models.py:61
msgid "Reader"
msgstr ""
#: build/lib/core/models.py:55 build/lib/core/models.py:62 core/models.py:55
#: core/models.py:62
msgid "Editor"
msgstr ""
#: build/lib/core/models.py:63 core/models.py:63
msgid "Administrator"
msgstr ""
#: build/lib/core/models.py:64 core/models.py:64
msgid "Owner"
msgstr ""
#: build/lib/core/models.py:75 core/models.py:75
msgid "Restricted"
msgstr ""
#: build/lib/core/models.py:79 core/models.py:79
msgid "Authenticated"
msgstr ""
#: build/lib/core/models.py:81 core/models.py:81
msgid "Public"
msgstr ""
#: build/lib/core/models.py:103 core/models.py:103
msgid "id"
msgstr ""
#: build/lib/core/models.py:104 core/models.py:104
msgid "primary key for the record as UUID"
msgstr ""
#: build/lib/core/models.py:110 core/models.py:110
msgid "created on"
msgstr ""
#: build/lib/core/models.py:111 core/models.py:111
msgid "date and time at which a record was created"
msgstr ""
#: build/lib/core/models.py:116 core/models.py:116
msgid "updated on"
msgstr ""
#: build/lib/core/models.py:117 core/models.py:117
msgid "date and time at which a record was last updated"
msgstr ""
#: build/lib/core/models.py:153 core/models.py:153
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
msgstr ""
#: build/lib/core/models.py:166 core/models.py:166
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
msgstr ""
#: build/lib/core/models.py:172 core/models.py:172
msgid "sub"
msgstr ""
#: build/lib/core/models.py:174 core/models.py:174
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
msgstr ""
#: build/lib/core/models.py:183 core/models.py:183
msgid "full name"
msgstr ""
#: build/lib/core/models.py:184 core/models.py:184
msgid "short name"
msgstr ""
#: build/lib/core/models.py:186 core/models.py:186
msgid "identity email address"
msgstr ""
#: build/lib/core/models.py:191 core/models.py:191
msgid "admin email address"
msgstr ""
#: build/lib/core/models.py:198 core/models.py:198
msgid "language"
msgstr ""
#: build/lib/core/models.py:199 core/models.py:199
msgid "The language in which the user wants to see the interface."
msgstr ""
#: build/lib/core/models.py:205 core/models.py:205
msgid "The timezone in which the user wants to see times."
msgstr ""
#: build/lib/core/models.py:208 core/models.py:208
msgid "device"
msgstr ""
#: build/lib/core/models.py:210 core/models.py:210
msgid "Whether the user is a device or a real user."
msgstr ""
#: build/lib/core/models.py:213 core/models.py:213
msgid "staff status"
msgstr ""
#: build/lib/core/models.py:215 core/models.py:215
msgid "Whether the user can log into this admin site."
msgstr ""
#: build/lib/core/models.py:218 core/models.py:218
msgid "active"
msgstr ""
#: build/lib/core/models.py:221 core/models.py:221
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: build/lib/core/models.py:233 core/models.py:233
msgid "user"
msgstr ""
#: build/lib/core/models.py:234 core/models.py:234
msgid "users"
msgstr ""
#: build/lib/core/models.py:373 build/lib/core/models.py:925 core/models.py:373
#: core/models.py:925
msgid "title"
msgstr ""
#: build/lib/core/models.py:374 core/models.py:374
msgid "excerpt"
msgstr ""
#: build/lib/core/models.py:405 core/models.py:405
msgid "Document"
msgstr ""
#: build/lib/core/models.py:406 core/models.py:406
msgid "Documents"
msgstr ""
#: build/lib/core/models.py:418 core/models.py:418
msgid "Untitled Document"
msgstr ""
#: build/lib/core/models.py:719 core/models.py:719
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
#: build/lib/core/models.py:723 core/models.py:723
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr ""
#: build/lib/core/models.py:726 core/models.py:726
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr ""
#: build/lib/core/models.py:762 core/models.py:762
msgid "This document is not deleted."
msgstr ""
#: build/lib/core/models.py:769 core/models.py:769
msgid "This document was permanently deleted and cannot be restored."
msgstr ""
#: build/lib/core/models.py:820 core/models.py:820
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:821 core/models.py:821
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:827 core/models.py:827
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:850 core/models.py:850
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:851 core/models.py:851
msgid "Document favorites"
msgstr ""
#: build/lib/core/models.py:857 core/models.py:857
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:879 core/models.py:879
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:880 core/models.py:880
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:886 core/models.py:886
msgid "This user is already in this document."
msgstr ""
#: build/lib/core/models.py:892 core/models.py:892
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:898 build/lib/core/models.py:1012
#: core/models.py:898 core/models.py:1012
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:926 core/models.py:926
msgid "description"
msgstr ""
#: build/lib/core/models.py:927 core/models.py:927
msgid "code"
msgstr ""
#: build/lib/core/models.py:928 core/models.py:928
msgid "css"
msgstr ""
#: build/lib/core/models.py:930 core/models.py:930
msgid "public"
msgstr ""
#: build/lib/core/models.py:932 core/models.py:932
msgid "Whether this template is public for anyone to use."
msgstr ""
#: build/lib/core/models.py:938 core/models.py:938
msgid "Template"
msgstr ""
#: build/lib/core/models.py:939 core/models.py:939
msgid "Templates"
msgstr ""
#: build/lib/core/models.py:993 core/models.py:993
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:994 core/models.py:994
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1000 core/models.py:1000
msgid "This user is already in this template."
msgstr ""
#: build/lib/core/models.py:1006 core/models.py:1006
msgid "This team is already in this template."
msgstr ""
#: build/lib/core/models.py:1029 core/models.py:1029
msgid "email address"
msgstr ""
#: build/lib/core/models.py:1048 core/models.py:1048
msgid "Document invitation"
msgstr ""
#: build/lib/core/models.py:1049 core/models.py:1049
msgid "Document invitations"
msgstr ""
#: build/lib/core/models.py:1069 core/models.py:1069
msgid "This email is already associated to a registered user."
msgstr ""
#: build/lib/impress/settings.py:236 impress/settings.py:236
msgid "English"
msgstr ""
#: build/lib/impress/settings.py:237 impress/settings.py:237
msgid "French"
msgstr ""
#: build/lib/impress/settings.py:238 impress/settings.py:238
msgid "German"
msgstr ""
#: core/templates/mail/html/invitation.html:162
#: core/templates/mail/text/invitation.txt:3
msgid "Logo email"
msgstr ""
#: core/templates/mail/html/invitation.html:209
#: core/templates/mail/text/invitation.txt:10
msgid "Open"
msgstr ""
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr ""
#: core/templates/mail/html/invitation.html:233
#: core/templates/mail/text/invitation.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
msgstr ""

View File

@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "impress"
version = "1.10.0"
version = "2.1.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -25,7 +25,7 @@ license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"boto3==1.35.90",
"boto3==1.36.7",
"Brotli==1.1.0",
"celery[redis]==5.4.0",
"django-configurations==2.5.1",
@@ -37,7 +37,8 @@ dependencies = [
"django-redis==5.4.0",
"django-storages[s3]==1.14.4",
"django-timezone-field>=5.1",
"django==5.1.4",
"django==5.1.5",
"django-treebeard==4.7.1",
"djangorestframework==3.15.2",
"drf_spectacular==0.28.0",
"dockerflow==2024.4.2",
@@ -47,16 +48,13 @@ dependencies = [
"jsonschema==4.23.0",
"markdown==3.7",
"nested-multipart-parser==1.5.0",
"openai==1.58.1",
"psycopg[binary]==3.2.3",
"openai==1.60.2",
"psycopg[binary]==3.2.4",
"PyJWT==2.10.1",
"pypandoc==1.14",
"python-frontmatter==1.1.0",
"python-magic==0.4.27",
"requests==2.32.3",
"sentry-sdk==2.19.2",
"sentry-sdk==2.20.0",
"url-normalize==1.4.3",
"WeasyPrint>=60.2",
"whitenoise==6.8.2",
"mozilla-django-oidc==4.0.1",
]
@@ -74,16 +72,16 @@ dev = [
"freezegun==1.5.1",
"ipdb==0.13.13",
"ipython==8.31.0",
"pyfakefs==5.7.3",
"pyfakefs==5.7.4",
"pylint-django==2.6.1",
"pylint==3.3.3",
"pylint==3.3.4",
"pytest-cov==6.0.0",
"pytest-django==4.9.0",
"pytest==8.3.4",
"pytest-icdiff==0.9",
"pytest-xdist==3.6.1",
"responses==0.25.3",
"ruff==0.8.4",
"responses==0.25.6",
"ruff==0.9.3",
"types-requests==2.32.0.20241016",
]

View File

@@ -1,6 +1,17 @@
import { Page, expect } from '@playwright/test';
export const keyCloakSignIn = async (page: Page, browserName: string) => {
export const keyCloakSignIn = async (
page: Page,
browserName: string,
fromHome: boolean = true,
) => {
if (fromHome) {
await page
.getByRole('button', { name: 'Proconnect Login' })
.first()
.click();
}
const login = `user-e2e-${browserName}`;
const password = `password-e2e-${browserName}`;
@@ -26,7 +37,7 @@ export const createDoc = async (
page: Page,
docName: string,
browserName: string,
length: number,
length: number = 1,
) => {
const randomDocs = randomName(docName, browserName, length);
@@ -40,7 +51,8 @@ export const createDoc = async (
})
.click();
const input = page.getByRole('textbox', { name: 'doc title input' });
const input = page.getByLabel('doc title input');
await expect(input).toHaveText('');
await input.click();
await input.fill(randomDocs[i]);
await input.blur();
@@ -91,6 +103,22 @@ export const addNewMember = async (
return users[index].email;
};
export const getGridRow = async (page: Page, title: string) => {
const docsGrid = page.getByRole('grid');
await expect(docsGrid).toBeVisible();
await expect(page.getByTestId('grid-loader')).toBeHidden();
const rows = docsGrid.getByRole('row');
const row = rows.filter({
hasText: title,
});
await expect(row).toBeVisible();
return row;
};
interface GoToGridDocOptions {
nthRow?: number;
title?: string;
@@ -104,7 +132,7 @@ export const goToGridDoc = async (
const docsGrid = page.getByTestId('docs-grid');
await expect(docsGrid).toBeVisible();
await expect(docsGrid.getByTestId('grid-loader')).toBeHidden();
await expect(page.getByTestId('grid-loader')).toBeHidden();
const rows = docsGrid.getByRole('row');
@@ -241,3 +269,8 @@ export const mockedAccesses = async (page: Page, json?: object) => {
}
});
};
export const expectLoginPage = async (page: Page) =>
await expect(
page.getByRole('heading', { name: 'Collaborative writing' }),
).toBeVisible();

View File

@@ -16,6 +16,7 @@ const config = {
['de-de', 'German'],
],
LANGUAGE_CODE: 'en-us',
POSTHOG_KEY: {},
SENTRY_DSN: null,
};
@@ -62,27 +63,6 @@ test.describe('Config', () => {
expect((await consoleMessage).text()).toContain(invalidMsg);
});
test('it checks that theme is configured from config endpoint', async ({
page,
}) => {
const responsePromise = page.waitForResponse(
(response) =>
response.url().includes('/config/') && response.status() === 200,
);
await page.goto('/');
const response = await responsePromise;
expect(response.ok()).toBeTruthy();
const jsonResponse = await response.json();
expect(jsonResponse.FRONTEND_THEME).toStrictEqual('dsfr');
const footer = page.locator('footer').first();
// alt 'Gouvernement Logo' comes from the theme
await expect(footer.getByAltText('Gouvernement Logo')).toBeVisible();
});
test('it checks that media server is configured from config endpoint', async ({
page,
browserName,
@@ -160,3 +140,28 @@ test.describe('Config', () => {
).toBeVisible();
});
});
test.describe('Config: Not loggued', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('it checks that theme is configured from config endpoint', async ({
page,
}) => {
const responsePromise = page.waitForResponse(
(response) =>
response.url().includes('/config/') && response.status() === 200,
);
await page.goto('/');
const response = await responsePromise;
expect(response.ok()).toBeTruthy();
const jsonResponse = await response.json();
expect(jsonResponse.FRONTEND_THEME).toStrictEqual('dsfr');
const footer = page.locator('footer').first();
// alt 'Gouvernement Logo' comes from the theme
await expect(footer.getByAltText('Gouvernement Logo')).toBeVisible();
});
});

View File

@@ -24,8 +24,6 @@ test.describe('Doc Create', () => {
const header = page.locator('header').first();
await header.locator('h2').getByText('Docs').click();
await expect(page.getByTestId('grid-loader')).toBeVisible();
const docsGrid = page.getByTestId('docs-grid');
await expect(docsGrid).toBeVisible();
await expect(page.getByTestId('grid-loader')).toBeHidden();

View File

@@ -123,7 +123,7 @@ test.describe('Doc Editor', () => {
const selectVisibility = page.getByLabel('Visibility', { exact: true });
// When the visibility is changed, the ws should close the connection (backend signal)
// When the visibility is changed, the ws should closed the connection (backend signal)
const wsClosePromise = webSocket.waitForEvent('close');
await selectVisibility.click();
@@ -220,7 +220,7 @@ test.describe('Doc Editor', () => {
browserName,
}) => {
// Check the first doc
const [doc] = await createDoc(page, 'doc-saves-change', browserName, 1);
const [doc] = await createDoc(page, 'doc-saves-change', browserName);
await verifyDocName(page, doc);
const editor = page.locator('.ProseMirror');
@@ -228,9 +228,11 @@ test.describe('Doc Editor', () => {
await editor.fill('Hello World Doc persisted 1');
await expect(editor.getByText('Hello World Doc persisted 1')).toBeVisible();
const secondDoc = await goToGridDoc(page, {
nthRow: 2,
});
const [secondDoc] = await createDoc(
page,
'doc-saves-change-other',
browserName,
);
await verifyDocName(page, secondDoc);
@@ -238,6 +240,7 @@ test.describe('Doc Editor', () => {
title: doc,
});
await verifyDocName(page, doc);
await expect(editor.getByText('Hello World Doc persisted 1')).toBeVisible();
});
@@ -246,8 +249,7 @@ test.describe('Doc Editor', () => {
test.skip(browserName === 'webkit', 'This test is very flaky with webkit');
// Check the first doc
const doc = await goToGridDoc(page);
const [doc] = await createDoc(page, 'doc-quit-1', browserName, 1);
await verifyDocName(page, doc);
const editor = page.locator('.ProseMirror');

View File

@@ -1,6 +1,7 @@
import path from 'path';
import { expect, test } from '@playwright/test';
import cs from 'convert-stream';
import jsdom from 'jsdom';
import pdf from 'pdf-parse';
import { createDoc, verifyDocName } from './common';
@@ -28,9 +29,7 @@ test.describe('Doc Export', () => {
.first(),
).toBeVisible();
await expect(
page.getByText(
'Upload your docs to a Microsoft Word, Open Office or PDF document',
),
page.getByText('Download your document in a .docx or .pdf format.'),
).toBeVisible();
await expect(
page.getByRole('combobox', { name: 'Template' }),
@@ -41,10 +40,8 @@ test.describe('Doc Export', () => {
).toBeVisible();
await expect(page.getByRole('button', { name: 'Download' })).toBeVisible();
});
test('it converts the doc to pdf with a template integrated', async ({
page,
browserName,
}) => {
test('it exports the doc to pdf', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-editor', browserName, 1);
const downloadPromise = page.waitForEvent('download', (download) => {
@@ -77,10 +74,7 @@ test.describe('Doc Export', () => {
expect(pdfText).toContain('Hello World'); // This is the doc text
});
test('it converts the doc to docx with a template integrated', async ({
page,
browserName,
}) => {
test('it exports the doc to docx', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-editor', browserName, 1);
const downloadPromise = page.waitForEvent('download', (download) => {
@@ -99,7 +93,7 @@ test.describe('Doc Export', () => {
.click();
await page.getByRole('combobox', { name: 'Format' }).click();
await page.getByRole('option', { name: 'Word / Open Office' }).click();
await page.getByRole('option', { name: 'Docx' }).click();
await page
.getByRole('button', {
@@ -111,152 +105,75 @@ test.describe('Doc Export', () => {
expect(download.suggestedFilename()).toBe(`${randomDoc}.docx`);
});
test('it converts the blocknote json in correct html for the export', async ({
page,
browserName,
}) => {
test.setTimeout(60000);
/**
* This test tell us that the export to pdf is working with images
* but it does not tell us if the images are beeing displayed correctly
* in the pdf.
*
* TODO: Check if the images are displayed correctly in the pdf
*/
test('it exports the docs with images', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-editor', browserName, 1);
let body = '';
await page.route('**/templates/*/generate-document/', async (route) => {
const request = route.request();
body = request.postDataJSON().body;
await route.continue();
const fileChooserPromise = page.waitForEvent('filechooser');
const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`${randomDoc}.pdf`);
});
await verifyDocName(page, randomDoc);
await page.locator('.bn-block-outer').last().fill('Hello World');
await page.locator('.bn-block-outer').last().click();
await page.keyboard.press('Enter');
await page.keyboard.press('Enter');
await page.locator('.bn-block-outer').last().fill('Break');
await expect(page.getByText('Break')).toBeVisible();
await page.locator('.ProseMirror.bn-editor').click();
await page.locator('.ProseMirror.bn-editor').fill('Hello World');
// Center the text
await page.getByText('Break').dblclick();
await page.locator('button[data-test="alignTextCenter"]').click();
// Change the background color
await page.locator('button[data-test="colors"]').click();
await page.locator('button[data-test="background-color-brown"]').click();
// Change the text color
await page.getByText('Break').dblclick();
await page.locator('button[data-test="colors"]').click();
await page.locator('button[data-test="text-color-orange"]').click();
// Add a list
await page.locator('.bn-block-outer').last().click();
await page.keyboard.press('Enter');
await page.locator('.bn-block-outer').last().fill('/');
await page.getByText('Bullet List').click();
await page
.locator('.bn-block-content[data-content-type="bulletListItem"] p')
.last()
.fill('Test List 1');
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(300);
await page.keyboard.press('Enter');
await page
.locator('.bn-block-content[data-content-type="bulletListItem"] p')
.last()
.fill('Test List 2');
await page.keyboard.press('Enter');
await page
.locator('.bn-block-content[data-content-type="bulletListItem"] p')
.last()
.fill('Test List 3');
await page.getByText('Resizable image with caption').click();
await page.getByText('Upload image').click();
await page.keyboard.press('Enter');
await page.keyboard.press('Backspace');
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(
path.join(__dirname, 'assets/logo-suite-numerique.png'),
);
// Add a number list
await page.locator('.bn-block-outer').last().click();
await page.keyboard.press('Enter');
await page.locator('.bn-block-outer').last().fill('/');
await page.getByText('Numbered List').click();
await page
.locator('.bn-block-content[data-content-type="numberedListItem"] p')
.last()
.fill('Test Number 1');
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(300);
await page.keyboard.press('Enter');
await page
.locator('.bn-block-content[data-content-type="numberedListItem"] p')
.last()
.fill('Test Number 2');
await page.keyboard.press('Enter');
await page
.locator('.bn-block-content[data-content-type="numberedListItem"] p')
.last()
.fill('Test Number 3');
const image = page.getByRole('img', { name: 'logo-suite-numerique.png' });
// Add img
await page.locator('.bn-block-outer').last().click();
await page.keyboard.press('Enter');
await page.locator('.bn-block-outer').last().fill('/');
await page
.getByRole('option', {
name: 'Image',
})
.click();
await page
.getByRole('tab', {
name: 'Embed',
})
.click();
await page
.getByPlaceholder('Enter URL')
.fill('https://example.com/image.jpg');
await page
.getByRole('button', {
name: 'Embed image',
})
.click();
await expect(image).toBeVisible();
// Download
await page
.getByRole('button', {
name: 'download',
})
.click();
await page
.getByRole('combobox', {
name: 'Template',
})
.click();
await page
.getByRole('option', {
name: 'Demo Template',
})
.click({
delay: 100,
});
await new Promise((resolve) => setTimeout(resolve, 1000));
await page
.getByRole('button', {
name: 'Download',
})
.click();
// Empty paragraph should be replaced by a <br/>
expect(body.match(/<br>/g)?.length).toBeGreaterThanOrEqual(2);
expect(body).toContain('style="color: orange;"');
expect(body).toContain('custom-style="center"');
expect(body).toContain('style="background-color: brown;"');
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
const { JSDOM } = jsdom;
const DOMParser = new JSDOM().window.DOMParser;
const parser = new DOMParser();
const html = parser.parseFromString(body, 'text/html');
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
const pdfExport = await pdf(pdfBuffer);
const pdfText = pdfExport.text;
const ulLis = html.querySelectorAll('ul li');
expect(ulLis.length).toBe(3);
expect(ulLis[0].textContent).toBe('Test List 1');
expect(ulLis[1].textContent).toBe('Test List 2');
expect(ulLis[2].textContent).toBe('Test List 3');
const olLis = html.querySelectorAll('ol li');
expect(olLis.length).toBe(3);
expect(olLis[0].textContent).toBe('Test Number 1');
expect(olLis[1].textContent).toBe('Test Number 2');
expect(olLis[2].textContent).toBe('Test Number 3');
const img = html.querySelectorAll('img');
expect(img.length).toBe(1);
expect(img[0].src).toBe('https://example.com/image.jpg');
expect(pdfText).toContain('Hello World');
});
});

View File

@@ -1,75 +0,0 @@
import { expect, test } from '@playwright/test';
import { createDoc, verifyDocName } from './common';
type SmallDoc = {
id: string;
title: string;
};
test.describe('Document favorite', () => {
test('it check the favorite workflow', async ({ page, browserName }) => {
const id = Math.random().toString(7);
await page.goto('/');
// Create document
const createdDoc = await createDoc(page, `Doc ${id}`, browserName, 1);
await verifyDocName(page, createdDoc[0]);
// Reload page
await page.reload();
await page.goto('/');
// Get all documents
let docs: SmallDoc[] = [];
const response = await page.waitForResponse(
(response) =>
response.url().endsWith('documents/?page=1') &&
response.status() === 200,
);
const result = await response.json();
docs = result.results as SmallDoc[];
const docsGrid = page.getByTestId('docs-grid');
await docsGrid.getByRole('heading', { name: 'All docs' }).click();
await expect(docsGrid.getByText(`Doc ${id}`)).toBeVisible();
const doc = docs.find((doc) => doc.title === createdDoc[0]) as SmallDoc;
// Check document
expect(doc).not.toBeUndefined();
expect(doc?.title).toBe(createdDoc[0]);
// Open document actions
const button = docsGrid.getByTestId(`docs-grid-actions-button-${doc.id}`);
await expect(button).toBeVisible();
await button.click();
// Pin document
const pinButton = page.getByTestId(`docs-grid-actions-pin-${docs[0].id}`);
await expect(pinButton).toBeVisible();
await pinButton.click();
// Check response
const responsePin = await page.waitForResponse(
(response) =>
response.url().includes(`documents/${doc.id}/favorite/`) &&
response.status() === 201,
);
expect(responsePin.ok()).toBeTruthy();
// Check left panel favorites
const leftPanelFavorites = page.getByTestId('left-panel-favorites');
await expect(leftPanelFavorites).toBeVisible();
await expect(leftPanelFavorites.getByText(`Doc ${id}`)).toBeVisible();
//
await button.click();
const unpinButton = page.getByTestId(
`docs-grid-actions-unpin-${docs[0].id}`,
);
await expect(unpinButton).toBeVisible();
await unpinButton.click();
// Check left panel favorites
await expect(leftPanelFavorites.getByText(`Doc ${id}`)).toBeHidden();
});
});

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