Compare commits

..

98 Commits

Author SHA1 Message Date
Anthony LC 58eaea000c 🔖(patch) release 1.2.1
Changed:
- ♻️ Change ordering docs datagrid
- 🔥(helm) use scaleway email
2024-08-23 16:27:52 +02:00
Anthony LC 7d97a037f6 🧑‍💻(makefile) bump-packages-version
Create the command bump-packages-version in Makefile.
This command will bump the version of
all the javascript packages in the project.
2024-08-23 16:27:52 +02:00
Anthony LC c830b4dae6 ♻️(email) replace base64 image with a link
The emails were too big, gmail by example was not
able to display them correctly.
It was caused by base64 image, so they are
replaced with a link to the image.

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

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

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

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

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

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

Changed:
- ♻️(frontend) replace docs panel with docs grid
- ♻️(frontend) create a doc from a modal
- ♻️(frontend) manage members from the share modal
2024-07-15 18:13:28 +02:00
renovate[bot] ccadd9567a ⬆️(dependencies) update django to v5.0.7 [SECURITY] 2024-07-15 17:24:18 +02:00
Anthony LC 2c9758eb30 🌐(frontend) translate last features
Translate:
- doc datagrid
- share feature
- remove translations not used anymore
2024-07-15 17:01:09 +02:00
Anthony LC 0f71a3fcfa 💄(frontend) force light theme on BlockNote editor
The BlockNote editor theme was not being forced to
light mode. Depend the user theme preference,
the blocknote theme could be dark.
We force it to light mode.
2024-07-15 10:58:44 +02:00
Anthony LC 839b78a6d0 📱(frontend) improve responsive share modal
Improved the responsiveness of the share modal
on small devices.
2024-07-15 10:58:44 +02:00
Anthony LC 69f2641159 ♻️(frontend) list members from side modal
We refactorize the members grid to display
it inside the share side modal.
It is not a member grid anymore but a member list
with infinite scroll. We can directly update the
role or delete a member from the each row of the
list.
2024-07-12 15:41:32 +02:00
Anthony LC e5de5a4345 💄(frontend) create IconBG component
Create IconBG component, a premade Icon component
with a background color.
2024-07-12 15:41:32 +02:00
Anthony LC c2d6e60ae8 ♻️(frontend) add user from side modal
We move the add user functionality to a side modal.
The side modal is opened from the share button.
2024-07-12 15:41:32 +02:00
Anthony LC ff832239d3 (frontend) add doc editor panel header
Add doc editor panel header to show
the doc title and other info.
2024-07-10 14:11:27 +02:00
Anthony LC a60399883b 🏷️(frontend) improve props currentDocRole
props of currentDocRole is now more accurate.
2024-07-10 14:11:27 +02:00
Anthony LC 12d32fe933 🛂(frontend) don't show action buttons
Don't show actions buttons if the user
doesn't have permission to edit or delete
the document.
2024-07-10 14:11:27 +02:00
Anthony LC 038d868d29 (frontend) create hook useDate
Create hook useDate, it is an date helper.
We use it for now to format date.
2024-07-10 14:11:27 +02:00
Anthony LC bfde526361 🐛(i18n) fix key that contain colon
Keys that contains colon where not being
translated correctly. This was due to the
colon being used as a separator for the
key and the value. This was fixed by
replacing the colon with a different
character that is not used in the key
or the value.
2024-07-10 14:11:27 +02:00
Anthony LC 132da0837f 🐛(e2e) sync doc grid sorting check
The sorting check was not the same between
what django provide and what the e2e test
check, it was giving some flakiness.
Django seems to ignore the punctuation (space)
in its sorting.
We improve other test to be more robust
as well.
2024-07-09 14:14:27 +02:00
Anthony LC 164ed885ca 🎨(frontend) improve interface SideModal
Improve the interface of the SideModal
component.
Set the width and the side by default.
2024-07-09 14:14:27 +02:00
Anthony LC f5a35c66bb (frontend) add doc grid actions button
Add document action buttons to the document grid:
- update document
- delete document
2024-07-09 14:14:27 +02:00
Anthony LC 8100422776 📝(github-actions) rename workflows
Change name workflows.
2024-07-09 14:14:27 +02:00
Anthony LC f48e385d3d 🛂(docker) add user minio
Add a user on Minio container in docker-compose.
2024-07-09 14:14:27 +02:00
Anthony LC b5895bf297 ⬆️(frontend) fix blocknote conflicts
Different versions of blocknote
were installed, we pint the same version
in all packages.json.
2024-07-08 17:10:54 +02:00
Anthony LC 0fb0c71cfa ⬆️(mail) bump mail dependencies
Lot of dependencies were outdated, so
we updated them.
2024-07-08 17:10:54 +02:00
Anthony LC a0fea64630 (frontend) create side modal component
Create SideModal component for
displaying modal on the side of the screen.
This component is build above the Cunningham
component, so all the cunnighama props are
still available.
2024-07-08 16:52:22 +02:00
Anthony LC de922e1c04 ♻️(frontend) create a doc from a modal
We refacto the create doc feature to use a modal
instead of a page and a card component.
It is more consistent with the other features.
2024-07-08 15:04:35 +02:00
Anthony LC 8007c45a35 👔(service-worker) add new field to create doc
We now display the creation and modification date
of the document in the document grid, so when we
create a new document in offline mode we need to
set the dates as well.
2024-07-08 13:41:08 +02:00
Anthony LC 3d370e5714 💄(frontend) darken the background as the mockup
Darken the background as the mockup to make the
elements more visible.
2024-07-05 19:02:01 +02:00
Anthony LC 03953f0fe7 ️(frontend) use resetQueries instead of invalidateQueries
With the list of documents, invalidateQueries doesn't
refresh as expected the list of documents. We
will prefer resetQueries, it seems to be more
appropriate for this case.
2024-07-05 19:02:01 +02:00
Anthony LC 67f4ddeef7 (e2e) adapt e2e test without the panel
The datagrid replace the panel. Lot of tests
need to be adapted to this new architecture.
2024-07-05 19:02:01 +02:00
Anthony LC 6f4b4bb7d3 🔥(frontend) remove docs-panel feature
Remove docs-panel feature, we will replace it
with a datagrid.
2024-07-05 19:02:01 +02:00
Anthony LC c27e6c1b06 (frontend) create docs-grid feature
Create docs-grid feature. It will be used to display
the list of documents in a grid view.
Grid view are more useful to display lot of
information, we can easily sort the information.
2024-07-05 19:02:01 +02:00
Anthony LC d2888d0b9d (frontend) create hook useTransRole
Create the hook useTransRole to get a translated
role.
2024-07-05 19:02:01 +02:00
Anthony LC 6b8af1f9ec ♻️(backend) add more doc sorting
Update the viewset to be able to sort by:
- created date
- updated date
- title
2024-07-05 19:02:01 +02:00
Anthony LC 35852dff0b ♻️(backend) add more info to doc
Update the serializer to include more info
about the doc:
- created date
- updated date
2024-07-05 19:02:01 +02:00
Anthony LC be93598b2d 🌱(demo) create dev users and make them doc accesses
To be able to test with dummy data, we need to create
our dev users from the demo and to give them access to
the docs.
The sub is the unicity of the user for our oidc provider,
so we need to know the sub to be able to create
correctly the user, it is why we set the sub
as the email of the user in the realm.json file.
2024-07-05 19:02:01 +02:00
Anthony LC 7d3fd25c61 🤡(demo) demo generate dummy documents
The demo command will generate dummy documents
and dummy accesses.
2024-07-05 19:02:01 +02:00
253 changed files with 10556 additions and 7968 deletions
+80 -6
View File
@@ -1,4 +1,4 @@
name: impress Workflow
name: Frontend Workflow
on:
push:
@@ -96,10 +96,10 @@ jobs:
- name: Check linting
run: cd src/frontend/ && yarn lint
test-e2e:
test-e2e-chromium:
runs-on: ubuntu-latest
needs: build-front
timeout-minutes: 15
timeout-minutes: 20
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -158,14 +158,88 @@ jobs:
make demo FLUSH_ARGS='--no-input'
- name: Install Playwright Browsers
run: cd src/frontend/apps/e2e && yarn install
run: cd src/frontend/apps/e2e && yarn install-playwright chromium
- name: Run e2e tests
run: cd src/frontend/ && yarn e2e:test
run: cd src/frontend/ && yarn e2e:test --project='chromium'
- uses: actions/upload-artifact@v3
if: always()
with:
name: playwright-report
name: playwright-chromium-report
path: src/frontend/apps/e2e/report/
retention-days: 7
test-e2e-other-browser:
runs-on: ubuntu-latest
needs: build-front
timeout-minutes: 20
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set services env variables
run: |
make data/media
make create-env-files
cat env.d/development/common.e2e.dist >> env.d/development/common
- name: Restore the mail templates
uses: actions/cache@v4
id: mail-templates
with:
path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
- name: Restore the frontend cache
uses: actions/cache@v4
id: front-node_modules
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
- name: Restore the build cache
uses: actions/cache@v4
id: cache-build
with:
path: src/frontend/apps/impress/out/
key: build-front-${{ github.run_id }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build the Docker images
uses: docker/bake-action@v4
with:
targets: |
app-dev
y-webrtc-signaling
load: true
set: |
*.cache-from=type=gha,scope=cached-stage
*.cache-to=type=gha,scope=cached-stage,mode=max
- name: Start Docker services
run: |
make run
- name: Apply DRF migrations
run: |
make migrate
- name: Add dummy data
run: |
make demo FLUSH_ARGS='--no-input'
- name: Install Playwright Browsers
run: cd src/frontend/apps/e2e && yarn install-playwright firefox webkit chromium
- name: Run e2e tests
run: cd src/frontend/ && yarn e2e:test --project=firefox --project=webkit
- uses: actions/upload-artifact@v3
if: always()
with:
name: playwright-other-report
path: src/frontend/apps/e2e/report/
retention-days: 7
+2 -67
View File
@@ -1,4 +1,4 @@
name: impress Workflow
name: Main Workflow
on:
push:
@@ -198,75 +198,10 @@ jobs:
- name: Install gettext (required to compile messages)
run: |
sudo apt-get update
sudo apt-get install -y gettext
sudo apt-get install -y gettext pandoc
- name: Generate a MO file from strings extracted from the project
run: python manage.py compilemessages
- name: Run tests
run: ~/.local/bin/pytest -n 2
i18n-crowdin:
runs-on: ubuntu-latest
steps:
-
uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "infrastructure,secrets"
-
name: Checkout repository
uses: actions/checkout@v2
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
-
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/impress/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
- name: Install gettext (required to make messages)
run: |
sudo apt-get update
sudo apt-get install -y gettext
- name: Install Python
uses: actions/setup-python@v3
with:
python-version: "3.10"
- name: Install development dependencies
working-directory: src/backend
run: pip install --user .[dev]
- name: Generate the translation base file
run: ~/.local/bin/django-admin makemessages --keep-pot --all
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "18.x"
cache: "yarn"
cache-dependency-path: src/frontend/yarn.lock
- name: Install dependencies
run: cd src/frontend/ && yarn install --frozen-lockfile
- name: Extract the frontend translation
run: make frontend-i18n-extract
- name: Upload files to Crowdin
run: |
docker run \
--rm \
-e CROWDIN_API_TOKEN=$CROWDIN_API_TOKEN \
-e CROWDIN_PROJECT_ID=$CROWDIN_PROJECT_ID \
-e CROWDIN_BASE_PATH=$CROWDIN_BASE_PATH \
-v "${{ github.workspace }}:/app" \
crowdin/cli:3.16.0 \
crowdin upload sources -c /app/crowdin/config.yml
-4
View File
@@ -33,7 +33,6 @@ MANIFEST
*.pot
# Environments
.env
.venv
env/
venv/
@@ -50,9 +49,6 @@ node_modules
# Mails
src/backend/core/templates/mail/
# Typescript client
src/frontend/tsclient
# Swagger
**/swagger.json
+1
View File
@@ -6,4 +6,5 @@ creation_rules:
- age16hnlml8yv4ynwy0seer57g8qww075crd0g7nsundz3pj4wk7m3vqftszg7 # github-repo
- age1plkp8td6zzfcavjusmsfrlk54t9vn8jjxm8zaz7cmnr7kzl2nfnsd54hwg # Anthony Le-Courric
- age12g6f5fse25tgrwweleh4jls3qs52hey2edh759smulwmk5lnzadslu2cp3 # Antoine Lebaud
- age1hnhuzj96ktkhpyygvmz0x9h8mfvssz7ss6emmukags644mdhf4msajk93r # Samuel Paccoud
+56 -1
View File
@@ -8,6 +8,57 @@ and this project adheres to
## [Unreleased]
## [1.2.1] - 2024-08-23
## Changed
- ♻️ Change ordering docs datagrid #195
- 🔥(helm) use scaleway email #194
## [1.2.0] - 2024-08-22
## Added
- 🎨(frontend) better conversion editor to pdf #151
- ✨Export docx (word) #161
- 🌐Internationalize invitation email #167
- ✨(frontend) White branding #164
- ✨Email invitation when add user to doc #171
- ✨Invitation management #174
## Fixed
- 🐛(y-webrtc) fix prob connection #147
- ⚡️(frontend) improve select share stability #159
- 🐛(backend) enable SSL when sending email #165
## Changed
- 🎨(frontend) stop limit layout height to screen size #158
- ⚡️(CI) only e2e chrome mandatory #177
## Removed
- 🔥(helm) remove htaccess #181
## [1.1.0] - 2024-07-15
## Added
- 🤡(demo) generate dummy documents on dev users #120
- ✨(frontend) create side modal component #134
- ✨(frontend) Doc grid actions (update / delete) #136
- ✨(frontend) Doc editor header information #137
## Changed
- ♻️(frontend) replace docs panel with docs grid #120
- ♻️(frontend) create a doc from a modal #132
- ♻️(frontend) manage members from the share modal #140
## [1.0.0] - 2024-07-02
## Added
@@ -46,6 +97,7 @@ and this project adheres to
- 💚(CI) Remove trigger workflow on push tags on CI (#68)
- 🔥(frontend) Remove coming soon page (#121)
## [0.1.0] - 2024-05-24
## Added
@@ -54,6 +106,9 @@ and this project adheres to
- 🚀 Impress, project to manage your documents easily and collaboratively.
[unreleased]: https://github.com/numerique-gouv/impress/compare/v1.0.0...main
[unreleased]: https://github.com/numerique-gouv/impress/compare/v1.2.1...main
[1.2.1]: https://github.com/numerique-gouv/impress/releases/v1.2.1
[1.2.0]: https://github.com/numerique-gouv/impress/releases/v1.2.0
[1.1.0]: https://github.com/numerique-gouv/impress/releases/v1.1.0
[1.0.0]: https://github.com/numerique-gouv/impress/releases/v1.0.0
[0.1.0]: https://github.com/numerique-gouv/impress/releases/v0.1.0
+1
View File
@@ -75,6 +75,7 @@ RUN apt-get update && \
libgdk-pixbuf2.0-0 \
libpango-1.0-0 \
libpangocairo-1.0-0 \
pandoc \
shared-mime-info && \
rm -rf /var/lib/apt/lists/*
+11 -12
View File
@@ -49,7 +49,6 @@ WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT
# -- Backend
MANAGE = $(COMPOSE_RUN_APP) python manage.py
MAIL_YARN = $(COMPOSE_RUN) -w /app/src/mail node yarn
TSCLIENT_YARN = $(COMPOSE_RUN) -w /app/src/tsclient node yarn
# -- Frontend
PATH_FRONT = ./src/frontend
@@ -186,7 +185,7 @@ back-i18n-compile: ## compile the gettext files
.PHONY: back-i18n-compile
back-i18n-generate: ## create the .pot files used for i18n
@$(MANAGE) makemessages -a --keep-pot
@$(MANAGE) makemessages -a --keep-pot --all
.PHONY: back-i18n-generate
shell: ## connect to database shell
@@ -275,16 +274,6 @@ mails-install: ## install the mail generator
@$(MAIL_YARN) install
.PHONY: mails-install
# -- TS client generator
tsclient-install: ## Install the Typescript API client generator
@$(TSCLIENT_YARN) install
.PHONY: tsclient-install
tsclient: tsclient-install ## Generate a Typescript API client
@$(TSCLIENT_YARN) generate:api:client:local ../frontend/tsclient
.PHONY: tsclient-install
# -- Misc
clean: ## restore repository state as it was freshly cloned
git clean -idx
@@ -324,3 +313,13 @@ start-tilt: ## start the kubernetes cluster using kind
tilt up -f ./bin/Tiltfile
.PHONY: build-k8s-cluster
VERSION_TYPE ?= minor
bump-packages-version: ## bump the version of the project - VERSION_TYPE can be "major", "minor", "patch"
cd ./src/mail && yarn version --no-git-tag-version --$(VERSION_TYPE)
cd ./src/frontend/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
cd ./src/frontend/apps/e2e/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
cd ./src/frontend/apps/impress/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
cd ./src/frontend/apps/y-webrtc-signaling/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
cd ./src/frontend/packages/eslint-config-impress/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
cd ./src/frontend/packages/i18n/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
.PHONY: bump-packages-version
View File
View File
+15
View File
@@ -1339,6 +1339,21 @@
"jsonType.label": "String"
}
},
{
"id": "qb109597-e31e-46d7-7844-62e5fcf32ac8",
"name": "email sub",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-property-mapper",
"consentRequired": false,
"config": {
"userinfo.token.claim": "true",
"user.attribute": "email",
"id.token.claim": "true",
"access.token.claim": "true",
"claim.name": "sub",
"jsonType.label": "String"
}
},
{
"id": "61c135e5-2447-494b-bc70-9612f383be27",
"name": "email verified",
+72
View File
@@ -0,0 +1,72 @@
# Releasing a new version
Whenever we are cooking a new release (e.g. `4.18.1`) we should follow a standard procedure described below:
1. Create a new branch named: `release/4.18.1`.
2. Bump the release number for backend project, frontend projects, and Helm files:
- for backend, update the version number by hand in `pyproject.toml`,
- for each projects (`src/frontend`, `src/frontend/apps/*`, `src/frontend/packages/*`, `src/mail`), run `yarn version --new-version --no-git-tag-version 4.18.1` in their directory. This will update their `package.json` for you,
- for Helm, update Docker image tag in files located at `src/helm/env.d` for both `preprod` and `production` environments:
```yaml
image:
repository: lasuite/impress-backend
pullPolicy: Always
tag: "v4.18.1" # Replace with your new version number, without forgetting the "v" prefix
...
frontend:
image:
repository: lasuite/impress-frontend
pullPolicy: Always
tag: "v4.18.1"
webrtc:
image:
repository: lasuite/impress-y-webrtc-signaling
pullPolicy: Always
tag: "v4.18.1"
```
The new images don't exist _yet_: they will be created automatically later in the process.
3. Update the project's `Changelog` following the [keepachangelog](https://keepachangelog.com/en/0.3.0/) recommendations
4. Commit your changes with the following format: the 🔖 release emoji, the type of release (patch/minor/patch) and the release version:
```text
🔖(minor) bump release to 4.18.0
```
5. Open a pull request, wait for an approval from your peers and merge it.
6. Checkout and pull changes from the `main` branch to ensure you have the latest updates.
7. Tag and push your commit:
```bash
git tag v4.18.1 && git push origin tag v4.18.1
```
Doing this triggers the CI and tells it to build the new Docker image versions that you targeted earlier in the Helm files.
8. Ensure the new [backend](https://hub.docker.com/r/lasuite/impress-frontend/tags) and [frontend](https://hub.docker.com/r/lasuite/impress-frontend/tags) image tags are on Docker Hub.
9. The release is now done!
# Deploying
> [!TIP]
> The `staging` platform is deployed automatically with every update of the `main` branch.
Making a new release doesn't publish it automatically in production.
Deployment is done by ArgoCD. ArgoCD checks for the `production` tag and automatically deploys the production platform with the targeted commit.
To publish, we mark the commit we want with the `production` tag. ArgoCD is then notified that the tag has changed. It then deploys the Docker image tags specified in the Helm files of the targeted commit.
To publish the release you just made:
```bash
git tag --force production v4.18.1
git push --force origin production
```
-25
View File
@@ -1,25 +0,0 @@
# Api client TypeScript
The backend application can automatically create a TypeScript client to be used in frontend
applications. It is used in the impress front application itself.
This client is made with [openapi-typescript-codegen](https://github.com/ferdikoomen/openapi-typescript-codegen)
and impress's backend OpenAPI schema (available [here](http://localhost:8071/v1.0/swagger/) if you have the backend running).
## Requirements
We'll need the online OpenAPI schema generated by swagger. Therefore you will first need to
install the backend application.
## Install openApiClientJs
```sh
$ cd src/tsclient
$ yarn install
```
## Generate the client
```sh
yarn generate:api:client:local <output_path_for_generated_client>
```
-3
View File
@@ -1,3 +0,0 @@
#!/bin/bash
find . -name "*.enc.*" -exec sops updatekeys -y {} \;
+1 -1
Submodule secrets updated: 1485c6dc9d...2643697e5f
+1
View File
@@ -1,4 +1,5 @@
"""Admin classes and registrations for core app."""
from django.contrib import admin
from django.contrib.auth import admin as auth_admin
from django.utils.translation import gettext_lazy as _
+4 -3
View File
@@ -1,4 +1,5 @@
"""Impress core API endpoints"""
from django.conf import settings
from django.core.exceptions import ValidationError
@@ -16,9 +17,9 @@ def exception_handler(exc, context):
https://gist.github.com/twidi/9d55486c36b6a51bdcb05ce3a763e79f
"""
if isinstance(exc, ValidationError):
if hasattr(exc, "message_dict"):
detail = exc.message_dict
elif hasattr(exc, "message"):
detail = exc.message_dict
if hasattr(exc, "message"):
detail = exc.message
elif hasattr(exc, "messages"):
detail = exc.messages
+1
View File
@@ -1,4 +1,5 @@
"""A JSONField for DRF to handle serialization/deserialization."""
import json
from rest_framework import serializers
+1
View File
@@ -1,4 +1,5 @@
"""Permission handlers for the impress core app."""
from django.core import exceptions
from rest_framework import permissions
+27 -2
View File
@@ -1,4 +1,5 @@
"""Client serializers for the impress core app."""
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
@@ -139,8 +140,17 @@ class DocumentSerializer(BaseResourceSerializer):
class Meta:
model = models.Document
fields = ["id", "content", "title", "accesses", "abilities", "is_public"]
read_only_fields = ["id", "accesses", "abilities"]
fields = [
"id",
"content",
"title",
"accesses",
"abilities",
"is_public",
"created_at",
"updated_at",
]
read_only_fields = ["id", "accesses", "abilities", "created_at", "updated_at"]
class TemplateSerializer(BaseResourceSerializer):
@@ -171,6 +181,12 @@ class DocumentGenerationSerializer(serializers.Serializer):
required=False,
default="html",
)
format = serializers.ChoiceField(
choices=["pdf", "docx"],
label=_("Format"),
required=False,
default="pdf",
)
class InvitationSerializer(serializers.ModelSerializer):
@@ -250,3 +266,12 @@ class InvitationSerializer(serializers.ModelSerializer):
attrs["document_id"] = document_id
attrs["issuer"] = user
return attrs
class DocumentVersionSerializer(serializers.Serializer):
"""Serialize Versions."""
etag = serializers.CharField()
is_latest = serializers.BooleanField()
last_modified = serializers.DateTimeField()
version_id = serializers.CharField()
+45 -13
View File
@@ -1,5 +1,4 @@
"""API endpoints"""
from io import BytesIO
from django.contrib.postgres.aggregates import ArrayAgg
from django.db.models import (
@@ -7,7 +6,7 @@ from django.db.models import (
Q,
Subquery,
)
from django.http import FileResponse, Http404
from django.http import Http404
from botocore.exceptions import ClientError
from rest_framework import (
@@ -24,6 +23,7 @@ from rest_framework import (
)
from core import models
from core.utils import email_invitation
from . import permissions, serializers
@@ -160,7 +160,7 @@ class ResourceViewsetMixin:
"""Mixin with methods common to all resource viewsets that are managed with accesses."""
filter_backends = [filters.OrderingFilter]
ordering_fields = ["created_at"]
ordering_fields = ["created_at", "updated_at", "title"]
ordering = ["-created_at"]
def get_queryset(self):
@@ -310,6 +310,7 @@ class DocumentViewSet(
access_model_class = models.DocumentAccess
resource_field_name = "document"
queryset = models.Document.objects.all()
ordering = ["-updated_at"]
def perform_create(self, serializer):
"""
@@ -337,10 +338,18 @@ class DocumentViewSet(
Q(user=request.user) | Q(team__in=request.user.get_teams()),
)
)
return drf_response.Response(
document.get_versions_slice(from_datetime=from_datetime)
versions_data = document.get_versions_slice(from_datetime=from_datetime)[
"versions"
]
paginator = pagination.PageNumberPagination()
paginated_versions = paginator.paginate_queryset(versions_data, request)
serialized_versions = serializers.DocumentVersionSerializer(
paginated_versions, many=True
)
return paginator.get_paginated_response(serialized_versions.data)
@decorators.action(
detail=True,
methods=["get", "delete"],
@@ -377,6 +386,7 @@ class DocumentViewSet(
{
"content": response["Body"].read().decode("utf-8"),
"last_modified": response["LastModified"],
"id": version_id,
}
)
@@ -421,6 +431,13 @@ class DocumentAccessViewSet(
resource_field_name = "document"
serializer_class = serializers.DocumentAccessSerializer
def perform_create(self, serializer):
"""Add a new access to the document and send an email to the new added user."""
access = serializer.save()
language = self.request.headers.get("Content-Language", "en-us")
email_invitation(language, access.user.email, access.document.id)
class TemplateViewSet(
ResourceViewsetMixin,
@@ -451,7 +468,16 @@ class TemplateViewSet(
# pylint: disable=unused-argument
def generate_document(self, request, pk=None):
"""
Generate and return pdf for this template with the content passed.
Generate and return a document for this template around the
body passed as argument.
2 types of body are accepted:
- HTML: body_type = "html"
- Markdown: body_type = "markdown"
2 types of documents can be generated:
- PDF: format = "pdf"
- Docx: format = "docx"
"""
serializer = serializers.DocumentGenerationSerializer(data=request.data)
@@ -462,13 +488,10 @@ class TemplateViewSet(
body = serializer.validated_data["body"]
body_type = serializer.validated_data["body_type"]
export_format = serializer.validated_data["format"]
template = self.get_object()
pdf_content = template.generate_document(body, body_type)
response = FileResponse(BytesIO(pdf_content), content_type="application/pdf")
response["Content-Disposition"] = f"attachment; filename={template.title}.pdf"
return response
return template.generate_document(body, body_type, export_format)
class TemplateAccessViewSet(
@@ -517,6 +540,7 @@ class InvitationViewset(
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.DestroyModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
"""API ViewSet for user invitations to document.
@@ -530,8 +554,9 @@ class InvitationViewset(
- role: str [administrator|editor|reader]
Return newly created invitation (issuer and document are automatically set)
PUT / PATCH : Not permitted. Instead of updating your invitation,
delete and create a new one.
PATCH /api/v1.0/documents/<document_id>/invitations/:<invitation_id>/ with expected data:
- role: str [owner|admin|editor|reader]
Return partially updated document invitation
DELETE /api/v1.0/documents/<document_id>/invitations/<invitation_id>/
Delete targeted invitation
@@ -585,3 +610,10 @@ class InvitationViewset(
.distinct()
)
return queryset
def perform_create(self, serializer):
"""Save invitation to a document then send an email to the invited user."""
invitation = serializer.save()
language = self.request.headers.get("Content-Language", "en-us")
email_invitation(language, invitation.email, invitation.document.id)
+1
View File
@@ -1,6 +1,7 @@
"""
Core application enums declaration
"""
from django.conf import global_settings, settings
from django.utils.translation import gettext_lazy as _
+1
View File
@@ -2,6 +2,7 @@
"""
Core application factories
"""
from django.conf import settings
from django.contrib.auth.hashers import make_password
+122 -70
View File
@@ -1,35 +1,35 @@
"""
Declare and configure the models for the impress core application
"""
import hashlib
import smtplib
import tempfile
import textwrap
import uuid
from datetime import timedelta
from io import BytesIO
from logging import getLogger
from django.conf import settings
from django.contrib.auth import models as auth_models
from django.contrib.auth.base_user import AbstractBaseUser
from django.contrib.sites.models import Site
from django.core import exceptions, mail, validators
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
from django.db import models
from django.http import FileResponse
from django.template.base import Template as DjangoTemplate
from django.template.context import Context
from django.template.loader import render_to_string
from django.utils import html, timezone
from django.utils.functional import lazy
from django.utils.translation import gettext_lazy as _
from django.utils.translation import override
import frontmatter
import markdown
import pypandoc
import weasyprint
from botocore.exceptions import ClientError
from timezone_field import TimeZoneField
from weasyprint import CSS, HTML
from weasyprint.text.fonts import FontConfiguration
logger = getLogger(__name__)
@@ -316,6 +316,29 @@ class Document(BaseModel):
def __str__(self):
return self.title
def save(self, *args, **kwargs):
"""Write content to object storage only if _content has changed."""
super().save(*args, **kwargs)
if self._content:
file_key = self.file_key
bytes_content = self._content.encode("utf-8")
if default_storage.exists(file_key):
response = default_storage.connection.meta.client.head_object(
Bucket=default_storage.bucket_name, Key=file_key
)
has_changed = (
response["ETag"].strip('"')
!= hashlib.md5(bytes_content).hexdigest() # noqa
)
else:
has_changed = True
if has_changed:
content_file = ContentFile(bytes_content)
default_storage.save(file_key, content_file)
@property
def key_base(self):
"""Key base of the location where the document is stored in object storage."""
@@ -356,28 +379,6 @@ class Document(BaseModel):
Bucket=default_storage.bucket_name, Key=self.file_key, VersionId=version_id
)
def save(self, *args, **kwargs):
"""Write content to object storage only if _content has changed."""
super().save(*args, **kwargs)
if self._content:
file_key = self.file_key
bytes_content = self._content.encode("utf-8")
if default_storage.exists(file_key):
response = default_storage.connection.meta.client.head_object(
Bucket=default_storage.bucket_name, Key=file_key
)
has_changed = (
response["ETag"].strip('"')
!= hashlib.md5(bytes_content).hexdigest() # noqa
)
else:
has_changed = True
if has_changed:
content_file = ContentFile(bytes_content)
default_storage.save(file_key, content_file)
def get_versions_slice(
self, from_version_id="", from_datetime=None, page_size=None
):
@@ -564,10 +565,90 @@ class Template(BaseModel):
"retrieve": can_get,
}
def generate_document(self, body, body_type):
def generate_pdf(self, body_html, metadata):
"""
Generate and return a PDF document for this template around the
Generate and return a pdf document wrapped around the current template
"""
document_html = weasyprint.HTML(
string=DjangoTemplate(self.code).render(
Context({"body": html.format_html(body_html), **metadata})
)
)
css = weasyprint.CSS(
string=self.css,
font_config=weasyprint.text.fonts.FontConfiguration(),
)
pdf_content = document_html.write_pdf(stylesheets=[css], zoom=1)
response = FileResponse(BytesIO(pdf_content), content_type="application/pdf")
response["Content-Disposition"] = f"attachment; filename={self.title}.pdf"
return response
def generate_word(self, body_html, metadata):
"""
Generate and return a docx document wrapped around the current template
"""
template_string = DjangoTemplate(self.code).render(
Context({"body": html.format_html(body_html), **metadata})
)
html_string = f"""
<!DOCTYPE html>
<html>
<head>
<style>
{self.css}
</style>
</head>
<body>
{template_string}
</body>
</html>
"""
reference_docx = "core/static/reference.docx"
output = BytesIO()
# Convert the HTML to a temporary docx file
with tempfile.NamedTemporaryFile(suffix=".docx", prefix="docx_") as tmp_file:
output_path = tmp_file.name
pypandoc.convert_text(
html_string,
"docx",
format="html",
outputfile=output_path,
extra_args=["--reference-doc", reference_docx],
)
# Create a BytesIO object to store the output of the temporary docx file
with open(output_path, "rb") as f:
output = BytesIO(f.read())
# Ensure the pointer is at the beginning
output.seek(0)
response = FileResponse(
output,
content_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
)
response["Content-Disposition"] = f"attachment; filename={self.title}.docx"
return response
def generate_document(self, body, body_type, export_format):
"""
Generate and return a document for this template around the
body passed as argument.
2 types of body are accepted:
- HTML: body_type = "html"
- Markdown: body_type = "markdown"
2 types of documents can be generated:
- PDF: export_format = "pdf"
- Docx: export_format = "docx"
"""
document = frontmatter.loads(body)
metadata = document.metadata
@@ -580,16 +661,10 @@ class Template(BaseModel):
markdown.markdown(textwrap.dedent(strip_body)) if strip_body else ""
)
document_html = HTML(
string=DjangoTemplate(self.code).render(
Context({"body": html.format_html(body_html), **metadata})
)
)
css = CSS(
string=self.css,
font_config=FontConfiguration(),
)
return document_html.write_pdf(stylesheets=[css], zoom=1)
if export_format == "pdf":
return self.generate_pdf(body_html, metadata)
return self.generate_word(body_html, metadata)
class TemplateAccess(BaseAccess):
@@ -668,14 +743,6 @@ class Invitation(BaseModel):
def __str__(self):
return f"{self.email} invited to {self.document}"
def save(self, *args, **kwargs):
"""Make invitations read-only."""
if self.created_at:
raise exceptions.PermissionDenied()
super().save(*args, **kwargs)
self.email_invitation()
def clean(self):
"""Validate fields."""
super().clean()
@@ -698,6 +765,7 @@ class Invitation(BaseModel):
def get_abilities(self, user):
"""Compute and return abilities for a given user."""
can_delete = False
can_update = False
roles = []
if user.is_authenticated:
@@ -716,29 +784,13 @@ class Invitation(BaseModel):
set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
)
can_update = bool(
set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
)
return {
"destroy": can_delete,
"update": False,
"partial_update": False,
"update": can_update,
"partial_update": can_update,
"retrieve": bool(roles),
}
def email_invitation(self):
"""Email invitation to the user."""
try:
with override(self.issuer.language):
title = _("Invitation to join Impress!")
template_vars = {"title": title, "site": Site.objects.get_current()}
msg_html = render_to_string("mail/html/invitation.html", template_vars)
msg_plain = render_to_string("mail/text/invitation.txt", template_vars)
mail.send_mail(
title,
msg_plain,
settings.EMAIL_FROM,
[self.email],
html_message=msg_html,
fail_silently=False,
)
except smtplib.SMTPException as exception:
logger.error("invitation to %s was not sent: %s", self.email, exception)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.
@@ -92,9 +92,12 @@ def test_models_oidc_user_getter_invalid_token(django_assert_num_queries, monkey
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with django_assert_num_queries(0), pytest.raises(
SuspiciousOperation,
match="User info contained no recognizable user identification",
with (
django_assert_num_queries(0),
pytest.raises(
SuspiciousOperation,
match="User info contained no recognizable user identification",
),
):
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
+1
View File
@@ -1,4 +1,5 @@
"""Fixtures for tests in the impress core application"""
from unittest import mock
import pytest
@@ -1,6 +1,7 @@
"""
Test document accesses API endpoints for users in impress's core app.
"""
import random
from uuid import uuid4
@@ -67,6 +68,7 @@ def test_api_document_accesses_list_authenticated_related(via, mock_user_get_tea
client.force_login(user)
document = factories.DocumentFactory()
user_access = None
if via == USER:
user_access = models.DocumentAccess.objects.create(
document=document,
@@ -173,7 +175,9 @@ def test_api_document_accesses_retrieve_authenticated_unrelated():
)
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
assert response.json() == {
"detail": "No DocumentAccess matches the given query."
}
@pytest.mark.parametrize("via", VIA)
@@ -212,203 +216,6 @@ def test_api_document_accesses_retrieve_authenticated_related(via, mock_user_get
}
def test_api_document_accesses_create_anonymous():
"""Anonymous users should not be allowed to create document accesses."""
user = factories.UserFactory()
document = factories.DocumentFactory()
response = APIClient().post(
f"/api/v1.0/documents/{document.id!s}/accesses/",
{
"user": str(user.id),
"document": str(document.id),
"role": random.choice(models.RoleChoices.choices)[0],
},
format="json",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
assert models.DocumentAccess.objects.exists() is False
def test_api_document_accesses_create_authenticated_unrelated():
"""
Authenticated users should not be allowed to create document accesses for a document to
which they are not related.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
other_user = factories.UserFactory()
document = factories.DocumentFactory()
response = client.post(
f"/api/v1.0/documents/{document.id!s}/accesses/",
{
"user": str(other_user.id),
},
format="json",
)
assert response.status_code == 403
assert not models.DocumentAccess.objects.filter(user=other_user).exists()
@pytest.mark.parametrize("role", ["reader", "editor"])
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_create_authenticated_reader_or_editor(
via, role, mock_user_get_teams
):
"""Readers or editors of a document should not be allowed to create document accesses."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
)
other_user = factories.UserFactory()
for new_role in [role[0] for role in models.RoleChoices.choices]:
response = client.post(
f"/api/v1.0/documents/{document.id!s}/accesses/",
{
"user": str(other_user.id),
"role": new_role,
},
format="json",
)
assert response.status_code == 403
assert not models.DocumentAccess.objects.filter(user=other_user).exists()
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_create_authenticated_administrator(
via, mock_user_get_teams
):
"""
Administrators of a document should be able to create document accesses
except for the "owner" role.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(
document=document, user=user, role="administrator"
)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="administrator"
)
other_user = factories.UserFactory()
# It should not be allowed to create an owner access
response = client.post(
f"/api/v1.0/documents/{document.id!s}/accesses/",
{
"user": str(other_user.id),
"role": "owner",
},
format="json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "Only owners of a resource can assign other users as owners."
}
# It should be allowed to create a lower access
role = random.choice(
[role[0] for role in models.RoleChoices.choices if role[0] != "owner"]
)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/accesses/",
{
"user_id": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == 201
assert models.DocumentAccess.objects.filter(user=other_user).count() == 1
new_document_access = models.DocumentAccess.objects.filter(user=other_user).get()
other_user = serializers.UserSerializer(instance=other_user).data
assert response.json() == {
"abilities": new_document_access.get_abilities(user),
"id": str(new_document_access.id),
"team": "",
"role": role,
"user": other_user,
}
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_create_authenticated_owner(via, mock_user_get_teams):
"""
Owners of a document should be able to create document accesses whatever the role.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="owner"
)
other_user = factories.UserFactory()
role = random.choice([role[0] for role in models.RoleChoices.choices])
response = client.post(
f"/api/v1.0/documents/{document.id!s}/accesses/",
{
"user_id": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == 201
assert models.DocumentAccess.objects.filter(user=other_user).count() == 1
new_document_access = models.DocumentAccess.objects.filter(user=other_user).get()
other_user = serializers.UserSerializer(instance=other_user).data
assert response.json() == {
"id": str(new_document_access.id),
"user": other_user,
"team": "",
"role": role,
"abilities": new_document_access.get_abilities(user),
}
def test_api_document_accesses_update_anonymous():
"""Anonymous users should not be allowed to update a document access."""
access = factories.UserDocumentAccessFactory()
@@ -738,6 +545,7 @@ def test_api_document_accesses_update_owner_self(via, mock_user_get_teams):
client.force_login(user)
document = factories.DocumentFactory()
access = None
if via == USER:
access = factories.UserDocumentAccessFactory(
document=document, user=user, role="owner"
@@ -968,6 +776,7 @@ def test_api_document_accesses_delete_owners_last_owner(via, mock_user_get_teams
client.force_login(user)
document = factories.DocumentFactory()
access = None
if via == USER:
access = factories.UserDocumentAccessFactory(
document=document, user=user, role="owner"
@@ -0,0 +1,231 @@
"""
Test document accesses API endpoints for users in impress's core app.
"""
import random
from django.core import mail
import pytest
from rest_framework.test import APIClient
from core import factories, models
from core.api import serializers
from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
def test_api_document_accesses_create_anonymous():
"""Anonymous users should not be allowed to create document accesses."""
user = factories.UserFactory()
document = factories.DocumentFactory()
response = APIClient().post(
f"/api/v1.0/documents/{document.id!s}/accesses/",
{
"user": str(user.id),
"document": str(document.id),
"role": random.choice(models.RoleChoices.choices)[0],
},
format="json",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
assert models.DocumentAccess.objects.exists() is False
def test_api_document_accesses_create_authenticated_unrelated():
"""
Authenticated users should not be allowed to create document accesses for a document to
which they are not related.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
other_user = factories.UserFactory()
document = factories.DocumentFactory()
response = client.post(
f"/api/v1.0/documents/{document.id!s}/accesses/",
{
"user": str(other_user.id),
},
format="json",
)
assert response.status_code == 403
assert not models.DocumentAccess.objects.filter(user=other_user).exists()
@pytest.mark.parametrize("role", ["reader", "editor"])
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_create_authenticated_reader_or_editor(
via, role, mock_user_get_teams
):
"""Readers or editors of a document should not be allowed to create document accesses."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
)
other_user = factories.UserFactory()
for new_role in [role[0] for role in models.RoleChoices.choices]:
response = client.post(
f"/api/v1.0/documents/{document.id!s}/accesses/",
{
"user": str(other_user.id),
"role": new_role,
},
format="json",
)
assert response.status_code == 403
assert not models.DocumentAccess.objects.filter(user=other_user).exists()
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_create_authenticated_administrator(
via, mock_user_get_teams
):
"""
Administrators of a document should be able to create document accesses
except for the "owner" role.
An email should be sent to the accesses to notify them of the adding.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(
document=document, user=user, role="administrator"
)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="administrator"
)
other_user = factories.UserFactory()
# It should not be allowed to create an owner access
response = client.post(
f"/api/v1.0/documents/{document.id!s}/accesses/",
{
"user": str(other_user.id),
"role": "owner",
},
format="json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "Only owners of a resource can assign other users as owners."
}
# It should be allowed to create a lower access
role = random.choice(
[role[0] for role in models.RoleChoices.choices if role[0] != "owner"]
)
assert len(mail.outbox) == 0
response = client.post(
f"/api/v1.0/documents/{document.id!s}/accesses/",
{
"user_id": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == 201
assert models.DocumentAccess.objects.filter(user=other_user).count() == 1
new_document_access = models.DocumentAccess.objects.filter(user=other_user).get()
other_user = serializers.UserSerializer(instance=other_user).data
assert response.json() == {
"abilities": new_document_access.get_abilities(user),
"id": str(new_document_access.id),
"team": "",
"role": role,
"user": other_user,
}
assert len(mail.outbox) == 1
email = mail.outbox[0]
assert email.to == [other_user["email"]]
email_content = " ".join(email.body.split())
assert "Invitation to join Docs!" in email_content
assert "docs/" + str(document.id) + "/" in email_content
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_create_authenticated_owner(via, mock_user_get_teams):
"""
Owners of a document should be able to create document accesses whatever the role.
An email should be sent to the accesses to notify them of the adding.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="owner"
)
other_user = factories.UserFactory()
role = random.choice([role[0] for role in models.RoleChoices.choices])
assert len(mail.outbox) == 0
response = client.post(
f"/api/v1.0/documents/{document.id!s}/accesses/",
{
"user_id": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == 201
assert models.DocumentAccess.objects.filter(user=other_user).count() == 1
new_document_access = models.DocumentAccess.objects.filter(user=other_user).get()
other_user = serializers.UserSerializer(instance=other_user).data
assert response.json() == {
"id": str(new_document_access.id),
"user": other_user,
"team": "",
"role": role,
"abilities": new_document_access.get_abilities(user),
}
assert len(mail.outbox) == 1
email = mail.outbox[0]
assert email.to == [other_user["email"]]
email_content = " ".join(email.body.split())
assert "Invitation to join Docs!" in email_content
assert "docs/" + str(document.id) + "/" in email_content
@@ -1,9 +1,12 @@
"""
Unit tests for the Invitation model
"""
import random
import time
from django.core import mail
import pytest
from rest_framework import status
from rest_framework.test import APIClient
@@ -98,6 +101,8 @@ def test_api_document_invitations__create__privileged_members(
"role": invited,
}
assert len(mail.outbox) == 0
client = APIClient()
client.force_login(user)
response = client.post(
@@ -108,11 +113,92 @@ def test_api_document_invitations__create__privileged_members(
if is_allowed:
assert response.status_code == status.HTTP_201_CREATED
assert models.Invitation.objects.count() == 1
assert len(mail.outbox) == 1
email = mail.outbox[0]
assert email.to == ["guest@example.com"]
email_content = " ".join(email.body.split())
assert "Invitation to join Docs!" in email_content
else:
assert response.status_code == status.HTTP_403_FORBIDDEN
assert models.Invitation.objects.exists() is False
def test_api_document_invitations__create__email_from_content_language():
"""
The email generated is from the language set in the Content-Language header
"""
user = factories.UserFactory()
document = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
invitation_values = {
"email": "guest@example.com",
"role": "reader",
}
assert len(mail.outbox) == 0
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id}/invitations/",
invitation_values,
format="json",
headers={"Content-Language": "fr-fr"},
)
assert response.status_code == status.HTTP_201_CREATED
assert response.json()["email"] == "guest@example.com"
assert models.Invitation.objects.count() == 1
assert len(mail.outbox) == 1
email = mail.outbox[0]
assert email.to == ["guest@example.com"]
email_content = " ".join(email.body.split())
assert "Invitation à rejoindre Docs !" in email_content
def test_api_document_invitations__create__email_from_content_language_not_supported():
"""
If the language from the Content-Language is not supported
it will display the default language, English.
"""
user = factories.UserFactory()
document = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
invitation_values = {
"email": "guest@example.com",
"role": "reader",
}
assert len(mail.outbox) == 0
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id}/invitations/",
invitation_values,
format="json",
headers={"Content-Language": "not-supported"},
)
assert response.status_code == status.HTTP_201_CREATED
assert response.json()["email"] == "guest@example.com"
assert models.Invitation.objects.count() == 1
assert len(mail.outbox) == 1
email = mail.outbox[0]
assert email.to == ["guest@example.com"]
email_content = " ".join(email.body.split())
assert "Invitation to join Docs!" in email_content
def test_api_document_invitations__create__issuer_and_document_override():
"""It should not be possible to set the "document" and "issuer" fields."""
user = factories.UserFactory()
@@ -165,7 +251,7 @@ def test_api_document_invitations__create__cannot_duplicate_invitation():
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["__all__"] == [
assert response.json() == [
"Document invitation with this Email address and Document already exists."
]
@@ -193,9 +279,7 @@ def test_api_document_invitations__create__cannot_invite_existing_users():
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["email"] == [
"This email is already associated to a registered user."
]
assert response.json() == ["This email is already associated to a registered user."]
def test_api_document_invitations__list__anonymous_user():
@@ -256,8 +340,8 @@ def test_api_document_invitations__list__authenticated(
"is_expired": False,
"abilities": {
"destroy": role in ["administrator", "owner"],
"update": False,
"partial_update": False,
"update": role in ["administrator", "owner"],
"partial_update": role in ["administrator", "owner"],
"retrieve": True,
},
}
@@ -308,8 +392,8 @@ def test_api_document_invitations__list__expired_invitations_still_listed(settin
"is_expired": True,
"abilities": {
"destroy": True,
"update": False,
"partial_update": False,
"update": True,
"partial_update": True,
"retrieve": True,
},
},
@@ -383,21 +467,17 @@ def test_api_document_invitations__retrieve__document_member(via, mock_user_get_
"is_expired": False,
"abilities": {
"destroy": role in ["administrator", "owner"],
"update": False,
"partial_update": False,
"update": role in ["administrator", "owner"],
"partial_update": role in ["administrator", "owner"],
"retrieve": True,
},
}
@pytest.mark.parametrize("via", VIA)
@pytest.mark.parametrize(
"method",
["put", "patch"],
)
def test_api_document_invitations__update__forbidden(method, via, mock_user_get_teams):
def test_api_document_invitations__put_authenticated(via, mock_user_get_teams):
"""
Update of invitations is currently forbidden.
Authenticated user can put invitations.
"""
user = factories.UserFactory()
invitation = factories.InvitationFactory()
@@ -414,13 +494,89 @@ def test_api_document_invitations__update__forbidden(method, via, mock_user_get_
client = APIClient()
client.force_login(user)
url = f"/api/v1.0/documents/{invitation.document.id}/invitations/{invitation.id}/"
if method == "put":
response = client.put(url)
response = client.patch(url, {"email": "test@test.test"}, format="json")
assert response.status_code == status.HTTP_200_OK
invitation.refresh_from_db()
assert invitation.email == "test@test.test"
@pytest.mark.parametrize("via", VIA)
def test_api_document_invitations__patch_authenticated(via, mock_user_get_teams):
"""
Authenticated user can patch invitations.
"""
user = factories.UserFactory()
invitation = factories.InvitationFactory(role="owner")
if via == USER:
factories.UserDocumentAccessFactory(
document=invitation.document, user=user, role="owner"
)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=invitation.document, team="lasuite", role="owner"
)
assert invitation.role == "owner"
client = APIClient()
client.force_login(user)
url = f"/api/v1.0/documents/{invitation.document.id}/invitations/{invitation.id}/"
response = client.patch(
url,
{"role": "reader"},
format="json",
)
assert response.status_code == status.HTTP_200_OK
invitation.refresh_from_db()
assert invitation.role == "reader"
@pytest.mark.parametrize("via", VIA)
@pytest.mark.parametrize(
"method",
["put", "patch"],
)
@pytest.mark.parametrize(
"role",
["editor", "reader"],
)
def test_api_document_invitations__update__forbidden__not_authenticated(
method, via, role, mock_user_get_teams
):
"""
Update of invitations is currently forbidden.
"""
user = factories.UserFactory()
invitation = factories.InvitationFactory()
if via == USER:
factories.UserDocumentAccessFactory(
document=invitation.document, user=user, role=role
)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=invitation.document, team="lasuite", role=role
)
client = APIClient()
client.force_login(user)
url = f"/api/v1.0/documents/{invitation.document.id}/invitations/{invitation.id}/"
response = client.put(url)
if method == "patch":
response = client.patch(url)
assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
assert response.json()["detail"] == f'Method "{method.upper()}" not allowed.'
assert response.status_code == status.HTTP_403_FORBIDDEN
assert (
response.json()["detail"]
== "You do not have permission to perform this action."
)
def test_api_document_invitations__delete__anonymous():
@@ -1,6 +1,7 @@
"""
Test document versions API endpoints for users in impress's core app.
"""
import random
import time
@@ -38,7 +39,7 @@ def test_api_document_versions_list_anonymous_private():
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/versions/")
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
assert response.json() == {"detail": "No Document matches the given query."}
def test_api_document_versions_list_authenticated_unrelated_public():
@@ -86,7 +87,7 @@ def test_api_document_versions_list_authenticated_unrelated_private():
f"/api/v1.0/documents/{document.id!s}/versions/",
)
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
assert response.json() == {"detail": "No Document matches the given query."}
@pytest.mark.parametrize("via", VIA)
@@ -125,7 +126,8 @@ def test_api_document_versions_list_authenticated_related(via, mock_user_get_tea
assert response.status_code == 200
content = response.json()
assert len(content["versions"]) == 0
assert len(content["results"]) == 0
assert content["count"] == 0
# Add a new version to the document
document.content = "new content"
@@ -137,9 +139,8 @@ def test_api_document_versions_list_authenticated_related(via, mock_user_get_tea
assert response.status_code == 200
content = response.json()
assert len(content["versions"]) == 1
assert content["next_version_id_marker"] == ""
assert content["is_truncated"] is False
assert len(content["results"]) == 1
assert content["count"] == 1
def test_api_document_versions_retrieve_anonymous_public():
@@ -169,7 +170,7 @@ def test_api_document_versions_retrieve_anonymous_private():
response = APIClient().get(url)
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
assert response.json() == {"detail": "No Document matches the given query."}
def test_api_document_versions_retrieve_authenticated_unrelated_public():
@@ -211,7 +212,7 @@ def test_api_document_versions_retrieve_authenticated_unrelated_private():
f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/",
)
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
assert response.json() == {"detail": "No Document matches the given query."}
@pytest.mark.parametrize("via", VIA)
@@ -434,7 +435,7 @@ def test_api_document_versions_delete_authenticated_private():
)
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
assert response.json() == {"detail": "No Document matches the given query."}
@pytest.mark.parametrize("role", ["reader", "editor"])
@@ -1,6 +1,7 @@
"""
Tests for Documents API endpoint in impress's core app: create
"""
import uuid
import pytest
@@ -1,6 +1,7 @@
"""
Tests for Documents API endpoint in impress's core app: delete
"""
import random
import pytest
@@ -1,15 +1,18 @@
"""
Tests for Documents API endpoint in impress's core app: list
"""
from unittest import mock
import pytest
from faker import Faker
from rest_framework.pagination import PageNumberPagination
from rest_framework.status import HTTP_200_OK
from rest_framework.test import APIClient
from core import factories
fake = Faker()
pytestmark = pytest.mark.django_db
@@ -166,57 +169,133 @@ def test_api_documents_list_authenticated_distinct():
assert content["results"][0]["id"] == str(document.id)
def test_api_documents_order():
def test_api_documents_order_updated_at_desc_default():
"""
Test that the endpoint GET documents is sorted in 'created_at' descending order by default.
Test that the endpoint GET documents is sorted in 'updated_at' descending order by default.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document_ids = [
str(document.id)
for document in factories.DocumentFactory.create_batch(5, is_public=True)
# Updated at next year to ensure the order is correct
documents_updated = [
document.updated_at.isoformat().replace("+00:00", "Z")
for document in factories.DocumentFactory.create_batch(
5, is_public=True, updated_at=fake.date_time_this_year(before_now=False)
)
]
response = client.get(
documents_updated.sort(reverse=True)
response = APIClient().get(
"/api/v1.0/documents/",
)
assert response.status_code == 200
response_data = response.json()
response_document_ids = [document["id"] for document in response_data["results"]]
document_ids.reverse()
response_document_updated = [
document["updated_at"] for document in response_data["results"]
]
assert (
response_document_ids == document_ids
), "created_at values are not sorted from newest to oldest"
response_document_updated == documents_updated
), "updated_at values are not sorted from newest to oldest"
def test_api_documents_order_param():
@pytest.mark.parametrize(
"ordering_field, factory_field",
[
("-created_at", "created_at"),
("-updated_at", "updated_at"),
("-title", "title"),
],
)
def test_api_documents_ordering_desc(ordering_field, factory_field):
"""
Test that the 'created_at' field is sorted in ascending order
Test that the specified field is sorted in descending order
when the 'ordering' query parameter is set.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
documents_ids = [
str(document.id)
for document in factories.DocumentFactory.create_batch(5, is_public=True)
]
if factory_field == "title":
documents_field_values = [
factories.DocumentFactory(
is_public=True, title=fake.sentence(nb_words=4)
).title
for _ in range(5)
]
else:
documents_field_values = [
getattr(document, factory_field).isoformat().replace("+00:00", "Z")
for document in factories.DocumentFactory.create_batch(5, is_public=True)
]
response = APIClient().get(
"/api/v1.0/documents/?ordering=created_at",
documents_field_values.sort(reverse=True)
response = client.get(
f"/api/v1.0/documents/?ordering={ordering_field}"
if ordering_field != "-created_at"
else "/api/v1.0/documents/",
)
assert response.status_code == 200
response_data = response.json()
response_document_ids = [document["id"] for document in response_data["results"]]
response_documents_field_values = [
document[factory_field] for document in response_data["results"]
]
assert (
response_document_ids == documents_ids
), "created_at values are not sorted from oldest to newest"
response_documents_field_values == documents_field_values
), f"{factory_field} values are not sorted as expected"
@pytest.mark.parametrize(
"field",
[
("updated_at"),
("title"),
("created_at"),
],
)
def test_api_documents_ordering_asc(field):
"""
Test that the specified field is sorted in ascending order
when the 'ordering' query parameter is set.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
if field == "title":
documents_field_values = [
factories.DocumentFactory(
is_public=True, title=fake.sentence(nb_words=4)
).title
for _ in range(5)
]
else:
documents_field_values = [
getattr(document, field).isoformat().replace("+00:00", "Z")
for document in factories.DocumentFactory.create_batch(5, is_public=True)
]
documents_field_values.sort()
response = client.get(
f"/api/v1.0/documents/?ordering={field}",
)
assert response.status_code == 200
response_data = response.json()
response_documents_field_values = [
document[field] for document in response_data["results"]
]
assert (
response_documents_field_values == documents_field_values
), f"{field} values are not sorted as expected"
@@ -1,6 +1,7 @@
"""
Tests for Documents API endpoint in impress's core app: retrieve
"""
import pytest
from rest_framework.test import APIClient
@@ -33,6 +34,8 @@ def test_api_documents_retrieve_anonymous_public():
"title": document.title,
"is_public": True,
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}
@@ -43,7 +46,7 @@ def test_api_documents_retrieve_anonymous_not_public():
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/")
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
assert response.json() == {"detail": "No Document matches the given query."}
def test_api_documents_retrieve_authenticated_unrelated_public():
@@ -78,6 +81,8 @@ def test_api_documents_retrieve_authenticated_unrelated_public():
"title": document.title,
"is_public": True,
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}
@@ -97,7 +102,7 @@ def test_api_documents_retrieve_authenticated_unrelated_not_public():
f"/api/v1.0/documents/{document.id!s}/",
)
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
assert response.json() == {"detail": "No Document matches the given query."}
def test_api_documents_retrieve_authenticated_related_direct():
@@ -146,6 +151,8 @@ def test_api_documents_retrieve_authenticated_related_direct():
"content": document.content,
"abilities": document.get_abilities(user),
"is_public": document.is_public,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}
@@ -178,7 +185,7 @@ def test_api_documents_retrieve_authenticated_related_team_none(mock_user_get_te
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
assert response.json() == {"detail": "No Document matches the given query."}
@pytest.mark.parametrize(
@@ -222,6 +229,8 @@ def test_api_documents_retrieve_authenticated_related_team_members(
factories.TeamDocumentAccessFactory()
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
# pylint: disable=R0801
assert response.status_code == 200
content = response.json()
expected_abilities = {
@@ -277,6 +286,8 @@ def test_api_documents_retrieve_authenticated_related_team_members(
"content": document.content,
"abilities": document.get_abilities(user),
"is_public": False,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}
@@ -394,6 +405,8 @@ def test_api_documents_retrieve_authenticated_related_team_administrators(
"content": document.content,
"abilities": document.get_abilities(user),
"is_public": False,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}
@@ -515,4 +528,6 @@ def test_api_documents_retrieve_authenticated_related_team_owners(
"content": document.content,
"abilities": document.get_abilities(user),
"is_public": False,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}
@@ -1,6 +1,7 @@
"""
Tests for Documents API endpoint in impress's core app: update
"""
import random
import pytest
@@ -58,7 +59,7 @@ def test_api_documents_update_authenticated_unrelated():
)
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
assert response.json() == {"detail": "No Document matches the given query."}
document.refresh_from_db()
document_values = serializers.DocumentSerializer(instance=document).data
@@ -141,8 +142,10 @@ def test_api_documents_update_authenticated_editor_administrator_or_owner(
document = models.Document.objects.get(pk=document.pk)
document_values = serializers.DocumentSerializer(instance=document).data
for key, value in document_values.items():
if key in ["id", "accesses"]:
if key in ["id", "accesses", "created_at"]:
assert value == old_document_values[key]
elif key == "updated_at":
assert value > old_document_values[key]
else:
assert value == new_document_values[key]
@@ -178,8 +181,10 @@ def test_api_documents_update_authenticated_owners(via, mock_user_get_teams):
document = models.Document.objects.get(pk=document.pk)
document_values = serializers.DocumentSerializer(instance=document).data
for key, value in document_values.items():
if key in ["id", "accesses"]:
if key in ["id", "accesses", "created_at"]:
assert value == old_document_values[key]
elif key == "updated_at":
assert value > old_document_values[key]
else:
assert value == new_document_values[key]
@@ -1,6 +1,7 @@
"""
Test suite for generated openapi schema.
"""
import json
from io import StringIO
@@ -1,6 +1,7 @@
"""
Tests for Templates API endpoint in impress's core app: create
"""
import pytest
from rest_framework.test import APIClient
@@ -1,6 +1,7 @@
"""
Tests for Templates API endpoint in impress's core app: delete
"""
import random
import pytest
@@ -1,6 +1,7 @@
"""
Test users API endpoints in the impress core app.
"""
import pytest
from rest_framework.test import APIClient
@@ -44,7 +45,7 @@ def test_api_templates_generate_document_anonymous_not_public():
)
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
assert response.json() == {"detail": "No Template matches the given query."}
def test_api_templates_generate_document_authenticated_public():
@@ -87,7 +88,7 @@ def test_api_templates_generate_document_authenticated_not_public():
)
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
assert response.json() == {"detail": "No Template matches the given query."}
@pytest.mark.parametrize("via", VIA)
@@ -97,7 +98,7 @@ def test_api_templates_generate_document_related(via, mock_user_get_teams):
client = APIClient()
client.force_login(user)
access = None
if via == USER:
access = factories.UserTemplateAccessFactory(user=user)
elif via == TEAM:
@@ -178,3 +179,26 @@ def test_api_templates_generate_document_type_unknown():
'"unknown" is not a valid choice.',
]
}
def test_api_templates_generate_document_export_docx():
"""Generate pdf document with the body type html."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory(is_public=True)
data = {"body": "<p>Test body</p>", "body_type": "html", "format": "docx"}
response = client.post(
f"/api/v1.0/templates/{template.id!s}/generate-document/",
data,
format="json",
)
assert response.status_code == 200
assert (
response.headers["content-type"]
== "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
)
@@ -1,6 +1,7 @@
"""
Tests for Templates API endpoint in impress's core app: list
"""
from unittest import mock
import pytest
@@ -1,6 +1,7 @@
"""
Tests for Templates API endpoint in impress's core app: retrieve
"""
import pytest
from rest_framework.test import APIClient
@@ -41,7 +42,7 @@ def test_api_templates_retrieve_anonymous_not_public():
response = APIClient().get(f"/api/v1.0/templates/{template.id!s}/")
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
assert response.json() == {"detail": "No Template matches the given query."}
def test_api_templates_retrieve_authenticated_unrelated_public():
@@ -94,7 +95,7 @@ def test_api_templates_retrieve_authenticated_unrelated_not_public():
f"/api/v1.0/templates/{template.id!s}/",
)
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
assert response.json() == {"detail": "No Template matches the given query."}
def test_api_templates_retrieve_authenticated_related_direct():
@@ -174,7 +175,7 @@ def test_api_templates_retrieve_authenticated_related_team_none(mock_user_get_te
response = client.get(f"/api/v1.0/templates/{template.id!s}/")
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
assert response.json() == {"detail": "No Template matches the given query."}
@pytest.mark.parametrize(
@@ -1,6 +1,7 @@
"""
Tests for Templates API endpoint in impress's core app: update
"""
import random
import pytest
@@ -58,7 +59,7 @@ def test_api_templates_update_authenticated_unrelated():
)
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
assert response.json() == {"detail": "No Template matches the given query."}
template.refresh_from_db()
template_values = serializers.TemplateSerializer(instance=template).data
@@ -1,6 +1,7 @@
"""
Test template accesses API endpoints for users in impress's core app.
"""
import random
from uuid import uuid4
@@ -67,6 +68,7 @@ def test_api_template_accesses_list_authenticated_related(via, mock_user_get_tea
client.force_login(user)
template = factories.TemplateFactory()
user_access = None
if via == USER:
user_access = models.TemplateAccess.objects.create(
template=template,
@@ -170,7 +172,9 @@ def test_api_template_accesses_retrieve_authenticated_unrelated():
)
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
assert response.json() == {
"detail": "No TemplateAccess matches the given query."
}
@pytest.mark.parametrize("via", VIA)
@@ -731,6 +735,7 @@ def test_api_template_accesses_update_owner_self(via, mock_user_get_teams):
client.force_login(user)
template = factories.TemplateFactory()
access = None
if via == USER:
access = factories.UserTemplateAccessFactory(
template=template, user=user, role="owner"
@@ -955,6 +960,7 @@ def test_api_template_accesses_delete_owners_last_owner(via, mock_user_get_teams
client.force_login(user)
template = factories.TemplateFactory()
access = None
if via == USER:
access = factories.UserTemplateAccessFactory(
template=template, user=user, role="owner"
+1
View File
@@ -1,6 +1,7 @@
"""
Test users API endpoints in the impress core app.
"""
import pytest
from rest_framework.test import APIClient
@@ -1,6 +1,7 @@
"""
Unit tests for the DocumentAccess model
"""
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ValidationError
@@ -318,7 +319,7 @@ def test_models_document_access_get_abilities_for_editor_of_administrator():
def test_models_document_access_get_abilities_for_editor_of_editor_user(
django_assert_num_queries
django_assert_num_queries,
):
"""Check abilities of editor access for the editor of a document."""
access = factories.UserDocumentAccessFactory(role="editor")
@@ -377,7 +378,7 @@ def test_models_document_access_get_abilities_for_reader_of_administrator():
def test_models_document_access_get_abilities_for_reader_of_reader_user(
django_assert_num_queries
django_assert_num_queries,
):
"""Check abilities of reader access for the reader of a document."""
access = factories.UserDocumentAccessFactory(role="reader")
@@ -1,6 +1,7 @@
"""
Unit tests for the Document model
"""
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ValidationError
from django.core.files.storage import default_storage
@@ -2,13 +2,10 @@
Unit tests for the Invitation model
"""
import smtplib
import time
from logging import Logger
from unittest import mock
from django.contrib.auth.models import AnonymousUser
from django.core import exceptions, mail
from django.core import exceptions
import pytest
from faker import Faker
@@ -23,13 +20,6 @@ pytestmark = pytest.mark.django_db
fake = Faker()
def test_models_invitations_readonly_after_create():
"""Existing invitations should be readonly."""
invitation = factories.InvitationFactory()
with pytest.raises(exceptions.PermissionDenied):
invitation.save()
def test_models_invitations_email_no_empty_mail():
"""The "email" field should not be empty."""
with pytest.raises(exceptions.ValidationError, match="This field cannot be blank"):
@@ -168,67 +158,6 @@ def test_models_invitation__new_user__user_creation_constant_num_queries(
models.User.objects.create(email=user_email, password="!")
def test_models_document_invitations_email():
"""Check email invitation during invitation creation."""
member_access = factories.UserDocumentAccessFactory(role="reader")
document = member_access.document
# pylint: disable-next=no-member
assert len(mail.outbox) == 0
factories.UserDocumentAccessFactory(document=document)
invitation = factories.InvitationFactory(document=document, email="john@people.com")
# pylint: disable-next=no-member
assert len(mail.outbox) == 1
# pylint: disable-next=no-member
email = mail.outbox[0]
assert email.to == [invitation.email]
assert email.subject == "Invitation to join Impress!"
email_content = " ".join(email.body.split())
assert "Invitation to join Impress!" in email_content
assert "[//example.com]" in email_content
@mock.patch(
"django.core.mail.send_mail",
side_effect=smtplib.SMTPException("Error SMTPException"),
)
@mock.patch.object(Logger, "error")
def test_models_document_invitations_email_failed(mock_logger, _mock_send_mail):
"""Check invitation behavior when an SMTP error occurs during invitation creation."""
member_access = factories.UserDocumentAccessFactory(role="reader")
document = member_access.document
# pylint: disable-next=no-member
assert len(mail.outbox) == 0
factories.UserDocumentAccessFactory(document=document)
# No error should be raised
invitation = factories.InvitationFactory(document=document, email="john@people.com")
# No email has been sent
# pylint: disable-next=no-member
assert len(mail.outbox) == 0
# Logger should be called
mock_logger.assert_called_once()
(
_,
email,
exception,
) = mock_logger.call_args.args
assert email == invitation.email
assert isinstance(exception, smtplib.SMTPException)
# get_abilities
@@ -282,8 +211,8 @@ def test_models_document_invitations_get_abilities_privileged_member(
assert abilities == {
"destroy": True,
"retrieve": True,
"partial_update": False,
"update": False,
"partial_update": True,
"update": True,
}
@@ -1,6 +1,7 @@
"""
Unit tests for the TemplateAccess model
"""
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ValidationError
@@ -318,7 +319,7 @@ def test_models_template_access_get_abilities_for_editor_of_administrator():
def test_models_template_access_get_abilities_for_editor_of_editor_user(
django_assert_num_queries
django_assert_num_queries,
):
"""Check abilities of editor access for the editor of a template."""
access = factories.UserTemplateAccessFactory(role="editor")
@@ -377,7 +378,7 @@ def test_models_template_access_get_abilities_for_reader_of_administrator():
def test_models_template_access_get_abilities_for_reader_of_reader_user(
django_assert_num_queries
django_assert_num_queries,
):
"""Check abilities of reader access for the reader of a template."""
access = factories.UserTemplateAccessFactory(role="reader")
@@ -1,6 +1,10 @@
"""
Unit tests for the Template model
"""
import os
from unittest import mock
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ValidationError
@@ -184,3 +188,30 @@ def test_models_templates_get_abilities_preset_role(django_assert_num_queries):
"partial_update": False,
"generate_document": True,
}
def test_models_templates__generate_word():
"""Generate word document and assert no tmp files are left in /tmp folder."""
template = factories.TemplateFactory()
response = template.generate_word("<p>Test body</p>", {})
assert response.status_code == 200
assert len([f for f in os.listdir("/tmp") if f.startswith("docx_")]) == 0
@mock.patch(
"pypandoc.convert_text",
side_effect=RuntimeError("Conversion failed"),
)
def test_models_templates__generate_word__raise_error(_mock_send_mail):
"""
Generate word document and assert no tmp files are left in /tmp folder
even when the conversion fails.
"""
template = factories.TemplateFactory()
try:
template.generate_word("<p>Test body</p>", {})
except RuntimeError as e:
assert str(e) == "Conversion failed"
assert len([f for f in os.listdir("/tmp") if f.startswith("docx_")]) == 0
@@ -1,6 +1,7 @@
"""
Unit tests for the User model
"""
from unittest import mock
from django.core.exceptions import ValidationError
+87
View File
@@ -0,0 +1,87 @@
"""
Unit tests for the Invitation model
"""
import smtplib
from logging import Logger
from unittest import mock
from django.core import mail
import pytest
from core.utils import email_invitation
pytestmark = pytest.mark.django_db
def test_utils__email_invitation_success():
"""
The email invitation is sent successfully.
"""
# pylint: disable-next=no-member
assert len(mail.outbox) == 0
email_invitation("en", "guest@example.com", "123-456-789")
# pylint: disable-next=no-member
assert len(mail.outbox) == 1
# pylint: disable-next=no-member
email = mail.outbox[0]
assert email.to == ["guest@example.com"]
email_content = " ".join(email.body.split())
assert "Invitation to join Docs!" in email_content
assert "docs/123-456-789/" in email_content
def test_utils__email_invitation_success_fr():
"""
The email invitation is sent successfully in french.
"""
# pylint: disable-next=no-member
assert len(mail.outbox) == 0
email_invitation("fr-fr", "guest@example.com", "123-456-789")
# pylint: disable-next=no-member
assert len(mail.outbox) == 1
# pylint: disable-next=no-member
email = mail.outbox[0]
assert email.to == ["guest@example.com"]
email_content = " ".join(email.body.split())
assert "Invitation à rejoindre Docs !" in email_content
assert "docs/123-456-789/" in email_content
@mock.patch(
"core.utils.send_mail",
side_effect=smtplib.SMTPException("Error SMTPException"),
)
@mock.patch.object(Logger, "error")
def test_utils__email_invitation_failed(mock_logger, _mock_send_mail):
"""Check mail behavior when an SMTP error occurs when sent an email invitation."""
# pylint: disable-next=no-member
assert len(mail.outbox) == 0
email_invitation("en", "guest@example.com", "123-456-789")
# No email has been sent
# pylint: disable-next=no-member
assert len(mail.outbox) == 0
# Logger should be called
mock_logger.assert_called_once()
(
_,
email,
exception,
) = mock_logger.call_args.args
assert email == "guest@example.com"
assert isinstance(exception, smtplib.SMTPException)
+1
View File
@@ -1,4 +1,5 @@
"""URL configuration for the core app."""
from django.conf import settings
from django.urls import include, path, re_path
+40
View File
@@ -0,0 +1,40 @@
"""
Utilities for the core app.
"""
import smtplib
from logging import getLogger
from django.conf import settings
from django.contrib.sites.models import Site
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.utils.translation import gettext_lazy as _
from django.utils.translation import override
logger = getLogger(__name__)
def email_invitation(language, email, document_id):
"""Send email invitation."""
try:
with override(language):
title = _("Invitation to join Docs!")
template_vars = {
"title": title,
"site": Site.objects.get_current(),
"document_id": document_id,
}
msg_html = render_to_string("mail/html/invitation.html", template_vars)
msg_plain = render_to_string("mail/text/invitation.txt", template_vars)
send_mail(
title,
msg_plain,
settings.EMAIL_FROM,
[email],
html_message=msg_html,
fail_silently=False,
)
except smtplib.SMTPException as exception:
logger.error("invitation to %s was not sent: %s", email, exception)
+2 -2
View File
@@ -1,7 +1,7 @@
<page size="A4">
<div class="header">
<img
src="https://upload.wikimedia.org/wikipedia/fr/7/72/Logo_du_Gouvernement_de_la_R%C3%A9publique_fran%C3%A7aise_%282020%29.svg"
<img width="200"
src="https://impress-staging.beta.numerique.gouv.fr/assets/logo-gouv.png"
/>
</div>
<div class="content">
+8 -6
View File
@@ -1,18 +1,20 @@
body {
background: white;
font-family: arial
}
.header {
display: flex;
justify-content: space-between;
font-family: arial;
}
.header img {
width: 5cm;
margin-left: -0.4cm;
}
.body{
margin-top: 1.5rem
margin-top: 1.5rem;
}
img {
max-width: 100%;
}
[custom-style="center"] {
text-align: center;
}
[custom-style="right"] {
text-align: right;
}
+19 -1
View File
@@ -1,5 +1,23 @@
"""Parameters that define how the demo site will be built."""
NB_OBJECTS = {
"users": 100,
"users": 50,
"docs": 50,
"max_users_per_document": 50,
}
DEV_USERS = [
{
"username": "impress",
"email": "impress@impress.world",
},
{
"username": "user-e2e-webkit",
"email": "user@webkit.e2e",
},
{
"username": "user-e2e-firefox",
"email": "user@firefox.e2e",
},
{"username": "user-e2e-chromium", "email": "user@chromium.e2e"},
]
@@ -125,6 +125,65 @@ def create_demo(stdout):
)
queue.flush()
with Timeit(stdout, "Creating documents"):
for _ in range(defaults.NB_OBJECTS["docs"]):
queue.push(
models.Document(
title=fake.sentence(nb_words=4),
is_public=random_true_with_probability(0.5),
)
)
queue.flush()
with Timeit(stdout, "Creating docs accesses"):
docs_ids = list(models.Document.objects.values_list("id", flat=True))
users_ids = list(models.User.objects.values_list("id", flat=True))
for doc_id in docs_ids:
for user_id in random.sample(
users_ids,
random.randint(1, defaults.NB_OBJECTS["max_users_per_document"]),
):
role = random.choice(models.RoleChoices.choices)
queue.push(
models.DocumentAccess(
document_id=doc_id, user_id=user_id, role=role[0]
)
)
queue.flush()
with Timeit(stdout, "Creating development users"):
for dev_user in defaults.DEV_USERS:
queue.push(
models.User(
admin_email=dev_user["email"],
email=dev_user["email"],
sub=dev_user["email"],
password="!",
is_superuser=False,
is_active=True,
is_staff=False,
language=random.choice(settings.LANGUAGES)[0],
)
)
queue.flush()
with Timeit(stdout, "Creating docs accesses on development users"):
for dev_user in defaults.DEV_USERS:
docs_ids = list(models.Document.objects.values_list("id", flat=True))
user_id = models.User.objects.get(email=dev_user["email"]).id
for doc_id in docs_ids:
role = random.choice(models.RoleChoices.choices)
queue.push(
models.DocumentAccess(
document_id=doc_id, user_id=user_id, role=role[0]
)
)
queue.flush()
with Timeit(stdout, "Creating Template"):
with open(
file="demo/data/template/code.txt", mode="r", encoding="utf-8"
@@ -1,4 +1,5 @@
"""Management user to create a superuser."""
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand
@@ -16,3 +16,16 @@ def test_commands_create_demo():
call_command("create_demo")
assert models.Template.objects.count() == 1
assert models.User.objects.count() >= 50
assert models.Document.objects.count() >= 50
assert models.DocumentAccess.objects.count() > 50
# assert dev users have doc accesses
user = models.User.objects.get(email="impress@impress.world")
assert models.DocumentAccess.objects.filter(user=user).exists()
user = models.User.objects.get(email="user@webkit.e2e")
assert models.DocumentAccess.objects.filter(user=user).exists()
user = models.User.objects.get(email="user@firefox.e2e")
assert models.DocumentAccess.objects.filter(user=user).exists()
user = models.User.objects.get(email="user@chromium.e2e")
assert models.DocumentAccess.objects.filter(user=user).exists()
+1
View File
@@ -1,4 +1,5 @@
"""Impress celery configuration file."""
import os
from celery import Celery
+2
View File
@@ -9,6 +9,7 @@ https://docs.djangoproject.com/en/3.1/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/3.1/ref/settings/
"""
import json
import os
@@ -278,6 +279,7 @@ class Base(Configuration):
EMAIL_HOST_PASSWORD = values.Value(None)
EMAIL_PORT = values.PositiveIntegerValue(None)
EMAIL_USE_TLS = values.BooleanValue(False)
EMAIL_USE_SSL = values.BooleanValue(False)
EMAIL_FROM = values.Value("from@example.com")
AUTH_USER_MODEL = "core.User"
Binary file not shown.
+437 -82
View File
@@ -1,208 +1,563 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Project-Id-Version: lasuite-people\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-04-03 10:31+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"POT-Creation-Date: 2024-08-14 12:43+0000\n"
"PO-Revision-Date: 2024-08-14 12:48\n"
"Last-Translator: \n"
"Language-Team: English\n"
"Language: en_US\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Crowdin-Project: lasuite-people\n"
"X-Crowdin-Project-ID: 637934\n"
"X-Crowdin-Language: en\n"
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 8\n"
#: build/lib/build/lib/build/lib/build/lib/core/admin.py:31
#: build/lib/build/lib/build/lib/core/admin.py:31
#: build/lib/build/lib/core/admin.py:31 build/lib/core/admin.py:31
#: core/admin.py:31
msgid "Personal info"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/admin.py:33
#: build/lib/build/lib/build/lib/core/admin.py:33
#: build/lib/build/lib/core/admin.py:33 build/lib/core/admin.py:33
#: core/admin.py:33
msgid "Permissions"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/admin.py:45
#: build/lib/build/lib/build/lib/core/admin.py:45
#: build/lib/build/lib/core/admin.py:45 build/lib/core/admin.py:45
#: core/admin.py:45
msgid "Important dates"
msgstr ""
#: core/api/serializers.py:128
msgid "Markdown Body"
#: build/lib/build/lib/build/lib/build/lib/core/api/serializers.py:176
#: build/lib/build/lib/build/lib/core/api/serializers.py:176
#: build/lib/build/lib/core/api/serializers.py:176
#: build/lib/core/api/serializers.py:176 core/api/serializers.py:176
msgid "Body"
msgstr ""
#: core/authentication.py:71
#: build/lib/build/lib/build/lib/build/lib/core/api/serializers.py:179
#: build/lib/build/lib/build/lib/core/api/serializers.py:179
#: build/lib/build/lib/core/api/serializers.py:179
#: build/lib/core/api/serializers.py:179 core/api/serializers.py:179
msgid "Body type"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/authentication/backends.py:71
#: build/lib/build/lib/build/lib/core/authentication/backends.py:71
#: build/lib/build/lib/core/authentication/backends.py:71
#: build/lib/core/authentication/backends.py:71
#: core/authentication/backends.py:71
msgid "User info contained no recognizable user identification"
msgstr ""
#: core/authentication.py:91
#: build/lib/build/lib/build/lib/build/lib/core/authentication/backends.py:91
#: build/lib/build/lib/build/lib/core/authentication/backends.py:91
#: build/lib/build/lib/core/authentication/backends.py:91
#: build/lib/core/authentication/backends.py:91
#: core/authentication/backends.py:91
msgid "Claims contained no recognizable user identification"
msgstr ""
#: core/models.py:27
msgid "Member"
#: build/lib/build/lib/build/lib/build/lib/core/models.py:60
#: build/lib/build/lib/build/lib/core/models.py:60
#: build/lib/build/lib/core/models.py:60 build/lib/core/models.py:60
#: core/models.py:61
msgid "Reader"
msgstr ""
#: core/models.py:28
#: build/lib/build/lib/build/lib/build/lib/core/models.py:61
#: build/lib/build/lib/build/lib/core/models.py:61
#: build/lib/build/lib/core/models.py:61 build/lib/core/models.py:61
#: core/models.py:62
msgid "Editor"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:62
#: build/lib/build/lib/build/lib/core/models.py:62
#: build/lib/build/lib/core/models.py:62 build/lib/core/models.py:62
#: core/models.py:63
msgid "Administrator"
msgstr ""
#: core/models.py:29
#: build/lib/build/lib/build/lib/build/lib/core/models.py:63
#: build/lib/build/lib/build/lib/core/models.py:63
#: build/lib/build/lib/core/models.py:63 build/lib/core/models.py:63
#: core/models.py:64
msgid "Owner"
msgstr ""
#: core/models.py:41
#: build/lib/build/lib/build/lib/build/lib/core/models.py:75
#: build/lib/build/lib/build/lib/core/models.py:75
#: build/lib/build/lib/core/models.py:75 build/lib/core/models.py:75
#: core/models.py:76
msgid "id"
msgstr ""
#: core/models.py:42
#: build/lib/build/lib/build/lib/build/lib/core/models.py:76
#: build/lib/build/lib/build/lib/core/models.py:76
#: build/lib/build/lib/core/models.py:76 build/lib/core/models.py:76
#: core/models.py:77
msgid "primary key for the record as UUID"
msgstr ""
#: core/models.py:48
#: build/lib/build/lib/build/lib/build/lib/core/models.py:82
#: build/lib/build/lib/build/lib/core/models.py:82
#: build/lib/build/lib/core/models.py:82 build/lib/core/models.py:82
#: core/models.py:83
msgid "created on"
msgstr ""
#: core/models.py:49
#: build/lib/build/lib/build/lib/build/lib/core/models.py:83
#: build/lib/build/lib/build/lib/core/models.py:83
#: build/lib/build/lib/core/models.py:83 build/lib/core/models.py:83
#: core/models.py:84
msgid "date and time at which a record was created"
msgstr ""
#: core/models.py:54
#: build/lib/build/lib/build/lib/build/lib/core/models.py:88
#: build/lib/build/lib/build/lib/core/models.py:88
#: build/lib/build/lib/core/models.py:88 build/lib/core/models.py:88
#: core/models.py:89
msgid "updated on"
msgstr ""
#: core/models.py:55
#: build/lib/build/lib/build/lib/build/lib/core/models.py:89
#: build/lib/build/lib/build/lib/core/models.py:89
#: build/lib/build/lib/core/models.py:89 build/lib/core/models.py:89
#: core/models.py:90
msgid "date and time at which a record was last updated"
msgstr ""
#: core/models.py:75
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
#: build/lib/build/lib/build/lib/build/lib/core/models.py:109
#: build/lib/build/lib/build/lib/core/models.py:109
#: build/lib/build/lib/core/models.py:109 build/lib/core/models.py:109
#: core/models.py:110
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters."
msgstr ""
#: core/models.py:81
#: build/lib/build/lib/build/lib/build/lib/core/models.py:115
#: build/lib/build/lib/build/lib/core/models.py:115
#: build/lib/build/lib/core/models.py:115 build/lib/core/models.py:115
#: core/models.py:116
msgid "sub"
msgstr ""
#: core/models.py:83
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
#: build/lib/build/lib/build/lib/build/lib/core/models.py:117
#: build/lib/build/lib/build/lib/core/models.py:117
#: build/lib/build/lib/core/models.py:117 build/lib/core/models.py:117
#: core/models.py:118
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
msgstr ""
#: core/models.py:91
#: build/lib/build/lib/build/lib/build/lib/core/models.py:125
#: build/lib/build/lib/build/lib/core/models.py:125
#: build/lib/build/lib/core/models.py:125 build/lib/core/models.py:125
#: core/models.py:126
msgid "identity email address"
msgstr ""
#: core/models.py:96
#: build/lib/build/lib/build/lib/build/lib/core/models.py:130
#: build/lib/build/lib/build/lib/core/models.py:130
#: build/lib/build/lib/core/models.py:130 build/lib/core/models.py:130
#: core/models.py:131
msgid "admin email address"
msgstr ""
#: core/models.py:103
#: build/lib/build/lib/build/lib/build/lib/core/models.py:137
#: build/lib/build/lib/build/lib/core/models.py:137
#: build/lib/build/lib/core/models.py:137 build/lib/core/models.py:137
#: core/models.py:138
msgid "language"
msgstr ""
#: core/models.py:104
#: build/lib/build/lib/build/lib/build/lib/core/models.py:138
#: build/lib/build/lib/build/lib/core/models.py:138
#: build/lib/build/lib/core/models.py:138 build/lib/core/models.py:138
#: core/models.py:139
msgid "The language in which the user wants to see the interface."
msgstr ""
#: core/models.py:110
#: build/lib/build/lib/build/lib/build/lib/core/models.py:144
#: build/lib/build/lib/build/lib/core/models.py:144
#: build/lib/build/lib/core/models.py:144 build/lib/core/models.py:144
#: core/models.py:145
msgid "The timezone in which the user wants to see times."
msgstr ""
#: core/models.py:113
#: build/lib/build/lib/build/lib/build/lib/core/models.py:147
#: build/lib/build/lib/build/lib/core/models.py:147
#: build/lib/build/lib/core/models.py:147 build/lib/core/models.py:147
#: core/models.py:148
msgid "device"
msgstr ""
#: core/models.py:115
#: build/lib/build/lib/build/lib/build/lib/core/models.py:149
#: build/lib/build/lib/build/lib/core/models.py:149
#: build/lib/build/lib/core/models.py:149 build/lib/core/models.py:149
#: core/models.py:150
msgid "Whether the user is a device or a real user."
msgstr ""
#: core/models.py:118
#: build/lib/build/lib/build/lib/build/lib/core/models.py:152
#: build/lib/build/lib/build/lib/core/models.py:152
#: build/lib/build/lib/core/models.py:152 build/lib/core/models.py:152
#: core/models.py:153
msgid "staff status"
msgstr ""
#: core/models.py:120
#: build/lib/build/lib/build/lib/build/lib/core/models.py:154
#: build/lib/build/lib/build/lib/core/models.py:154
#: build/lib/build/lib/core/models.py:154 build/lib/core/models.py:154
#: core/models.py:155
msgid "Whether the user can log into this admin site."
msgstr ""
#: core/models.py:123
#: build/lib/build/lib/build/lib/build/lib/core/models.py:157
#: build/lib/build/lib/build/lib/core/models.py:157
#: build/lib/build/lib/core/models.py:157 build/lib/core/models.py:157
#: core/models.py:158
msgid "active"
msgstr ""
#: core/models.py:126
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
#: build/lib/build/lib/build/lib/build/lib/core/models.py:160
#: build/lib/build/lib/build/lib/core/models.py:160
#: build/lib/build/lib/core/models.py:160 build/lib/core/models.py:160
#: core/models.py:161
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: core/models.py:138
#: build/lib/build/lib/build/lib/build/lib/core/models.py:172
#: build/lib/build/lib/build/lib/core/models.py:172
#: build/lib/build/lib/core/models.py:172 build/lib/core/models.py:172
#: core/models.py:173
msgid "user"
msgstr ""
#: core/models.py:139
#: build/lib/build/lib/build/lib/build/lib/core/models.py:173
#: build/lib/build/lib/build/lib/core/models.py:173
#: build/lib/build/lib/core/models.py:173 build/lib/core/models.py:173
#: core/models.py:174
msgid "users"
msgstr ""
#: core/models.py:161
#: build/lib/build/lib/build/lib/build/lib/core/models.py:304
#: build/lib/build/lib/build/lib/build/lib/core/models.py:531
#: build/lib/build/lib/build/lib/core/models.py:304
#: build/lib/build/lib/build/lib/core/models.py:531
#: build/lib/build/lib/core/models.py:304
#: build/lib/build/lib/core/models.py:531 build/lib/core/models.py:304
#: build/lib/core/models.py:531 core/models.py:305 core/models.py:532
msgid "title"
msgstr ""
#: core/models.py:162
msgid "description"
msgstr ""
#: core/models.py:163
msgid "code"
msgstr ""
#: core/models.py:164
msgid "css"
msgstr ""
#: core/models.py:166
#: build/lib/build/lib/build/lib/build/lib/core/models.py:306
#: build/lib/build/lib/build/lib/build/lib/core/models.py:536
#: build/lib/build/lib/build/lib/core/models.py:306
#: build/lib/build/lib/build/lib/core/models.py:536
#: build/lib/build/lib/core/models.py:306
#: build/lib/build/lib/core/models.py:536 build/lib/core/models.py:306
#: build/lib/core/models.py:536 core/models.py:307 core/models.py:537
msgid "public"
msgstr ""
#: core/models.py:168
msgid "Whether this template is public for anyone to use."
#: build/lib/build/lib/build/lib/build/lib/core/models.py:308
#: build/lib/build/lib/build/lib/core/models.py:308
#: build/lib/build/lib/core/models.py:308 build/lib/core/models.py:308
#: core/models.py:309
msgid "Whether this document is public for anyone to use."
msgstr ""
#: core/models.py:174
msgid "Template"
#: build/lib/build/lib/build/lib/build/lib/core/models.py:316
#: build/lib/build/lib/build/lib/core/models.py:316
#: build/lib/build/lib/core/models.py:316 build/lib/core/models.py:316
#: core/models.py:317
msgid "Document"
msgstr ""
#: core/models.py:175
msgid "Templates"
#: build/lib/build/lib/build/lib/build/lib/core/models.py:317
#: build/lib/build/lib/build/lib/core/models.py:317
#: build/lib/build/lib/core/models.py:317 build/lib/core/models.py:317
#: core/models.py:318
msgid "Documents"
msgstr ""
#: core/models.py:256
msgid "Template/user relation"
#: build/lib/build/lib/build/lib/build/lib/core/models.py:495
#: build/lib/build/lib/build/lib/core/models.py:495
#: build/lib/build/lib/core/models.py:495 build/lib/core/models.py:495
#: core/models.py:496
msgid "Document/user relation"
msgstr ""
#: core/models.py:257
msgid "Template/user relations"
#: build/lib/build/lib/build/lib/build/lib/core/models.py:496
#: build/lib/build/lib/build/lib/core/models.py:496
#: build/lib/build/lib/core/models.py:496 build/lib/core/models.py:496
#: core/models.py:497
msgid "Document/user relations"
msgstr ""
#: core/models.py:263
msgid "This user is already in this template."
#: build/lib/build/lib/build/lib/build/lib/core/models.py:502
#: build/lib/build/lib/build/lib/core/models.py:502
#: build/lib/build/lib/core/models.py:502 build/lib/core/models.py:502
#: core/models.py:503
msgid "This user is already in this document."
msgstr ""
#: core/models.py:269
msgid "This team is already in this template."
#: build/lib/build/lib/build/lib/build/lib/core/models.py:508
#: build/lib/build/lib/build/lib/core/models.py:508
#: build/lib/build/lib/core/models.py:508 build/lib/core/models.py:508
#: core/models.py:509
msgid "This team is already in this document."
msgstr ""
#: core/models.py:275
#: build/lib/build/lib/build/lib/build/lib/core/models.py:514
#: build/lib/build/lib/build/lib/build/lib/core/models.py:691
#: build/lib/build/lib/build/lib/core/models.py:514
#: build/lib/build/lib/build/lib/core/models.py:691
#: build/lib/build/lib/core/models.py:514
#: build/lib/build/lib/core/models.py:691 build/lib/core/models.py:514
#: build/lib/core/models.py:691 core/models.py:515 core/models.py:704
msgid "Either user or team must be set, not both."
msgstr ""
#: impress/settings.py:134
#: build/lib/build/lib/build/lib/build/lib/core/models.py:532
#: build/lib/build/lib/build/lib/core/models.py:532
#: build/lib/build/lib/core/models.py:532 build/lib/core/models.py:532
#: core/models.py:533
msgid "description"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:533
#: build/lib/build/lib/build/lib/core/models.py:533
#: build/lib/build/lib/core/models.py:533 build/lib/core/models.py:533
#: core/models.py:534
msgid "code"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:534
#: build/lib/build/lib/build/lib/core/models.py:534
#: build/lib/build/lib/core/models.py:534 build/lib/core/models.py:534
#: core/models.py:535
msgid "css"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:538
#: build/lib/build/lib/build/lib/core/models.py:538
#: build/lib/build/lib/core/models.py:538 build/lib/core/models.py:538
#: core/models.py:539
msgid "Whether this template is public for anyone to use."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:544
#: build/lib/build/lib/build/lib/core/models.py:544
#: build/lib/build/lib/core/models.py:544 build/lib/core/models.py:544
#: core/models.py:545
msgid "Template"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:545
#: build/lib/build/lib/build/lib/core/models.py:545
#: build/lib/build/lib/core/models.py:545 build/lib/core/models.py:545
#: core/models.py:546
msgid "Templates"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:672
#: build/lib/build/lib/build/lib/core/models.py:672
#: build/lib/build/lib/core/models.py:672 build/lib/core/models.py:672
#: core/models.py:685
msgid "Template/user relation"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:673
#: build/lib/build/lib/build/lib/core/models.py:673
#: build/lib/build/lib/core/models.py:673 build/lib/core/models.py:673
#: core/models.py:686
msgid "Template/user relations"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:679
#: build/lib/build/lib/build/lib/core/models.py:679
#: build/lib/build/lib/core/models.py:679 build/lib/core/models.py:679
#: core/models.py:692
msgid "This user is already in this template."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:685
#: build/lib/build/lib/build/lib/core/models.py:685
#: build/lib/build/lib/core/models.py:685 build/lib/core/models.py:685
#: core/models.py:698
msgid "This team is already in this template."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:708
#: build/lib/build/lib/build/lib/core/models.py:708
#: build/lib/build/lib/core/models.py:708 build/lib/core/models.py:708
#: core/models.py:721
msgid "email address"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:725
#: build/lib/build/lib/build/lib/core/models.py:725
#: build/lib/build/lib/core/models.py:725 build/lib/core/models.py:725
#: core/models.py:738
msgid "Document invitation"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:726
#: build/lib/build/lib/build/lib/core/models.py:726
#: build/lib/build/lib/core/models.py:726 build/lib/core/models.py:726
#: core/models.py:739
msgid "Document invitations"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:751
#: build/lib/build/lib/build/lib/core/models.py:751
#: build/lib/build/lib/core/models.py:751 build/lib/core/models.py:751
#: core/models.py:764
msgid "This email is already associated to a registered user."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:795
#: build/lib/build/lib/build/lib/core/models.py:795
#: build/lib/build/lib/core/models.py:795 build/lib/core/models.py:795
msgid "Invitation to join Impress!"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/impress/settings.py:158
#: build/lib/build/lib/build/lib/impress/settings.py:158
#: build/lib/build/lib/impress/settings.py:158
#: build/lib/impress/settings.py:158 impress/settings.py:158
msgid "English"
msgstr ""
#: impress/settings.py:135
#: build/lib/build/lib/build/lib/build/lib/impress/settings.py:159
#: build/lib/build/lib/build/lib/impress/settings.py:159
#: build/lib/build/lib/impress/settings.py:159
#: build/lib/impress/settings.py:159 impress/settings.py:159
msgid "French"
msgstr ""
#: build/lib/build/lib/core/api/serializers.py:185
#: build/lib/core/api/serializers.py:185 core/api/serializers.py:185
msgid "Format"
msgstr ""
#: core/models.py:808
msgid "Invitation to join Docs!"
msgstr ""
#: core/templates/mail/html/hello.html:159 core/templates/mail/text/hello.txt:3
msgid "Company logo"
msgstr ""
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
#, python-format
msgid "Hello %(name)s"
msgstr ""
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
msgid "Hello"
msgstr ""
#: core/templates/mail/html/hello.html:189 core/templates/mail/text/hello.txt:6
msgid "Thank you very much for your visit!"
msgstr ""
#: core/templates/mail/html/hello.html:221
#, python-format
msgid "This mail has been sent to %(email)s by <a href=\"%(href)s\">%(name)s</a>"
msgstr ""
#: core/templates/mail/html/invitation.html:160
#: core/templates/mail/text/invitation.txt:3
msgid "La Suite Numérique"
msgstr ""
#: core/templates/mail/html/invitation.html:190
#: core/templates/mail/text/invitation.txt:5
msgid "Invitation to join a document !"
msgstr ""
#: core/templates/mail/html/invitation.html:198
msgid "Welcome to <strong>Docs!</strong>"
msgstr ""
#: core/templates/mail/html/invitation.html:213
#: core/templates/mail/text/invitation.txt:12
msgid "We are delighted to welcome you to our community on Docs, your new companion to collaborate on documents efficiently, intuitively, and securely."
msgstr ""
#: core/templates/mail/html/invitation.html:218
#: core/templates/mail/text/invitation.txt:13
msgid "Our application is designed to help you organize, collaborate, and manage permissions."
msgstr ""
#: core/templates/mail/html/invitation.html:223
#: core/templates/mail/text/invitation.txt:14
msgid "With Docs, you will be able to:"
msgstr ""
#: core/templates/mail/html/invitation.html:224
#: core/templates/mail/text/invitation.txt:15
msgid "Create documents."
msgstr ""
#: core/templates/mail/html/invitation.html:225
#: core/templates/mail/text/invitation.txt:16
msgid "Work offline."
msgstr ""
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:17
msgid "Invite members of your community to your document in just a few clicks."
msgstr ""
#: core/templates/mail/html/invitation.html:237
#: core/templates/mail/text/invitation.txt:19
msgid "Visit Docs"
msgstr ""
#: core/templates/mail/html/invitation.html:246
#: core/templates/mail/text/invitation.txt:21
msgid "We are confident that Docs will help you increase efficiency and productivity while strengthening the bond among members."
msgstr ""
#: core/templates/mail/html/invitation.html:251
#: core/templates/mail/text/invitation.txt:22
msgid "Feel free to explore all the features of the application and share your feedback and suggestions with us. Your feedback is valuable to us and will enable us to continually improve our service."
msgstr ""
#: core/templates/mail/html/invitation.html:256
#: core/templates/mail/text/invitation.txt:23
msgid "Once again, welcome aboard! We are eager to accompany you on your collaboration adventure."
msgstr ""
#: core/templates/mail/html/invitation.html:263
#: core/templates/mail/text/invitation.txt:25
msgid "Sincerely,"
msgstr ""
#: core/templates/mail/html/invitation.html:264
#: core/templates/mail/text/invitation.txt:27
msgid "The La Suite Numérique Team"
msgstr ""
#: core/templates/mail/text/hello.txt:8
#, python-format
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
msgstr ""
#: core/templates/mail/text/invitation.txt:8
msgid "Welcome to Docs!"
msgstr ""
Binary file not shown.
+439 -84
View File
@@ -1,208 +1,563 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Project-Id-Version: lasuite-people\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-04-03 10:31+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"POT-Creation-Date: 2024-08-14 12:43+0000\n"
"PO-Revision-Date: 2024-08-14 12:48\n"
"Last-Translator: \n"
"Language-Team: French\n"
"Language: fr_FR\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
"X-Crowdin-Project: lasuite-people\n"
"X-Crowdin-Project-ID: 637934\n"
"X-Crowdin-Language: fr\n"
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 8\n"
#: build/lib/build/lib/build/lib/build/lib/core/admin.py:31
#: build/lib/build/lib/build/lib/core/admin.py:31
#: build/lib/build/lib/core/admin.py:31 build/lib/core/admin.py:31
#: core/admin.py:31
msgid "Personal info"
msgstr ""
msgstr "Infos Personnelles"
#: build/lib/build/lib/build/lib/build/lib/core/admin.py:33
#: build/lib/build/lib/build/lib/core/admin.py:33
#: build/lib/build/lib/core/admin.py:33 build/lib/core/admin.py:33
#: core/admin.py:33
msgid "Permissions"
msgstr ""
msgstr "Permissions"
#: build/lib/build/lib/build/lib/build/lib/core/admin.py:45
#: build/lib/build/lib/build/lib/core/admin.py:45
#: build/lib/build/lib/core/admin.py:45 build/lib/core/admin.py:45
#: core/admin.py:45
msgid "Important dates"
msgstr "Dates importantes"
#: build/lib/build/lib/build/lib/build/lib/core/api/serializers.py:176
#: build/lib/build/lib/build/lib/core/api/serializers.py:176
#: build/lib/build/lib/core/api/serializers.py:176
#: build/lib/core/api/serializers.py:176 core/api/serializers.py:176
msgid "Body"
msgstr ""
#: core/api/serializers.py:128
msgid "Markdown Body"
#: build/lib/build/lib/build/lib/build/lib/core/api/serializers.py:179
#: build/lib/build/lib/build/lib/core/api/serializers.py:179
#: build/lib/build/lib/core/api/serializers.py:179
#: build/lib/core/api/serializers.py:179 core/api/serializers.py:179
msgid "Body type"
msgstr ""
#: core/authentication.py:71
#: build/lib/build/lib/build/lib/build/lib/core/authentication/backends.py:71
#: build/lib/build/lib/build/lib/core/authentication/backends.py:71
#: build/lib/build/lib/core/authentication/backends.py:71
#: build/lib/core/authentication/backends.py:71
#: core/authentication/backends.py:71
msgid "User info contained no recognizable user identification"
msgstr ""
#: core/authentication.py:91
#: build/lib/build/lib/build/lib/build/lib/core/authentication/backends.py:91
#: build/lib/build/lib/build/lib/core/authentication/backends.py:91
#: build/lib/build/lib/core/authentication/backends.py:91
#: build/lib/core/authentication/backends.py:91
#: core/authentication/backends.py:91
msgid "Claims contained no recognizable user identification"
msgstr ""
#: core/models.py:27
msgid "Member"
#: build/lib/build/lib/build/lib/build/lib/core/models.py:60
#: build/lib/build/lib/build/lib/core/models.py:60
#: build/lib/build/lib/core/models.py:60 build/lib/core/models.py:60
#: core/models.py:61
msgid "Reader"
msgstr ""
#: core/models.py:28
#: build/lib/build/lib/build/lib/build/lib/core/models.py:61
#: build/lib/build/lib/build/lib/core/models.py:61
#: build/lib/build/lib/core/models.py:61 build/lib/core/models.py:61
#: core/models.py:62
msgid "Editor"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:62
#: build/lib/build/lib/build/lib/core/models.py:62
#: build/lib/build/lib/core/models.py:62 build/lib/core/models.py:62
#: core/models.py:63
msgid "Administrator"
msgstr ""
#: core/models.py:29
#: build/lib/build/lib/build/lib/build/lib/core/models.py:63
#: build/lib/build/lib/build/lib/core/models.py:63
#: build/lib/build/lib/core/models.py:63 build/lib/core/models.py:63
#: core/models.py:64
msgid "Owner"
msgstr ""
#: core/models.py:41
#: build/lib/build/lib/build/lib/build/lib/core/models.py:75
#: build/lib/build/lib/build/lib/core/models.py:75
#: build/lib/build/lib/core/models.py:75 build/lib/core/models.py:75
#: core/models.py:76
msgid "id"
msgstr ""
#: core/models.py:42
#: build/lib/build/lib/build/lib/build/lib/core/models.py:76
#: build/lib/build/lib/build/lib/core/models.py:76
#: build/lib/build/lib/core/models.py:76 build/lib/core/models.py:76
#: core/models.py:77
msgid "primary key for the record as UUID"
msgstr ""
#: core/models.py:48
#: build/lib/build/lib/build/lib/build/lib/core/models.py:82
#: build/lib/build/lib/build/lib/core/models.py:82
#: build/lib/build/lib/core/models.py:82 build/lib/core/models.py:82
#: core/models.py:83
msgid "created on"
msgstr ""
#: core/models.py:49
#: build/lib/build/lib/build/lib/build/lib/core/models.py:83
#: build/lib/build/lib/build/lib/core/models.py:83
#: build/lib/build/lib/core/models.py:83 build/lib/core/models.py:83
#: core/models.py:84
msgid "date and time at which a record was created"
msgstr ""
#: core/models.py:54
#: build/lib/build/lib/build/lib/build/lib/core/models.py:88
#: build/lib/build/lib/build/lib/core/models.py:88
#: build/lib/build/lib/core/models.py:88 build/lib/core/models.py:88
#: core/models.py:89
msgid "updated on"
msgstr ""
#: core/models.py:55
#: build/lib/build/lib/build/lib/build/lib/core/models.py:89
#: build/lib/build/lib/build/lib/core/models.py:89
#: build/lib/build/lib/core/models.py:89 build/lib/core/models.py:89
#: core/models.py:90
msgid "date and time at which a record was last updated"
msgstr ""
#: core/models.py:75
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
#: build/lib/build/lib/build/lib/build/lib/core/models.py:109
#: build/lib/build/lib/build/lib/core/models.py:109
#: build/lib/build/lib/core/models.py:109 build/lib/core/models.py:109
#: core/models.py:110
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters."
msgstr ""
#: core/models.py:81
#: build/lib/build/lib/build/lib/build/lib/core/models.py:115
#: build/lib/build/lib/build/lib/core/models.py:115
#: build/lib/build/lib/core/models.py:115 build/lib/core/models.py:115
#: core/models.py:116
msgid "sub"
msgstr ""
#: core/models.py:83
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
#: build/lib/build/lib/build/lib/build/lib/core/models.py:117
#: build/lib/build/lib/build/lib/core/models.py:117
#: build/lib/build/lib/core/models.py:117 build/lib/core/models.py:117
#: core/models.py:118
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
msgstr ""
#: core/models.py:91
#: build/lib/build/lib/build/lib/build/lib/core/models.py:125
#: build/lib/build/lib/build/lib/core/models.py:125
#: build/lib/build/lib/core/models.py:125 build/lib/core/models.py:125
#: core/models.py:126
msgid "identity email address"
msgstr ""
#: core/models.py:96
#: build/lib/build/lib/build/lib/build/lib/core/models.py:130
#: build/lib/build/lib/build/lib/core/models.py:130
#: build/lib/build/lib/core/models.py:130 build/lib/core/models.py:130
#: core/models.py:131
msgid "admin email address"
msgstr ""
#: core/models.py:103
#: build/lib/build/lib/build/lib/build/lib/core/models.py:137
#: build/lib/build/lib/build/lib/core/models.py:137
#: build/lib/build/lib/core/models.py:137 build/lib/core/models.py:137
#: core/models.py:138
msgid "language"
msgstr ""
#: core/models.py:104
#: build/lib/build/lib/build/lib/build/lib/core/models.py:138
#: build/lib/build/lib/build/lib/core/models.py:138
#: build/lib/build/lib/core/models.py:138 build/lib/core/models.py:138
#: core/models.py:139
msgid "The language in which the user wants to see the interface."
msgstr ""
#: core/models.py:110
#: build/lib/build/lib/build/lib/build/lib/core/models.py:144
#: build/lib/build/lib/build/lib/core/models.py:144
#: build/lib/build/lib/core/models.py:144 build/lib/core/models.py:144
#: core/models.py:145
msgid "The timezone in which the user wants to see times."
msgstr ""
#: core/models.py:113
#: build/lib/build/lib/build/lib/build/lib/core/models.py:147
#: build/lib/build/lib/build/lib/core/models.py:147
#: build/lib/build/lib/core/models.py:147 build/lib/core/models.py:147
#: core/models.py:148
msgid "device"
msgstr ""
#: core/models.py:115
#: build/lib/build/lib/build/lib/build/lib/core/models.py:149
#: build/lib/build/lib/build/lib/core/models.py:149
#: build/lib/build/lib/core/models.py:149 build/lib/core/models.py:149
#: core/models.py:150
msgid "Whether the user is a device or a real user."
msgstr ""
#: core/models.py:118
#: build/lib/build/lib/build/lib/build/lib/core/models.py:152
#: build/lib/build/lib/build/lib/core/models.py:152
#: build/lib/build/lib/core/models.py:152 build/lib/core/models.py:152
#: core/models.py:153
msgid "staff status"
msgstr ""
#: core/models.py:120
#: build/lib/build/lib/build/lib/build/lib/core/models.py:154
#: build/lib/build/lib/build/lib/core/models.py:154
#: build/lib/build/lib/core/models.py:154 build/lib/core/models.py:154
#: core/models.py:155
msgid "Whether the user can log into this admin site."
msgstr ""
#: core/models.py:123
#: build/lib/build/lib/build/lib/build/lib/core/models.py:157
#: build/lib/build/lib/build/lib/core/models.py:157
#: build/lib/build/lib/core/models.py:157 build/lib/core/models.py:157
#: core/models.py:158
msgid "active"
msgstr ""
#: core/models.py:126
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
#: build/lib/build/lib/build/lib/build/lib/core/models.py:160
#: build/lib/build/lib/build/lib/core/models.py:160
#: build/lib/build/lib/core/models.py:160 build/lib/core/models.py:160
#: core/models.py:161
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: core/models.py:138
#: build/lib/build/lib/build/lib/build/lib/core/models.py:172
#: build/lib/build/lib/build/lib/core/models.py:172
#: build/lib/build/lib/core/models.py:172 build/lib/core/models.py:172
#: core/models.py:173
msgid "user"
msgstr ""
#: core/models.py:139
#: build/lib/build/lib/build/lib/build/lib/core/models.py:173
#: build/lib/build/lib/build/lib/core/models.py:173
#: build/lib/build/lib/core/models.py:173 build/lib/core/models.py:173
#: core/models.py:174
msgid "users"
msgstr ""
#: core/models.py:161
#: build/lib/build/lib/build/lib/build/lib/core/models.py:304
#: build/lib/build/lib/build/lib/build/lib/core/models.py:531
#: build/lib/build/lib/build/lib/core/models.py:304
#: build/lib/build/lib/build/lib/core/models.py:531
#: build/lib/build/lib/core/models.py:304
#: build/lib/build/lib/core/models.py:531 build/lib/core/models.py:304
#: build/lib/core/models.py:531 core/models.py:305 core/models.py:532
msgid "title"
msgstr ""
#: core/models.py:162
msgid "description"
msgstr ""
#: core/models.py:163
msgid "code"
msgstr ""
#: core/models.py:164
msgid "css"
msgstr ""
#: core/models.py:166
#: build/lib/build/lib/build/lib/build/lib/core/models.py:306
#: build/lib/build/lib/build/lib/build/lib/core/models.py:536
#: build/lib/build/lib/build/lib/core/models.py:306
#: build/lib/build/lib/build/lib/core/models.py:536
#: build/lib/build/lib/core/models.py:306
#: build/lib/build/lib/core/models.py:536 build/lib/core/models.py:306
#: build/lib/core/models.py:536 core/models.py:307 core/models.py:537
msgid "public"
msgstr ""
#: core/models.py:168
msgid "Whether this template is public for anyone to use."
#: build/lib/build/lib/build/lib/build/lib/core/models.py:308
#: build/lib/build/lib/build/lib/core/models.py:308
#: build/lib/build/lib/core/models.py:308 build/lib/core/models.py:308
#: core/models.py:309
msgid "Whether this document is public for anyone to use."
msgstr ""
#: core/models.py:174
msgid "Template"
#: build/lib/build/lib/build/lib/build/lib/core/models.py:316
#: build/lib/build/lib/build/lib/core/models.py:316
#: build/lib/build/lib/core/models.py:316 build/lib/core/models.py:316
#: core/models.py:317
msgid "Document"
msgstr ""
#: core/models.py:175
msgid "Templates"
#: build/lib/build/lib/build/lib/build/lib/core/models.py:317
#: build/lib/build/lib/build/lib/core/models.py:317
#: build/lib/build/lib/core/models.py:317 build/lib/core/models.py:317
#: core/models.py:318
msgid "Documents"
msgstr ""
#: core/models.py:256
msgid "Template/user relation"
#: build/lib/build/lib/build/lib/build/lib/core/models.py:495
#: build/lib/build/lib/build/lib/core/models.py:495
#: build/lib/build/lib/core/models.py:495 build/lib/core/models.py:495
#: core/models.py:496
msgid "Document/user relation"
msgstr ""
#: core/models.py:257
msgid "Template/user relations"
#: build/lib/build/lib/build/lib/build/lib/core/models.py:496
#: build/lib/build/lib/build/lib/core/models.py:496
#: build/lib/build/lib/core/models.py:496 build/lib/core/models.py:496
#: core/models.py:497
msgid "Document/user relations"
msgstr ""
#: core/models.py:263
msgid "This user is already in this template."
#: build/lib/build/lib/build/lib/build/lib/core/models.py:502
#: build/lib/build/lib/build/lib/core/models.py:502
#: build/lib/build/lib/core/models.py:502 build/lib/core/models.py:502
#: core/models.py:503
msgid "This user is already in this document."
msgstr ""
#: core/models.py:269
msgid "This team is already in this template."
#: build/lib/build/lib/build/lib/build/lib/core/models.py:508
#: build/lib/build/lib/build/lib/core/models.py:508
#: build/lib/build/lib/core/models.py:508 build/lib/core/models.py:508
#: core/models.py:509
msgid "This team is already in this document."
msgstr ""
#: core/models.py:275
#: build/lib/build/lib/build/lib/build/lib/core/models.py:514
#: build/lib/build/lib/build/lib/build/lib/core/models.py:691
#: build/lib/build/lib/build/lib/core/models.py:514
#: build/lib/build/lib/build/lib/core/models.py:691
#: build/lib/build/lib/core/models.py:514
#: build/lib/build/lib/core/models.py:691 build/lib/core/models.py:514
#: build/lib/core/models.py:691 core/models.py:515 core/models.py:704
msgid "Either user or team must be set, not both."
msgstr ""
#: impress/settings.py:134
#: build/lib/build/lib/build/lib/build/lib/core/models.py:532
#: build/lib/build/lib/build/lib/core/models.py:532
#: build/lib/build/lib/core/models.py:532 build/lib/core/models.py:532
#: core/models.py:533
msgid "description"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:533
#: build/lib/build/lib/build/lib/core/models.py:533
#: build/lib/build/lib/core/models.py:533 build/lib/core/models.py:533
#: core/models.py:534
msgid "code"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:534
#: build/lib/build/lib/build/lib/core/models.py:534
#: build/lib/build/lib/core/models.py:534 build/lib/core/models.py:534
#: core/models.py:535
msgid "css"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:538
#: build/lib/build/lib/build/lib/core/models.py:538
#: build/lib/build/lib/core/models.py:538 build/lib/core/models.py:538
#: core/models.py:539
msgid "Whether this template is public for anyone to use."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:544
#: build/lib/build/lib/build/lib/core/models.py:544
#: build/lib/build/lib/core/models.py:544 build/lib/core/models.py:544
#: core/models.py:545
msgid "Template"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:545
#: build/lib/build/lib/build/lib/core/models.py:545
#: build/lib/build/lib/core/models.py:545 build/lib/core/models.py:545
#: core/models.py:546
msgid "Templates"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:672
#: build/lib/build/lib/build/lib/core/models.py:672
#: build/lib/build/lib/core/models.py:672 build/lib/core/models.py:672
#: core/models.py:685
msgid "Template/user relation"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:673
#: build/lib/build/lib/build/lib/core/models.py:673
#: build/lib/build/lib/core/models.py:673 build/lib/core/models.py:673
#: core/models.py:686
msgid "Template/user relations"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:679
#: build/lib/build/lib/build/lib/core/models.py:679
#: build/lib/build/lib/core/models.py:679 build/lib/core/models.py:679
#: core/models.py:692
msgid "This user is already in this template."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:685
#: build/lib/build/lib/build/lib/core/models.py:685
#: build/lib/build/lib/core/models.py:685 build/lib/core/models.py:685
#: core/models.py:698
msgid "This team is already in this template."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:708
#: build/lib/build/lib/build/lib/core/models.py:708
#: build/lib/build/lib/core/models.py:708 build/lib/core/models.py:708
#: core/models.py:721
msgid "email address"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:725
#: build/lib/build/lib/build/lib/core/models.py:725
#: build/lib/build/lib/core/models.py:725 build/lib/core/models.py:725
#: core/models.py:738
msgid "Document invitation"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:726
#: build/lib/build/lib/build/lib/core/models.py:726
#: build/lib/build/lib/core/models.py:726 build/lib/core/models.py:726
#: core/models.py:739
msgid "Document invitations"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:751
#: build/lib/build/lib/build/lib/core/models.py:751
#: build/lib/build/lib/core/models.py:751 build/lib/core/models.py:751
#: core/models.py:764
msgid "This email is already associated to a registered user."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:795
#: build/lib/build/lib/build/lib/core/models.py:795
#: build/lib/build/lib/core/models.py:795 build/lib/core/models.py:795
msgid "Invitation to join Impress!"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/impress/settings.py:158
#: build/lib/build/lib/build/lib/impress/settings.py:158
#: build/lib/build/lib/impress/settings.py:158
#: build/lib/impress/settings.py:158 impress/settings.py:158
msgid "English"
msgstr ""
#: impress/settings.py:135
#: build/lib/build/lib/build/lib/build/lib/impress/settings.py:159
#: build/lib/build/lib/build/lib/impress/settings.py:159
#: build/lib/build/lib/impress/settings.py:159
#: build/lib/impress/settings.py:159 impress/settings.py:159
msgid "French"
msgstr ""
#: build/lib/build/lib/core/api/serializers.py:185
#: build/lib/core/api/serializers.py:185 core/api/serializers.py:185
msgid "Format"
msgstr ""
#: core/models.py:808
msgid "Invitation to join Docs!"
msgstr "Invitation à rejoindre Docs !"
#: core/templates/mail/html/hello.html:159 core/templates/mail/text/hello.txt:3
msgid "Company logo"
msgstr ""
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
#, python-format
msgid "Hello %(name)s"
msgstr ""
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
msgid "Hello"
msgstr ""
#: core/templates/mail/html/hello.html:189 core/templates/mail/text/hello.txt:6
msgid "Thank you very much for your visit!"
msgstr ""
#: core/templates/mail/html/hello.html:221
#, python-format
msgid "This mail has been sent to %(email)s by <a href=\"%(href)s\">%(name)s</a>"
msgstr ""
#: core/templates/mail/html/invitation.html:160
#: core/templates/mail/text/invitation.txt:3
msgid "La Suite Numérique"
msgstr ""
#: core/templates/mail/html/invitation.html:190
#: core/templates/mail/text/invitation.txt:5
msgid "Invitation to join a document !"
msgstr "Invitation à rejoindre un document !"
#: core/templates/mail/html/invitation.html:198
msgid "Welcome to <strong>Docs!</strong>"
msgstr "Bienvenue sur <strong>Docs !</strong>"
#: core/templates/mail/html/invitation.html:213
#: core/templates/mail/text/invitation.txt:12
msgid "We are delighted to welcome you to our community on Docs, your new companion to collaborate on documents efficiently, intuitively, and securely."
msgstr "Nous sommes heureux de vous accueillir dans notre communauté sur Docs, votre nouveau compagnon pour collaborer sur des documents efficacement, intuitivement, et en toute sécurité."
#: core/templates/mail/html/invitation.html:218
#: core/templates/mail/text/invitation.txt:13
msgid "Our application is designed to help you organize, collaborate, and manage permissions."
msgstr "Notre application est conçue pour vous aider à organiser, collaborer et gérer vos permissions."
#: core/templates/mail/html/invitation.html:223
#: core/templates/mail/text/invitation.txt:14
msgid "With Docs, you will be able to:"
msgstr "Avec Docs, vous serez capable de :"
#: core/templates/mail/html/invitation.html:224
#: core/templates/mail/text/invitation.txt:15
msgid "Create documents."
msgstr "Créez des documents."
#: core/templates/mail/html/invitation.html:225
#: core/templates/mail/text/invitation.txt:16
msgid "Work offline."
msgstr "Travailler hors ligne."
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:17
msgid "Invite members of your community to your document in just a few clicks."
msgstr "Invitez des membres de votre communauté sur votre document en quelques clics."
#: core/templates/mail/html/invitation.html:237
#: core/templates/mail/text/invitation.txt:19
msgid "Visit Docs"
msgstr "Visitez Docs"
#: core/templates/mail/html/invitation.html:246
#: core/templates/mail/text/invitation.txt:21
msgid "We are confident that Docs will help you increase efficiency and productivity while strengthening the bond among members."
msgstr "Nous sommes persuadés que Docs vous aidera à améliorer votre efficacité et votre productivité tout en renforçant les liens entre vos membres."
#: core/templates/mail/html/invitation.html:251
#: core/templates/mail/text/invitation.txt:22
msgid "Feel free to explore all the features of the application and share your feedback and suggestions with us. Your feedback is valuable to us and will enable us to continually improve our service."
msgstr "N'hésitez pas à explorer toutes les fonctionnalités de l'application et à nous faire part de vos commentaires et suggestions. Vos commentaires nous sont précieux et nous permettront d'améliorer continuellement notre service."
#: core/templates/mail/html/invitation.html:256
#: core/templates/mail/text/invitation.txt:23
msgid "Once again, welcome aboard! We are eager to accompany you on your collaboration adventure."
msgstr "Encore une fois, bienvenue à bord ! Nous sommes impatients de vous accompagner dans votre aventure collaborative."
#: core/templates/mail/html/invitation.html:263
#: core/templates/mail/text/invitation.txt:25
msgid "Sincerely,"
msgstr "Sincèrement,"
#: core/templates/mail/html/invitation.html:264
#: core/templates/mail/text/invitation.txt:27
msgid "The La Suite Numérique Team"
msgstr "L'équipe La Suite Numérique"
#: core/templates/mail/text/hello.txt:8
#, python-format
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
msgstr ""
#: core/templates/mail/text/invitation.txt:8
msgid "Welcome to Docs!"
msgstr "Bienvenue sur Docs !"
+1
View File
@@ -2,6 +2,7 @@
"""
impress's sandbox management script.
"""
import os
import sys
+39 -38
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "impress"
version = "1.0.0"
version = "1.2.1"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -25,37 +25,38 @@ license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"boto3==1.33.6",
"boto3==1.35.0",
"Brotli==1.1.0",
"celery[redis]==5.3.6",
"django-configurations==2.5",
"django-cors-headers==4.3.1",
"django-countries==7.5.1",
"celery[redis]==5.4.0",
"django-configurations==2.5.1",
"django-cors-headers==4.4.0",
"django-countries==7.6.1",
"django-parler==2.3",
"redis==5.0.3",
"redis==5.0.8",
"django-redis==5.4.0",
"django-storages[s3]==1.14.2",
"django-timezone-field>=5.1",
"django==5.0.3",
"djangorestframework==3.14.0",
"drf_spectacular==0.26.5",
"dockerflow==2022.8.0",
"easy_thumbnails==2.8.5",
"factory_boy==3.3.0",
"freezegun==1.5.0",
"gunicorn==22.0.0",
"jsonschema==4.20.0",
"markdown==3.5.1",
"django==5.0.8",
"djangorestframework==3.15.2",
"drf_spectacular==0.27.2",
"dockerflow==2024.4.2",
"easy_thumbnails==2.9",
"factory_boy==3.3.1",
"freezegun==1.5.1",
"gunicorn==23.0.0",
"jsonschema==4.23.0",
"markdown==3.7",
"nested-multipart-parser==1.5.0",
"psycopg[binary]==3.1.14",
"PyJWT==2.8.0",
"python-frontmatter==1.0.1",
"requests==2.32.2",
"sentry-sdk==1.38.0",
"psycopg[binary]==3.2.1",
"PyJWT==2.9.0",
"pypandoc==1.13",
"python-frontmatter==1.1.0",
"requests==2.32.3",
"sentry-sdk==2.13.0",
"url-normalize==1.4.3",
"WeasyPrint>=60.2",
"whitenoise==6.6.0",
"mozilla-django-oidc==4.0.0",
"whitenoise==6.7.0",
"mozilla-django-oidc==4.0.1",
]
[project.urls]
@@ -67,20 +68,20 @@ dependencies = [
[project.optional-dependencies]
dev = [
"django-extensions==3.2.3",
"drf-spectacular-sidecar==2023.12.1",
"drf-spectacular-sidecar==2024.7.1",
"ipdb==0.13.13",
"ipython==8.18.1",
"pyfakefs==5.3.2",
"ipython==8.26.0",
"pyfakefs==5.6.0",
"pylint-django==2.5.5",
"pylint==3.0.3",
"pytest-cov==4.1.0",
"pytest-django==4.7.0",
"pytest==7.4.3",
"pytest-icdiff==0.8",
"pytest-xdist==3.5.0",
"responses==0.24.1",
"ruff==0.1.6",
"types-requests==2.31.0.10",
"pylint==3.2.6",
"pytest-cov==5.0.0",
"pytest-django==4.8.0",
"pytest==8.3.2",
"pytest-icdiff==0.9",
"pytest-xdist==3.6.1",
"responses==0.25.3",
"ruff==0.6.1",
"types-requests==2.32.0.20240712",
]
[tool.setuptools]
@@ -99,11 +100,11 @@ exclude = [
"__pycache__",
"*/migrations/*",
]
ignore= ["DJ001", "PLR2004"]
line-length = 88
[tool.ruff.lint]
ignore = ["DJ001", "PLR2004"]
select = [
"B", # flake8-bugbear
"BLE", # flake8-blind-except
@@ -125,7 +126,7 @@ select = [
section-order = ["future","standard-library","django","third-party","impress","first-party","local-folder"]
sections = { impress=["core"], django=["django"] }
[tool.ruff.per-file-ignores]
[tool.ruff.lint.per-file-ignores]
"**/tests/*" = ["S", "SLF"]
[tool.pytest.ini_options]
+9
View File
@@ -61,6 +61,15 @@ FROM impress as impress-builder
WORKDIR /home/frontend/apps/impress
ARG FRONTEND_THEME
ENV NEXT_PUBLIC_THEME=${FRONTEND_THEME}
ARG SIGNALING_URL
ENV NEXT_PUBLIC_SIGNALING_URL=${SIGNALING_URL}
ARG API_ORIGIN
ENV NEXT_PUBLIC_API_ORIGIN=${API_ORIGIN}
RUN yarn build
# ---- Front-end image ----
@@ -17,3 +17,11 @@ setup('authenticate-webkit', async ({ page }) => {
.context()
.storageState({ path: `playwright/.auth/user-webkit.json` });
});
setup('authenticate-firefox', async ({ page }) => {
await page.goto('/');
await keyCloakSignIn(page, 'firefox');
await page
.context()
.storageState({ path: `playwright/.auth/user-firefox.json` });
});
@@ -33,7 +33,6 @@ export const createDoc = async (
length: number,
isPublic: boolean = false,
) => {
const panel = page.getByLabel('Documents panel').first();
const buttonCreate = page.getByRole('button', {
name: 'Create the document',
});
@@ -41,8 +40,20 @@ export const createDoc = async (
const randomDocs = randomName(docName, browserName, length);
for (let i = 0; i < randomDocs.length; i++) {
await panel.getByRole('button', { name: 'Add a document' }).click();
await page.getByText('Document name').fill(randomDocs[i]);
const header = page.locator('header').first();
await header.locator('h2').getByText('Docs').click();
const buttonCreateHomepage = page.getByRole('button', {
name: 'Create a new document',
});
await buttonCreateHomepage.click();
// Fill input
await page
.getByRole('textbox', {
name: 'Document name',
})
.fill(randomDocs[i]);
if (isPublic) {
await page.getByText('Is it public ?').click();
@@ -50,45 +61,17 @@ export const createDoc = async (
await expect(buttonCreate).toBeEnabled();
await buttonCreate.click();
await expect(panel.locator('li').getByText(randomDocs[i])).toBeVisible();
await expect(page.locator('h2').getByText(randomDocs[i])).toBeVisible();
}
return randomDocs;
};
export const createTemplate = async (
page: Page,
templateName: string,
browserName: string,
length: number,
) => {
const menu = page.locator('menu').first();
await menu.getByLabel(`Template button`).click();
const panel = page.getByLabel('Templates panel').first();
const buttonCreate = page.getByRole('button', {
name: 'Create the template',
});
const randomTemplates = randomName(templateName, browserName, length);
for (let i = 0; i < randomTemplates.length; i++) {
await panel.getByRole('button', { name: 'Add a template' }).click();
await page.getByText('Template name').fill(randomTemplates[i]);
await expect(buttonCreate).toBeEnabled();
await buttonCreate.click();
await expect(
panel.locator('li').getByText(randomTemplates[i]),
).toBeVisible();
}
return randomTemplates;
};
export const addNewMember = async (
page: Page,
index: number,
role: 'Admin' | 'Owner' | 'Member',
role: 'Administrator' | 'Owner' | 'Member' | 'Editor' | 'Reader',
fillText: string = 'user',
) => {
const responsePromiseSearchUser = page.waitForResponse(
@@ -97,9 +80,6 @@ export const addNewMember = async (
response.status() === 200,
);
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Add members' }).click();
const inputSearch = page.getByLabel(/Find a member to add to the document/);
// Select a new user
@@ -115,8 +95,8 @@ export const addNewMember = async (
await page.getByRole('option', { name: users[index].email }).click();
// Choose a role
await page.getByRole('radio', { name: role }).click();
await page.getByRole('combobox', { name: /Choose a role/ }).click();
await page.getByRole('option', { name: role }).click();
await page.getByRole('button', { name: 'Validate' }).click();
await expect(
@@ -125,3 +105,69 @@ export const addNewMember = async (
return users[index].email;
};
interface GoToGridDocOptions {
nthRow?: number;
title?: string;
}
export const goToGridDoc = async (
page: Page,
{ nthRow = 1, title }: GoToGridDocOptions = {},
) => {
const header = page.locator('header').first();
await header.locator('h2').getByText('Docs').click();
const datagrid = page
.getByLabel('Datagrid of the documents page 1')
.getByRole('table');
await expect(datagrid.getByLabel('Loading data')).toBeHidden();
const rows = datagrid.getByRole('row');
const row = title
? rows.filter({
hasText: title,
})
: rows.nth(nthRow);
const docTitleCell = row.getByRole('cell').nth(1);
const docTitle = await docTitleCell.textContent();
expect(docTitle).toBeDefined();
await docTitleCell.click();
return docTitle as string;
};
export const mockedDocument = async (page: Page, json: object) => {
await page.route('**/documents/**/', async (route) => {
const request = route.request();
if (request.method().includes('GET') && !request.url().includes('page=')) {
await route.fulfill({
json: {
id: 'mocked-document-id',
content: '',
title: 'Mocked document',
accesses: [],
abilities: {
destroy: false, // Means not owner
versions_destroy: false,
versions_list: true,
versions_retrieve: true,
manage_accesses: false, // Means not admin
update: false,
partial_update: false, // Means not editor
retrieve: true,
},
is_public: false,
created_at: '2021-09-01T09:00:00Z',
...json,
},
});
} else {
await route.continue();
}
});
};
@@ -1,5 +1,7 @@
import { expect, test } from '@playwright/test';
import { createDoc } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
@@ -12,18 +14,12 @@ test.describe('Doc Create', () => {
await buttonCreateHomepage.click();
await expect(buttonCreateHomepage).toBeHidden();
const card = page.getByLabel('Create new document card').first();
await expect(card.getByLabel('Document name')).toBeVisible();
await expect(card.getByLabel('icon group')).toBeVisible();
const card = page.getByRole('dialog').first();
await expect(
card.getByRole('heading', {
name: 'Name the document',
level: 3,
}),
card.locator('h2').getByText('Create a new document'),
).toBeVisible();
await expect(card.getByLabel('Document name')).toBeVisible();
await expect(card.getByText('Is it public ?')).toBeVisible();
@@ -33,11 +29,7 @@ test.describe('Doc Create', () => {
}),
).toBeVisible();
await expect(
card.getByRole('button', {
name: 'Cancel',
}),
).toBeVisible();
await expect(card.getByLabel('Close the modal')).toBeVisible();
});
test('checks the cancel button interaction', async ({ page }) => {
@@ -47,84 +39,37 @@ test.describe('Doc Create', () => {
await buttonCreateHomepage.click();
await expect(buttonCreateHomepage).toBeHidden();
const card = page.getByLabel('Create new document card').first();
const card = page.getByRole('dialog').first();
await card
.getByRole('button', {
name: 'Cancel',
})
.click();
await card.getByLabel('Close the modal').click();
await expect(buttonCreateHomepage).toBeVisible();
});
test('checks the routing on new doc created', async ({
page,
browserName,
}) => {
const panel = page.getByLabel('Documents panel').first();
await panel.getByRole('button', { name: 'Add a document' }).click();
const docName = `My routing doc ${browserName}-${Math.floor(Math.random() * 1000)}`;
await page.getByText('Document name').fill(docName);
await page.getByRole('button', { name: 'Create the document' }).click();
const elDoc = page.locator('h2').getByText(docName);
await expect(elDoc).toBeVisible();
await panel.getByRole('button', { name: 'Add a document' }).click();
await expect(elDoc).toBeHidden();
await panel.locator('li').getByText(docName).click();
await expect(elDoc).toBeVisible();
});
test('checks alias docs url with homepage', async ({ page }) => {
await expect(page).toHaveURL('/');
const buttonCreateHomepage = page.getByRole('button', {
name: 'Create a new document',
});
await expect(buttonCreateHomepage).toBeVisible();
await page.goto('/docs');
await expect(buttonCreateHomepage).toBeVisible();
await expect(page).toHaveURL(/\/docs$/);
});
test('checks 404 on docs/[id] page', async ({ page }) => {
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(300);
await page.goto('/docs/some-unknown-doc');
await expect(
page.getByText(
'It seems that the page you are looking for does not exist or cannot be displayed correctly.',
),
).toBeVisible({
timeout: 15000,
});
});
test('checks that the doc is public', async ({ page, browserName }) => {
const responsePromiseDoc = page.waitForResponse(
(response) =>
response.url().includes('/documents/') && response.status() === 201,
test('create a new public doc', async ({ page, browserName }) => {
const [docTitle] = await createDoc(
page,
'My new doc',
browserName,
1,
true,
);
const panel = page.getByLabel('Documents panel').first();
const header = page.locator('header').first();
await header.locator('h2').getByText('Docs').click();
await panel.getByRole('button', { name: 'Add a document' }).click();
const datagrid = page
.getByLabel('Datagrid of the documents page 1')
.getByRole('table');
const docName = `My routing doc ${browserName}-${Math.floor(Math.random() * 1000)}`;
await page.getByText('Document name').fill(docName);
await page.getByText('Is it public ?').click();
await page.getByRole('button', { name: 'Create the document' }).click();
await expect(datagrid.getByLabel('Loading data')).toBeHidden();
const responseDoc = await responsePromiseDoc;
const is_public = (await responseDoc.json()).is_public;
expect(is_public).toBeTruthy();
await expect(datagrid.getByText(docTitle)).toBeVisible();
const row = datagrid.getByRole('row').filter({
hasText: docTitle,
});
await expect(row.getByRole('cell').nth(0)).toHaveText('Public');
});
});
@@ -1,25 +1,12 @@
import { expect, test } from '@playwright/test';
import { createDoc } from './common';
import { createDoc, goToGridDoc, mockedDocument } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Doc Editor', () => {
test('checks the Doc Editor interact correctly', async ({
page,
browserName,
}) => {
const randomDoc = await createDoc(page, 'doc-editor', browserName, 1);
await expect(page.locator('h2').getByText(randomDoc[0])).toBeVisible();
await page.locator('.ProseMirror.bn-editor').click();
await page.locator('.ProseMirror.bn-editor').fill('Hello World');
await expect(page.getByText('Hello World')).toBeVisible();
});
test('checks the Doc is connected to the webrtc server', async ({
page,
browserName,
@@ -76,26 +63,18 @@ test.describe('Doc Editor', () => {
test('it renders correctly when we switch from one doc to another', async ({
page,
browserName,
}) => {
const [firstDoc, secondDoc] = await createDoc(
page,
'doc-multiple',
browserName,
2,
);
const panel = page.getByLabel('Documents panel').first();
// Check the first doc
await panel.getByText(firstDoc).click();
const firstDoc = await goToGridDoc(page);
await expect(page.locator('h2').getByText(firstDoc)).toBeVisible();
await page.locator('.ProseMirror.bn-editor').click();
await page.locator('.ProseMirror.bn-editor').fill('Hello World Doc 1');
await expect(page.getByText('Hello World Doc 1')).toBeVisible();
// Check the second doc
await panel.getByText(secondDoc).click();
const secondDoc = await goToGridDoc(page, {
nthRow: 2,
});
await expect(page.locator('h2').getByText(secondDoc)).toBeVisible();
await expect(page.getByText('Hello World Doc 1')).toBeHidden();
await page.locator('.ProseMirror.bn-editor').click();
@@ -103,22 +82,17 @@ test.describe('Doc Editor', () => {
await expect(page.getByText('Hello World Doc 2')).toBeVisible();
// Check the first doc again
await panel.getByText(firstDoc).click();
await goToGridDoc(page, {
title: firstDoc,
});
await expect(page.locator('h2').getByText(firstDoc)).toBeVisible();
await expect(page.getByText('Hello World Doc 2')).toBeHidden();
await expect(page.getByText('Hello World Doc 1')).toBeVisible();
});
test('it saves the doc when we change pages', async ({
page,
browserName,
}) => {
const [doc] = await createDoc(page, 'doc-save-page', browserName, 1);
const panel = page.getByLabel('Documents panel').first();
test('it saves the doc when we change pages', async ({ page }) => {
// Check the first doc
await panel.getByText(doc).click();
const doc = await goToGridDoc(page);
await expect(page.locator('h2').getByText(doc)).toBeVisible();
await page.locator('.ProseMirror.bn-editor').click();
await page
@@ -126,27 +100,19 @@ test.describe('Doc Editor', () => {
.fill('Hello World Doc persisted 1');
await expect(page.getByText('Hello World Doc persisted 1')).toBeVisible();
await panel
.getByRole('button', {
name: 'Add a document',
})
.click();
const secondDoc = await goToGridDoc(page, {
nthRow: 2,
});
await expect(
page.getByText(`Your document "${doc}" has been saved.`),
).toBeVisible();
const card = page.getByLabel('Create new document card').first();
await expect(
card.getByRole('heading', {
name: 'Name the document',
level: 3,
}),
).toBeVisible();
await expect(page.locator('h2').getByText(secondDoc)).toBeVisible();
await page.goto('/');
await panel.getByText(doc).click();
await goToGridDoc(page, {
title: doc,
});
await expect(page.getByText('Hello World Doc persisted 1')).toBeVisible();
});
@@ -155,12 +121,8 @@ test.describe('Doc Editor', () => {
// eslint-disable-next-line playwright/no-skipped-test
test.skip(browserName === 'webkit', 'This test is very flaky with webkit');
const [doc] = await createDoc(page, 'doc-save-quit', browserName, 1);
const panel = page.getByLabel('Documents panel').first();
// Check the first doc
await panel.getByText(doc).click();
const doc = await goToGridDoc(page);
await expect(page.locator('h2').getByText(doc)).toBeVisible();
await page.locator('.ProseMirror.bn-editor').click();
await page
@@ -170,48 +132,31 @@ test.describe('Doc Editor', () => {
await page.goto('/');
await panel.getByText(doc).click();
await goToGridDoc(page, {
title: doc,
});
await expect(page.getByText('Hello World Doc persisted 2')).toBeVisible();
});
test('it cannot edit if viewer', async ({ page, browserName }) => {
await page.route('**/documents/**/', async (route) => {
const request = route.request();
if (
request.method().includes('GET') &&
!request.url().includes('page=')
) {
await route.fulfill({
json: {
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
content: '',
title: 'Mocked document',
accesses: [],
abilities: {
destroy: false, // Means not owner
versions_destroy: false,
versions_list: true,
versions_retrieve: true,
manage_accesses: false, // Means not admin
update: false,
partial_update: false, // Means not editor
retrieve: true,
},
is_public: false,
},
});
} else {
await route.continue();
}
test('it cannot edit if viewer', async ({ page }) => {
await mockedDocument(page, {
abilities: {
destroy: false, // Means not owner
versions_destroy: false,
versions_list: true,
versions_retrieve: true,
manage_accesses: false, // Means not admin
update: false,
partial_update: false, // Means not editor
retrieve: true,
},
});
await createDoc(page, 'doc-right-edit', browserName, 1);
await goToGridDoc(page);
await expect(
page.getByText(
"Read only, you don't have the right to update this document.",
),
page.getByText('Read only, you cannot edit this document.'),
).toBeVisible();
});
});
@@ -0,0 +1,228 @@
import { expect, test } from '@playwright/test';
import cs from 'convert-stream';
import jsdom from 'jsdom';
import pdf from 'pdf-parse';
import { createDoc } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Doc Export', () => {
test('it converts the doc to pdf with a template integrated', async ({
page,
browserName,
}) => {
const [randomDoc] = await createDoc(page, 'doc-editor', browserName, 1);
const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`${randomDoc}.pdf`);
});
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await page.locator('.ProseMirror.bn-editor').click();
await page.locator('.ProseMirror.bn-editor').fill('Hello World');
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
name: 'Export',
})
.click();
await page
.getByRole('button', {
name: 'Download',
})
.click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
const pdfText = (await pdf(pdfBuffer)).text;
expect(pdfText).toContain('Hello World'); // This is the doc text
});
test('it converts the doc to docx with a template integrated', async ({
page,
browserName,
}) => {
const [randomDoc] = await createDoc(page, 'doc-editor', browserName, 1);
const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`${randomDoc}.docx`);
});
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await page.locator('.ProseMirror.bn-editor').click();
await page.locator('.ProseMirror.bn-editor').fill('Hello World');
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
name: 'Export',
})
.click();
await page.getByText('Docx').click();
await page
.getByRole('button', {
name: 'Download',
})
.click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe(`${randomDoc}.docx`);
});
test('it converts the blocknote json in correct html for the export', async ({
page,
browserName,
}) => {
test.slow();
const [randomDoc] = await createDoc(page, 'doc-editor', browserName, 1);
let body = '';
await page.route('**/templates/*/generate-document/', async (route) => {
const request = route.request();
body = request.postDataJSON().body;
await route.continue();
});
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await page.locator('.bn-block-outer').last().fill('Hello World');
await page.locator('.bn-block-outer').last().click();
await page.keyboard.press('Enter');
await page.keyboard.press('Enter');
await page.locator('.bn-block-outer').last().fill('Break');
await expect(page.getByText('Break')).toBeVisible();
// Center the text
await page.getByText('Break').dblclick();
await page.locator('button[data-test="alignTextCenter"]').click();
// Change the background color
await page.locator('button[data-test="colors"]').click();
await page.locator('button[data-test="background-color-brown"]').click();
// Change the text color
await page.getByText('Break').dblclick();
await page.locator('button[data-test="colors"]').click();
await page.locator('button[data-test="text-color-orange"]').click();
// Add a list
await page.locator('.bn-block-outer').last().click();
await page.keyboard.press('Enter');
await page.locator('.bn-block-outer').last().fill('/');
await page.getByText('Bullet List').click();
await page
.locator('.bn-block-content[data-content-type="bulletListItem"] p')
.last()
.fill('Test List 1');
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(300);
await page.keyboard.press('Enter');
await page
.locator('.bn-block-content[data-content-type="bulletListItem"] p')
.last()
.fill('Test List 2');
await page.keyboard.press('Enter');
await page
.locator('.bn-block-content[data-content-type="bulletListItem"] p')
.last()
.fill('Test List 3');
await page.keyboard.press('Enter');
await page.keyboard.press('Backspace');
// Add a number list
await page.locator('.bn-block-outer').last().click();
await page.keyboard.press('Enter');
await page.locator('.bn-block-outer').last().fill('/');
await page.getByText('Numbered List').click();
await page
.locator('.bn-block-content[data-content-type="numberedListItem"] p')
.last()
.fill('Test Number 1');
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(300);
await page.keyboard.press('Enter');
await page
.locator('.bn-block-content[data-content-type="numberedListItem"] p')
.last()
.fill('Test Number 2');
await page.keyboard.press('Enter');
await page
.locator('.bn-block-content[data-content-type="numberedListItem"] p')
.last()
.fill('Test Number 3');
// Add img
await page.locator('.bn-block-outer').last().click();
await page.keyboard.press('Enter');
await page.locator('.bn-block-outer').last().fill('/');
await page
.getByRole('option', {
name: 'Image',
})
.click();
await page
.getByPlaceholder('Enter URL')
.fill('https://example.com/image.jpg');
await page
.getByRole('button', {
name: 'Embed image',
})
.click();
// Download
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
name: 'Export',
})
.click();
await page
.getByRole('button', {
name: 'Download',
})
.click();
// Empty paragraph should be replaced by a <br/>
expect(body.match(/<br>/g)?.length).toBeGreaterThanOrEqual(2);
expect(body).toContain('style="color: orange;"');
expect(body).toContain('custom-style="center"');
expect(body).toContain('style="background-color: brown;"');
const { JSDOM } = jsdom;
const DOMParser = new JSDOM().window.DOMParser;
const parser = new DOMParser();
const html = parser.parseFromString(body, 'text/html');
const ulLis = html.querySelectorAll('ul li');
expect(ulLis.length).toBe(3);
expect(ulLis[0].textContent).toBe('Test List 1');
expect(ulLis[1].textContent).toBe('Test List 2');
expect(ulLis[2].textContent).toBe('Test List 3');
const olLis = html.querySelectorAll('ol li');
expect(olLis.length).toBe(3);
expect(olLis[0].textContent).toBe('Test Number 1');
expect(olLis[1].textContent).toBe('Test Number 2');
expect(olLis[2].textContent).toBe('Test Number 3');
const img = html.querySelectorAll('img');
expect(img.length).toBe(1);
expect(img[0].src).toBe('https://example.com/image.jpg');
});
});
@@ -0,0 +1,268 @@
import { expect, test } from '@playwright/test';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Documents Grid', () => {
test('checks all the elements are visible', async ({ page }) => {
await expect(page.locator('h2').getByText('Documents')).toBeVisible();
const datagrid = page
.getByLabel('Datagrid of the documents page 1')
.getByRole('table');
const thead = datagrid.locator('thead');
await expect(thead.getByText(/Document name/i)).toBeVisible();
await expect(thead.getByText(/Created at/i)).toBeVisible();
await expect(thead.getByText(/Updated at/i)).toBeVisible();
await expect(thead.getByText(/Your role/i)).toBeVisible();
await expect(thead.getByText(/Members/i)).toBeVisible();
const row1 = datagrid.getByRole('row').nth(1).getByRole('cell');
const docName = await row1.nth(1).textContent();
expect(docName).toBeDefined();
const docCreatedAt = await row1.nth(2).textContent();
expect(docCreatedAt).toBeDefined();
const docUpdatedAt = await row1.nth(3).textContent();
expect(docUpdatedAt).toBeDefined();
const docRole = await row1.nth(4).textContent();
expect(
docRole &&
['Administrator', 'Owner', 'Reader', 'Editor'].includes(docRole),
).toBeTruthy();
const docUserNumber = await row1.nth(5).textContent();
expect(docUserNumber).toBeDefined();
// Open the document
await row1.nth(1).click();
await expect(page.locator('h2').getByText(docName!)).toBeVisible();
});
[
{
nameColumn: 'Document name',
ordering: 'title',
cellNumber: 1,
orderDefault: '',
orderDesc: '&ordering=-title',
orderAsc: '&ordering=title',
},
{
nameColumn: 'Created at',
ordering: 'created_at',
cellNumber: 2,
orderDefault: '',
orderDesc: '&ordering=-created_at',
orderAsc: '&ordering=created_at',
},
{
nameColumn: 'Updated at',
ordering: 'updated_at',
cellNumber: 3,
orderDefault: '&ordering=-updated_at',
orderDesc: '&ordering=updated_at',
orderAsc: '',
},
].forEach(
({
nameColumn,
ordering,
cellNumber,
orderDefault,
orderDesc,
orderAsc,
}) => {
test(`checks datagrid ordering ${ordering}`, async ({ page }) => {
const responsePromise = page.waitForResponse(
(response) =>
response.url().includes(`/documents/?page=1${orderDefault}`) &&
response.status() === 200,
);
const responsePromiseOrderingDesc = page.waitForResponse(
(response) =>
response.url().includes(`/documents/?page=1${orderDesc}`) &&
response.status() === 200,
);
const responsePromiseOrderingAsc = page.waitForResponse(
(response) =>
response.url().includes(`/documents/?page=1${orderAsc}`) &&
response.status() === 200,
);
// Checks the initial state
const datagrid = page
.getByLabel('Datagrid of the documents page 1')
.getByRole('table');
const thead = datagrid.locator('thead');
const response = await responsePromise;
expect(response.ok()).toBeTruthy();
const docNameRow1 = datagrid
.getByRole('row')
.nth(1)
.getByRole('cell')
.nth(cellNumber);
const docNameRow2 = datagrid
.getByRole('row')
.nth(2)
.getByRole('cell')
.nth(cellNumber);
await expect(datagrid.getByLabel('Loading data')).toBeHidden();
// Initial state
await expect(docNameRow1).toHaveText(/.*/);
await expect(docNameRow2).toHaveText(/.*/);
const initialDocNameRow1 = await docNameRow1.textContent();
const initialDocNameRow2 = await docNameRow2.textContent();
expect(initialDocNameRow1).toBeDefined();
expect(initialDocNameRow2).toBeDefined();
// Ordering ASC
await thead.getByText(nameColumn).click();
const responseOrderingAsc = await responsePromiseOrderingAsc;
expect(responseOrderingAsc.ok()).toBeTruthy();
await expect(datagrid.getByLabel('Loading data')).toBeHidden();
await expect(docNameRow1).toHaveText(/.*/);
await expect(docNameRow2).toHaveText(/.*/);
const textDocNameRow1Asc = await docNameRow1.textContent();
const textDocNameRow2Asc = await docNameRow2.textContent();
expect(
textDocNameRow1Asc &&
textDocNameRow2Asc &&
textDocNameRow1Asc.localeCompare(textDocNameRow2Asc, 'en', {
caseFirst: 'false',
ignorePunctuation: true,
}) <= 0,
).toBeTruthy();
// Ordering Desc
await thead.getByText(nameColumn).click();
const responseOrderingDesc = await responsePromiseOrderingDesc;
expect(responseOrderingDesc.ok()).toBeTruthy();
await expect(datagrid.getByLabel('Loading data')).toBeHidden();
await expect(docNameRow1).toHaveText(/.*/);
await expect(docNameRow2).toHaveText(/.*/);
const textDocNameRow1Desc = await docNameRow1.textContent();
const textDocNameRow2Desc = await docNameRow2.textContent();
expect(
textDocNameRow1Desc &&
textDocNameRow2Desc &&
textDocNameRow1Desc.localeCompare(textDocNameRow2Desc, 'en', {
caseFirst: 'false',
ignorePunctuation: true,
}) >= 0,
).toBeTruthy();
});
},
);
test('checks the pagination', async ({ page }) => {
const responsePromisePage1 = page.waitForResponse(
(response) =>
response.url().includes(`/documents/?page=1`) &&
response.status() === 200,
);
const responsePromisePage2 = page.waitForResponse(
(response) =>
response.url().includes(`/documents/?page=2`) &&
response.status() === 200,
);
const datagridPage1 = page
.getByLabel('Datagrid of the documents page 1')
.getByRole('table');
const responsePage1 = await responsePromisePage1;
expect(responsePage1.ok()).toBeTruthy();
await expect(
datagridPage1.getByRole('row').nth(1).getByRole('cell').nth(1),
).toHaveText(/.*/);
await page.getByLabel('Go to page 2').click();
const datagridPage2 = page
.getByLabel('Datagrid of the documents page 2')
.getByRole('table');
const responsePage2 = await responsePromisePage2;
expect(responsePage2.ok()).toBeTruthy();
await expect(
datagridPage2.getByRole('row').nth(1).getByRole('cell').nth(1),
).toHaveText(/.*/);
});
test('it updates document', async ({ page }) => {
const datagrid = page
.getByLabel('Datagrid of the documents page 1')
.getByRole('table');
const docRow = datagrid.getByRole('row').nth(1).getByRole('cell');
const docName = await docRow.nth(1).textContent();
await docRow.getByLabel('Open the document options').click();
await page.getByText('Update document').click();
await page.getByLabel('Document name').fill(`${docName} updated`);
await page.getByText('Validate the modification').click();
await expect(datagrid.getByText(`${docName} updated`)).toBeVisible();
});
test('it deletes the document', async ({ page }) => {
const datagrid = page
.getByLabel('Datagrid of the documents page 1')
.getByRole('table');
const docRow = datagrid.getByRole('row').nth(1).getByRole('cell');
const docName = await docRow.nth(1).textContent();
await docRow.getByLabel('Open the document options').click();
await page
.getByRole('button', {
name: 'Delete document',
})
.click();
await expect(
page.locator('h2').getByText(`Deleting the document "${docName}"`),
).toBeVisible();
await page
.getByRole('button', {
name: 'Confirm deletion',
})
.click();
await expect(
page.getByText('The document has been deleted.'),
).toBeVisible();
await expect(datagrid.getByText(docName!)).toBeHidden();
});
});
@@ -0,0 +1,256 @@
import { expect, test } from '@playwright/test';
import { createDoc, goToGridDoc, mockedDocument } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Doc Header', () => {
test('it checks the element are correctly displayed', async ({ page }) => {
await mockedDocument(page, {
accesses: [
{
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
role: 'owner',
user: {
email: 'super@owner.com',
},
},
{
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
role: 'admin',
user: {
email: 'super@admin.com',
},
},
{
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
role: 'owner',
user: {
email: 'super2@owner.com',
},
},
],
abilities: {
destroy: true, // Means owner
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
manage_accesses: true,
update: true,
partial_update: true,
retrieve: true,
},
is_public: true,
created_at: '2021-09-01T09:00:00Z',
});
await goToGridDoc(page);
const card = page.getByLabel(
'It is the card information about the document.',
);
await expect(card.locator('a').getByText('home')).toBeVisible();
await expect(card.locator('h2').getByText('Mocked document')).toBeVisible();
await expect(card.getByText('Public')).toBeVisible();
await expect(
card.getByText('Created at 09/01/2021, 11:00 AM'),
).toBeVisible();
await expect(
card.getByText('Owners: super@owner.com / super2@owner.com'),
).toBeVisible();
await expect(card.getByText('Your role: Owner')).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
});
test('it updates the doc', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(
page,
'doc-update',
browserName,
1,
true,
);
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
name: 'Update document',
})
.click();
await expect(
page.locator('h2').getByText(`Update document "${randomDoc}"`),
).toBeVisible();
await expect(
page.getByRole('checkbox', { name: 'Is it public ?' }),
).toBeChecked();
await page.getByText('Document name').fill(`${randomDoc}-updated`);
await page.getByText('Is it public ?').click();
await page
.getByRole('button', {
name: 'Validate the modification',
})
.click();
await expect(
page.getByText('The document has been updated.'),
).toBeVisible();
const docTitle = await goToGridDoc(page, {
title: `${randomDoc}-updated`,
});
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
name: 'Update document',
})
.click();
await expect(
page.getByRole('checkbox', { name: 'Is it public ?' }),
).not.toBeChecked();
});
test('it deletes the doc', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-delete', browserName, 1);
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
name: 'Delete document',
})
.click();
await expect(
page.locator('h2').getByText(`Deleting the document "${randomDoc}"`),
).toBeVisible();
await page
.getByRole('button', {
name: 'Confirm deletion',
})
.click();
await expect(
page.getByText('The document has been deleted.'),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Create a new document' }),
).toBeVisible();
const row = page
.getByLabel('Datagrid of the documents page 1')
.getByRole('table')
.getByRole('row')
.filter({
hasText: randomDoc,
});
expect(await row.count()).toBe(0);
});
test('it checks the options available if administrator', async ({ page }) => {
await mockedDocument(page, {
abilities: {
destroy: false, // Means not owner
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
manage_accesses: true, // Means admin
update: true,
partial_update: true,
retrieve: true,
},
});
await goToGridDoc(page);
await expect(page.locator('h2').getByText('Mocked document')).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
await page.getByLabel('Open the document options').click();
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
await expect(
page.getByRole('button', { name: 'Update document' }),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Delete document' }),
).toBeHidden();
});
test('it checks the options available if editor', async ({ page }) => {
await mockedDocument(page, {
abilities: {
destroy: false, // Means not owner
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
manage_accesses: false, // Means not admin
update: true,
partial_update: true, // Means editor
retrieve: true,
},
});
await goToGridDoc(page);
await expect(page.locator('h2').getByText('Mocked document')).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeHidden();
await page.getByLabel('Open the document options').click();
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
await expect(
page.getByRole('button', { name: 'Update document' }),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Delete document' }),
).toBeHidden();
});
test('it checks the options available if reader', async ({ page }) => {
await mockedDocument(page, {
abilities: {
destroy: false, // Means not owner
versions_destroy: false,
versions_list: true,
versions_retrieve: true,
manage_accesses: false, // Means not admin
update: false,
partial_update: false, // Means not editor
retrieve: true,
},
});
await goToGridDoc(page);
await expect(page.locator('h2').getByText('Mocked document')).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeHidden();
await page.getByLabel('Open the document options').click();
await expect(page.getByRole('button', { name: 'Share' })).toBeHidden();
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
await expect(
page.getByRole('button', { name: 'Update document' }),
).toBeHidden();
await expect(
page.getByRole('button', { name: 'Delete document' }),
).toBeHidden();
});
});
@@ -6,7 +6,7 @@ test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Document add users', () => {
test.describe('Document create member', () => {
test('it selects 2 users and 1 invitation', async ({ page, browserName }) => {
const responsePromise = page.waitForResponse(
(response) =>
@@ -14,8 +14,7 @@ test.describe('Document add users', () => {
);
await createDoc(page, 'select-multi-users', browserName, 1);
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Add members' }).click();
await page.getByRole('button', { name: 'Share' }).click();
const inputSearch = page.getByLabel(/Find a member to add to the document/);
await expect(inputSearch).toBeVisible();
@@ -56,12 +55,14 @@ test.describe('Document add users', () => {
await expect(page.getByLabel(`Remove ${email}`)).toBeVisible();
// Check roles are displayed
await expect(page.getByText(/Choose a role/)).toBeVisible();
await expect(page.getByRole('radio', { name: 'Reader' })).toBeChecked();
await expect(page.getByRole('radio', { name: 'Owner' })).toBeVisible();
await page.getByRole('combobox', { name: /Choose a role/ }).click();
await expect(page.getByRole('option', { name: 'Reader' })).toBeVisible();
await expect(page.getByRole('option', { name: 'Editor' })).toBeVisible();
await expect(
page.getByRole('radio', { name: 'Administrator' }),
page.getByRole('option', { name: 'Administrator' }),
).toBeVisible();
await expect(page.getByRole('option', { name: 'Owner' })).toBeVisible();
});
test('it sends a new invitation and adds a new user', async ({
@@ -75,8 +76,7 @@ test.describe('Document add users', () => {
await createDoc(page, 'user-invitation', browserName, 1);
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Add members' }).click();
await page.getByRole('button', { name: 'Share' }).click();
const inputSearch = page.getByLabel(/Find a member to add to the document/);
@@ -93,7 +93,8 @@ test.describe('Document add users', () => {
await page.getByRole('option', { name: user.email }).click();
// Choose a role
await page.getByRole('radio', { name: 'Administrator' }).click();
await page.getByRole('combobox', { name: /Choose a role/ }).click();
await page.getByRole('option', { name: 'Administrator' }).click();
const responsePromiseCreateInvitation = page.waitForResponse(
(response) =>
@@ -110,6 +111,9 @@ test.describe('Document add users', () => {
await expect(page.getByText(`Invitation sent to ${email}`)).toBeVisible();
const responseCreateInvitation = await responsePromiseCreateInvitation;
expect(responseCreateInvitation.ok()).toBeTruthy();
expect(
responseCreateInvitation.request().headers()['content-language'],
).toBe('en-us');
// Check user added
await expect(
@@ -117,6 +121,18 @@ test.describe('Document add users', () => {
).toBeVisible();
const responseAddUser = await responsePromiseAddUser;
expect(responseAddUser.ok()).toBeTruthy();
expect(responseAddUser.request().headers()['content-language']).toBe(
'en-us',
);
const listInvitation = page.getByLabel('List invitation card');
await expect(listInvitation.locator('li').getByText(email)).toBeVisible();
await expect(
listInvitation.locator('li').getByText('Invited'),
).toBeVisible();
const listMember = page.getByLabel('List members card');
await expect(listMember.locator('li').getByText(user.email)).toBeVisible();
});
test('it try to add twice the same user', async ({ page, browserName }) => {
@@ -127,8 +143,7 @@ test.describe('Document add users', () => {
await createDoc(page, 'user-twice', browserName, 1);
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Add members' }).click();
await page.getByRole('button', { name: 'Share' }).click();
const inputSearch = page.getByLabel(/Find a member to add to the document/);
await inputSearch.fill('user');
@@ -139,7 +154,8 @@ test.describe('Document add users', () => {
await page.getByRole('option', { name: user.email }).click();
// Choose a role
await page.getByRole('radio', { name: 'Owner' }).click();
await page.getByRole('combobox', { name: /Choose a role/ }).click();
await page.getByRole('option', { name: 'Owner' }).click();
const responsePromiseAddMember = page.waitForResponse(
(response) =>
@@ -154,10 +170,8 @@ test.describe('Document add users', () => {
const responseAddMember = await responsePromiseAddMember;
expect(responseAddMember.ok()).toBeTruthy();
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Add members' }).click();
await inputSearch.fill('user');
await expect(page.getByText('Loading...')).toBeHidden();
await expect(page.getByRole('option', { name: user.email })).toBeHidden();
});
@@ -167,17 +181,17 @@ test.describe('Document add users', () => {
}) => {
await createDoc(page, 'invitation-twice', browserName, 1);
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Add members' }).click();
await page.getByRole('button', { name: 'Share' }).click();
const inputSearch = page.getByLabel(/Find a member to add to the document/);
const email = randomName('test@test.fr', browserName, 1)[0];
const [email] = randomName('test@test.fr', browserName, 1);
await inputSearch.fill(email);
await page.getByRole('option', { name: email }).click();
// Choose a role
await page.getByRole('radio', { name: 'Owner' }).click();
await page.getByRole('combobox', { name: /Choose a role/ }).click();
await page.getByRole('option', { name: 'Owner' }).click();
const responsePromiseCreateInvitation = page.waitForResponse(
(response) =>
@@ -191,10 +205,106 @@ test.describe('Document add users', () => {
const responseCreateInvitation = await responsePromiseCreateInvitation;
expect(responseCreateInvitation.ok()).toBeTruthy();
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Add members' }).click();
await inputSearch.fill(email);
await expect(page.getByRole('option', { name: email })).toBeHidden();
await page.getByRole('option', { name: email }).click();
// Choose a role
await page.getByRole('combobox', { name: /Choose a role/ }).click();
await page.getByRole('option', { name: 'Owner' }).click();
const responsePromiseCreateInvitationFail = page.waitForResponse(
(response) =>
response.url().includes('/invitations/') && response.status() === 400,
);
await page.getByRole('button', { name: 'Validate' }).click();
await expect(
page.getByText(`"${email}" is already invited to the document.`),
).toBeVisible();
const responseCreateInvitationFail =
await responsePromiseCreateInvitationFail;
expect(responseCreateInvitationFail.ok()).toBeFalsy();
});
test('The invitation endpoint get the language of the website', async ({
page,
browserName,
}) => {
await createDoc(page, 'user-invitation', browserName, 1);
const header = page.locator('header').first();
await header.getByRole('combobox').getByText('EN').click();
await header.getByRole('option', { name: 'FR' }).click();
await page.getByRole('button', { name: 'Partager' }).click();
const inputSearch = page.getByLabel(
/Trouver un membre à ajouter au document/,
);
const email = randomName('test@test.fr', browserName, 1)[0];
await inputSearch.fill(email);
await page.getByRole('option', { name: email }).click();
// Choose a role
await page.getByRole('combobox', { name: /Choisissez un rôle/ }).click();
await page.getByRole('option', { name: 'Administrateur' }).click();
const responsePromiseCreateInvitation = page.waitForResponse(
(response) =>
response.url().includes('/invitations/') && response.status() === 201,
);
await page.getByRole('button', { name: 'Valider' }).click();
// Check invitation sent
await expect(page.getByText(`Invitation envoyée à ${email}`)).toBeVisible();
const responseCreateInvitation = await responsePromiseCreateInvitation;
expect(responseCreateInvitation.ok()).toBeTruthy();
expect(
responseCreateInvitation.request().headers()['content-language'],
).toBe('fr-fr');
});
test('it manages invitation', async ({ page, browserName }) => {
await createDoc(page, 'user-invitation', browserName, 1);
await page.getByRole('button', { name: 'Share' }).click();
const inputSearch = page.getByLabel(/Find a member to add to the document/);
const email = randomName('test@test.fr', browserName, 1)[0];
await inputSearch.fill(email);
await page.getByRole('option', { name: email }).click();
// Choose a role
await page.getByRole('combobox', { name: /Choose a role/ }).click();
await page.getByRole('option', { name: 'Administrator' }).click();
const responsePromiseCreateInvitation = page.waitForResponse(
(response) =>
response.url().includes('/invitations/') && response.status() === 201,
);
await page.getByRole('button', { name: 'Validate' }).click();
// Check invitation sent
await expect(page.getByText(`Invitation sent to ${email}`)).toBeVisible();
const responseCreateInvitation = await responsePromiseCreateInvitation;
expect(responseCreateInvitation.ok()).toBeTruthy();
const listInvitation = page.getByLabel('List invitation card');
const li = listInvitation.locator('li').filter({
hasText: email,
});
await expect(li.getByText(email)).toBeVisible();
await li.getByRole('combobox', { name: /Role/ }).click();
await li.getByRole('option', { name: 'Reader' }).click();
await expect(page.getByText(`The role has been updated.`)).toBeVisible();
await li.getByText('delete').click();
await expect(
page.getByText(`The invitation has been removed.`),
).toBeVisible();
await expect(listInvitation.locator('li').getByText(email)).toBeHidden();
});
});
@@ -1,192 +0,0 @@
import { expect, test } from '@playwright/test';
import { addNewMember, createDoc } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Members Delete', () => {
test('it cannot delete himself when it is the last owner', async ({
page,
browserName,
}) => {
await createDoc(page, 'member-delete-1', browserName, 1);
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Manage members' }).click();
const table = page.getByLabel('List members card').getByRole('table');
const cells = table.getByRole('row').nth(1).getByRole('cell');
await expect(cells.nth(0)).toHaveText(
new RegExp(`user@${browserName}.e2e`, 'i'),
);
await cells.nth(2).getByLabel('Member options').click();
await page.getByLabel('Open the modal to delete this member').click();
await expect(
page.getByText(
'You are the last owner, you cannot be removed from your document.',
),
).toBeVisible();
await expect(page.getByRole('button', { name: 'Validate' })).toBeDisabled();
});
test('it deletes himself when it is not the last owner', async ({
page,
browserName,
}) => {
await createDoc(page, 'member-delete-2', browserName, 1);
await addNewMember(page, 0, 'Owner');
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Manage members' }).click();
const table = page.getByLabel('List members card').getByRole('table');
// find row where regexp match the name
const cells = table
.getByRole('row')
.filter({ hasText: new RegExp(`user@${browserName}.e2e`, 'i') })
.getByRole('cell');
await cells.nth(2).getByLabel('Member options').click();
await page.getByLabel('Open the modal to delete this member').click();
await page.getByRole('button', { name: 'Validate' }).click();
await expect(
page.getByText(`The member has been removed from the document`),
).toBeVisible();
await expect(
page.getByRole('button', { name: `Create a new document` }),
).toBeVisible();
});
test('it cannot delete owner member', async ({ page, browserName }) => {
await createDoc(page, 'member-delete-3', browserName, 1);
const username = await addNewMember(page, 0, 'Owner');
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Manage members' }).click();
const table = page.getByLabel('List members card').getByRole('table');
// find row where regexp match the name
const cells = table
.getByRole('row')
.filter({ hasText: username })
.getByRole('cell');
await cells.getByLabel('Member options').click();
await page.getByLabel('Open the modal to delete this member').click();
await expect(
page.getByText(`You cannot remove other owner.`),
).toBeVisible();
await expect(page.getByRole('button', { name: 'Validate' })).toBeDisabled();
});
test('it deletes admin member', async ({ page, browserName }) => {
await createDoc(page, 'member-delete-4', browserName, 1);
const username = await addNewMember(page, 0, 'Admin');
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Manage members' }).click();
const table = page.getByLabel('List members card').getByRole('table');
// find row where regexp match the name
const cells = table
.getByRole('row')
.filter({ hasText: username })
.getByRole('cell');
await cells.getByLabel('Member options').click();
await page.getByLabel('Open the modal to delete this member').click();
await page.getByRole('button', { name: 'Validate' }).click();
await expect(
page.getByText(`The member has been removed from the document`),
).toBeVisible();
await expect(table.getByText(username)).toBeHidden();
});
test('it cannot delete owner member when admin', async ({
page,
browserName,
}) => {
await createDoc(page, 'member-delete-5', browserName, 1);
const username = await addNewMember(page, 0, 'Owner');
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Manage members' }).click();
const table = page.getByLabel('List members card').getByRole('table');
// find row where regexp match the name
const myCells = table
.getByRole('row')
.filter({ hasText: new RegExp(`user@${browserName}.e2e`, 'i') })
.getByRole('cell');
await myCells.getByLabel('Member options').click();
// Change role to Admin
await page.getByText('Update role').click();
const radioGroup = page.getByLabel('Radio buttons to update the roles');
await radioGroup.getByRole('radio', { name: 'Administrator' }).click();
await page.getByRole('button', { name: 'Validate' }).click();
const cells = table
.getByRole('row')
.filter({ hasText: username })
.getByRole('cell');
await expect(cells.getByLabel('Member options')).toBeHidden();
});
test('it deletes admin member when admin', async ({ page, browserName }) => {
await createDoc(page, 'member-delete-6', browserName, 1);
// To not be the only owner
await addNewMember(page, 0, 'Owner');
const username = await addNewMember(page, 1, 'Admin');
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Manage members' }).click();
const table = page.getByLabel('List members card').getByRole('table');
// find row where regexp match the name
const myCells = table
.getByRole('row')
.filter({ hasText: new RegExp(`user@${browserName}.e2e`, 'i') })
.getByRole('cell');
await myCells.getByLabel('Member options').click();
// Change role to Admin
await page.getByText('Update role').click();
const radioGroup = page.getByLabel('Radio buttons to update the roles');
await radioGroup.getByRole('radio', { name: 'Administrator' }).click();
await page.getByRole('button', { name: 'Validate' }).click();
await expect(page.getByText(`The role has been updated`)).toBeVisible();
await expect(page.getByText(`The role has been updated`)).toBeHidden({
timeout: 5000,
});
const cells = table
.getByRole('row')
.filter({ hasText: new RegExp(username, 'i') })
.getByRole('cell');
await cells.nth(2).getByLabel('Member options').click();
await page.getByLabel('Open the modal to delete this member').click();
await page.getByRole('button', { name: 'Validate' }).click();
await expect(
page.getByText(`The member has been removed from the document`),
).toBeVisible();
await expect(table.getByText(username)).toBeHidden();
});
});
@@ -1,113 +0,0 @@
import { expect, test } from '@playwright/test';
import { createDoc } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Document grid members', () => {
test('it display the grid', async ({ page, browserName }) => {
await createDoc(page, 'grid-display', browserName, 1);
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Manage members' }).click();
await expect(page.getByText('Members of the document')).toBeVisible();
const table = page.getByLabel('List members card').getByRole('table');
const thead = table.locator('thead');
await expect(thead.getByText(/Emails/i)).toBeVisible();
await expect(thead.getByText(/Roles/i)).toBeVisible();
const cells = table.getByRole('row').nth(1).getByRole('cell');
await expect(cells.nth(0)).toHaveText(`user@${browserName}.e2e`);
await expect(cells.nth(1)).toHaveText(/Owner/i);
await expect(cells.nth(2)).toHaveAccessibleName(
'Open the member options modal',
);
});
test('it display the grid with many members', async ({
page,
browserName,
}) => {
await page.route('**/documents/*/', async (route) => {
const request = route.request();
if (
request.method().includes('GET') &&
!request.url().includes('page=')
) {
await route.fulfill({
json: {
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
content: '',
title: 'Mocked document',
accesses: [],
abilities: {
destroy: true,
manage_accesses: true,
partial_update: true,
},
is_public: false,
},
});
} else {
await route.continue();
}
});
await page.route(
'**/documents/b0df4343-c8bd-4c20-9ff6-fbf94fc94egg/accesses/?page=*',
async (route) => {
const request = route.request();
const url = new URL(request.url());
const pageId = url.searchParams.get('page');
const accesses = {
count: 100,
next: null,
previous: null,
results: Array.from({ length: 20 }, (_, i) => ({
id: `2ff1ec07-86c1-4534-a643-f41824a6c53a-${pageId}-${i}`,
user: {
id: `fc092149-cafa-4ffa-a29d-e4b18af751-${pageId}-${i}`,
email: `impress@impress.world-page-${pageId}-${i}`,
},
team: '',
role: 'owner',
abilities: {
destroy: false,
partial_update: true,
},
})),
};
if (request.method().includes('GET')) {
await route.fulfill({
json: accesses,
});
} else {
await route.continue();
}
},
);
await createDoc(page, 'grid-no-member', browserName, 1);
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Manage members' }).click();
await expect(
page.getByText('impress@impress.world-page-1-19'),
).toBeVisible();
await page.getByLabel('Go to page 4').click();
await expect(
page.getByText('impress@impress.world-page-1-19'),
).toBeHidden();
await expect(
page.getByText('impress@impress.world-page-4-19'),
).toBeVisible();
});
});
@@ -0,0 +1,164 @@
import { expect, test } from '@playwright/test';
import { waitForElementCount } from '../helpers';
import { addNewMember, createDoc, goToGridDoc } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Document list members', () => {
test('it checks a big list of members', async ({ page }) => {
await page.route(
/.*\/documents\/.*\/accesses\/\?page=.*/,
async (route) => {
const request = route.request();
const url = new URL(request.url());
const pageId = url.searchParams.get('page');
const accesses = {
count: 100,
next: 'http://anything/?page=2',
previous: null,
results: Array.from({ length: 20 }, (_, i) => ({
id: `2ff1ec07-86c1-4534-a643-f41824a6c53a-${pageId}-${i}`,
user: {
id: `fc092149-cafa-4ffa-a29d-e4b18af751-${pageId}-${i}`,
email: `impress@impress.world-page-${pageId}-${i}`,
},
team: '',
role: 'editor',
abilities: {
destroy: false,
partial_update: true,
set_role_to: [],
},
})),
};
if (request.method().includes('GET')) {
await route.fulfill({
json: accesses,
});
} else {
await route.continue();
}
},
);
await goToGridDoc(page);
await page.getByRole('button', { name: 'Share' }).click();
const list = page.getByLabel('List members card').locator('ul');
await expect(list.locator('li')).toHaveCount(20);
await list.getByText(`impress@impress.world-page-${1}-18`).hover();
await page.mouse.wheel(0, 10);
await waitForElementCount(list.locator('li'), 21, 10000);
expect(await list.locator('li').count()).toBeGreaterThan(20);
await expect(
list.getByText(`impress@impress.world-page-1-16`),
).toBeVisible();
await expect(
list.getByText(`impress@impress.world-page-2-15`),
).toBeVisible();
});
test('it checks the role rules', async ({ page, browserName }) => {
const [docTitle] = await createDoc(page, 'Doc role rules', browserName, 1);
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
const list = page.getByLabel('List members card').locator('ul');
await expect(list.getByText(`user@${browserName}.e2e`)).toBeVisible();
const soleOwner = list.getByText(
`You are the sole owner of this group, make another member the group owner before you can change your own role or be removed from your document.`,
);
await expect(soleOwner).toBeVisible();
const username = await addNewMember(page, 0, 'Owner');
await expect(list.getByText(username)).toBeVisible();
await expect(soleOwner).toBeHidden();
const otherOwner = list.getByText(
`You cannot update the role or remove other owner.`,
);
await expect(otherOwner).toBeVisible();
const SelectRoleCurrentUser = list
.locator('li')
.filter({
hasText: `user@${browserName}.e2e`,
})
.getByRole('combobox', { name: 'Role' });
await SelectRoleCurrentUser.click();
await page.getByRole('option', { name: 'Administrator' }).click();
await expect(page.getByText('The role has been updated')).toBeVisible();
// Admin still have the right to share
await expect(page.locator('h3').getByText('Share')).toBeVisible();
await SelectRoleCurrentUser.click();
await page.getByRole('option', { name: 'Reader' }).click();
await expect(page.getByText('The role has been updated')).toBeVisible();
// Reader does not have the right to share
await expect(page.locator('h3').getByText('Share')).toBeHidden();
});
test('it checks the delete members', async ({ page, browserName }) => {
const [docTitle] = await createDoc(page, 'Doc role rules', browserName, 1);
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
const list = page.getByLabel('List members card').locator('ul');
const nameMyself = `user@${browserName}.e2e`;
await expect(list.getByText(nameMyself)).toBeVisible();
const userOwner = await addNewMember(page, 0, 'Owner');
await expect(list.getByText(userOwner)).toBeVisible();
const userReader = await addNewMember(page, 0, 'Reader');
await expect(list.getByText(userReader)).toBeVisible();
await list
.locator('li')
.filter({
hasText: userReader,
})
.getByText('delete')
.click();
await expect(list.getByText(userReader)).toBeHidden();
await list
.locator('li')
.filter({
hasText: nameMyself,
})
.getByText('delete')
.click();
await expect(list.getByText(nameMyself)).toBeHidden();
await expect(
page.getByText('The member has been removed from the document').first(),
).toBeVisible();
await expect(page.getByText('Share')).toBeHidden();
});
});
@@ -1,159 +0,0 @@
import { expect, test } from '@playwright/test';
import { waitForElementCount } from '../helpers';
import { createDoc } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Documents Panel', () => {
test('checks all the elements are visible', async ({ page }) => {
const panel = page.getByLabel('Documents panel').first();
await expect(panel.getByText('Documents')).toBeVisible();
await expect(
panel.getByRole('button', {
name: 'Sort the documents',
}),
).toBeVisible();
await expect(
panel.getByRole('button', {
name: 'Add a document',
}),
).toBeVisible();
});
test('checks the sort button', async ({ page }) => {
const responsePromiseSortDesc = page.waitForResponse(
(response) =>
response.url().includes('/documents/?page=1&ordering=-created_at') &&
response.status() === 200,
);
const responsePromiseSortAsc = page.waitForResponse(
(response) =>
response.url().includes('/documents/?page=1&ordering=created_at') &&
response.status() === 200,
);
const panel = page.getByLabel('Documents panel').first();
await panel
.getByRole('button', {
name: 'Sort the documents by creation date ascendent',
})
.click();
const responseSortAsc = await responsePromiseSortAsc;
expect(responseSortAsc.ok()).toBeTruthy();
await panel
.getByRole('button', {
name: 'Sort the documents by creation date descendent',
})
.click();
const responseSortDesc = await responsePromiseSortDesc;
expect(responseSortDesc.ok()).toBeTruthy();
});
test('checks the infinite scroll', async ({ page }) => {
await page.route(
/.*\/documents\/\?page=.*&ordering=-created_at/,
async (route) => {
const request = route.request();
const url = new URL(request.url());
const pageId = url.searchParams.get('page');
const documents = {
count: 40,
next: 'http://localhost:3000/documents/?page=2&ordering=-created_at',
previous: null,
results: Array.from({ length: 20 }, (_, i) => ({
id: `2ff-${pageId}-${i}`,
title: `My document-${pageId}-${i}`,
accesses: [
{
id: 'b644e9b1-0517-4cfb-90ca-f7d6f2f6bb9a',
role: `owner`,
team: '',
user: {
id: 'a4743608-c9d8-4692-bef4-f795e25a3a88',
email: 'user@chromium.e2e',
},
},
],
content: '',
is_public: true,
abilities: {},
})),
};
if (request.method().includes('GET')) {
await route.fulfill({
json: documents,
});
} else {
await route.continue();
}
},
);
await page.route(`**/documents/2ff-1-16/`, async (route) => {
const request = route.request();
if (request.method().includes('GET')) {
await route.fulfill({
json: {
id: '2ff-1-16',
title: 'My document-1-16',
content: '',
abilities: {
partial_update: true,
},
accesses: [
{
id: 'b644e9b1-0517-4cfb-90ca-f7d6f2f6bb9a',
role: `owner`,
team: '',
user: {
id: 'a4743608-c9d8-4692-bef4-f795e25a3a88',
email: '',
},
},
],
},
});
} else {
await route.continue();
}
});
const panel = page.getByLabel('Documents panel').first();
await expect(panel.locator('li')).toHaveCount(20);
await panel.getByText(`My document-1-16`).click();
await waitForElementCount(panel.locator('li'), 21, 10000);
expect(await panel.locator('li').count()).toBeGreaterThan(20);
await expect(panel.getByText(`My document-1-16`)).toBeVisible();
await expect(panel.getByText(`My document-2-15`)).toBeVisible();
});
test('checks the hover and selected state', async ({ page, browserName }) => {
const panel = page.getByLabel('Documents panel').first();
await createDoc(page, 'doc-hover', browserName, 2);
const selectedDoc = panel.locator('li').nth(0);
await expect(selectedDoc).toHaveCSS(
'background-color',
'rgb(202, 202, 251)',
);
const hoverDoc = panel.locator('li').nth(1);
await hoverDoc.hover();
await expect(hoverDoc).toHaveCSS('background-color', 'rgb(227, 227, 253)');
});
});
@@ -0,0 +1,50 @@
import { expect, test } from '@playwright/test';
import { keyCloakSignIn } from './common';
test.describe('Doc Routing', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('checks alias docs url with homepage', async ({ page }) => {
await expect(page).toHaveURL('/');
const buttonCreateHomepage = page.getByRole('button', {
name: 'Create a new document',
});
await expect(buttonCreateHomepage).toBeVisible();
await page.goto('/docs/');
await expect(buttonCreateHomepage).toBeVisible();
await expect(page).toHaveURL(/\/docs\/$/);
});
test('checks 404 on docs/[id] page', async ({ page }) => {
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(300);
await page.goto('/docs/some-unknown-doc');
await expect(
page.getByText(
'It seems that the page you are looking for does not exist or cannot be displayed correctly.',
),
).toBeVisible({
timeout: 15000,
});
});
});
test.describe('Doc Routing: Not loggued', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('checks redirect to a doc after login', async ({
page,
browserName,
}) => {
await page.goto('/docs/mocked-document-id/');
await keyCloakSignIn(page, browserName);
await expect(page).toHaveURL(/\/docs\/mocked-document-id\/$/);
});
});
@@ -1,337 +0,0 @@
import { expect, test } from '@playwright/test';
import cs from 'convert-stream';
import pdf from 'pdf-parse';
import { createDoc } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Doc Tools', () => {
test('it converts the doc to pdf with a template integrated', async ({
page,
browserName,
}) => {
const [randomDoc] = await createDoc(page, 'doc-editor', browserName, 1);
const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`${randomDoc}.pdf`);
});
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await page.locator('.ProseMirror.bn-editor').click();
await page.locator('.ProseMirror.bn-editor').fill('Hello World');
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
name: 'Generate PDF',
})
.click();
await page
.getByRole('button', {
name: 'Download',
})
.click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
const pdfText = (await pdf(pdfBuffer)).text;
expect(pdfText).toContain('Hello World'); // This is the doc text
});
test('it converts the blocknote json in correct html for the pdf', async ({
page,
browserName,
}) => {
const [randomDoc] = await createDoc(page, 'doc-editor', browserName, 1);
let body = '';
await page.route('**/templates/*/generate-document/', async (route) => {
const request = route.request();
body = request.postDataJSON().body;
await route.continue();
});
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await page.locator('.bn-block-outer').last().fill('Hello World');
await page.locator('.bn-block-outer').last().click();
await page.keyboard.press('Enter');
await page.keyboard.press('Enter');
await page.locator('.bn-block-outer').last().fill('Break');
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
name: 'Generate PDF',
})
.click();
await page
.getByRole('button', {
name: 'Download',
})
.click();
// Empty paragraph should be replaced by a <br/>
expect(body).toContain('<br/><br/>');
});
test('it updates the doc', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(
page,
'doc-update',
browserName,
1,
true,
);
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
name: 'Update document',
})
.click();
await expect(
page.locator('h2').getByText(`Update document "${randomDoc}"`),
).toBeVisible();
await expect(
page.getByRole('checkbox', { name: 'Is it public ?' }),
).toBeChecked();
await page.getByText('Document name').fill(`${randomDoc}-updated`);
await page.getByText('Is it public ?').click();
await page
.getByRole('button', {
name: 'Validate the modification',
})
.click();
await expect(
page.getByText('The document has been updated.'),
).toBeVisible();
const panel = page.getByLabel('Documents panel').first();
await expect(
panel.locator('li').getByText(`${randomDoc}-updated`),
).toBeVisible();
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
name: 'Update document',
})
.click();
await expect(
page.getByRole('checkbox', { name: 'Is it public ?' }),
).not.toBeChecked();
});
test('it deletes the doc', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-delete', browserName, 1);
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
name: 'Delete document',
})
.click();
await expect(
page.locator('h2').getByText(`Deleting the document "${randomDoc}"`),
).toBeVisible();
await page
.getByRole('button', {
name: 'Confirm deletion',
})
.click();
await expect(
page.getByText('The document has been deleted.'),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Create a new document' }),
).toBeVisible();
const panel = page.getByLabel('Documents panel').first();
await expect(panel.locator('li').getByText(randomDoc)).toBeHidden();
});
test('it checks the options available if administrator', async ({
page,
browserName,
}) => {
await page.route('**/documents/**/', async (route) => {
const request = route.request();
if (
request.method().includes('GET') &&
!request.url().includes('page=')
) {
await route.fulfill({
json: {
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
content: '',
title: 'Mocked document',
accesses: [],
abilities: {
destroy: false, // Means not owner
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
manage_accesses: true, // Means admin
update: true,
partial_update: true,
retrieve: true,
},
is_public: false,
},
});
} else {
await route.continue();
}
});
await createDoc(page, 'doc-tools-right-admin', browserName, 1);
await expect(page.locator('h2').getByText('Mocked document')).toBeVisible();
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('button', { name: 'Add members' }),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Generate PDF' }),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Update document' }),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Delete document' }),
).toBeHidden();
});
test('it checks the options available if editor', async ({
page,
browserName,
}) => {
await page.route('**/documents/**/', async (route) => {
const request = route.request();
if (
request.method().includes('GET') &&
!request.url().includes('page=')
) {
await route.fulfill({
json: {
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
content: '',
title: 'Mocked document',
accesses: [],
abilities: {
destroy: false, // Means not owner
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
manage_accesses: false, // Means not admin
update: true,
partial_update: true, // Means editor
retrieve: true,
},
is_public: false,
},
});
} else {
await route.continue();
}
});
await createDoc(page, 'doc-tools-right-editor', browserName, 1);
await expect(page.locator('h2').getByText('Mocked document')).toBeVisible();
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('button', { name: 'Add members' }),
).toBeHidden();
await expect(
page.getByRole('button', { name: 'Generate PDF' }),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Update document' }),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Delete document' }),
).toBeHidden();
});
test('it checks the options available if reader', async ({
page,
browserName,
}) => {
await page.route('**/documents/**/', async (route) => {
const request = route.request();
if (
request.method().includes('GET') &&
!request.url().includes('page=')
) {
await route.fulfill({
json: {
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
content: '',
title: 'Mocked document',
accesses: [],
abilities: {
destroy: false, // Means not owner
versions_destroy: false,
versions_list: true,
versions_retrieve: true,
manage_accesses: false, // Means not admin
update: false,
partial_update: false, // Means not editor
retrieve: true,
},
is_public: false,
},
});
} else {
await route.continue();
}
});
await createDoc(page, 'doc-tools-right-reader', browserName, 1);
await expect(page.locator('h2').getByText('Mocked document')).toBeVisible();
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('button', { name: 'Add members' }),
).toBeHidden();
await expect(
page.getByRole('button', { name: 'Generate PDF' }),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Update document' }),
).toBeHidden();
await expect(
page.getByRole('button', { name: 'Delete document' }),
).toBeHidden();
});
});
@@ -8,11 +8,7 @@ test.describe('Footer', () => {
test('checks all the elements are visible', async ({ page }) => {
const footer = page.locator('footer').first();
await expect(footer.getByAltText('Marianne Logo')).toBeVisible();
await expect(
footer.getByAltText('Freedom Equality Fraternity Logo'),
).toBeVisible();
await expect(footer.getByAltText('Gouvernement Logo')).toBeVisible();
await expect(
footer.getByRole('link', { name: 'legifrance.gouv.fr' }),
@@ -2,19 +2,15 @@ import { expect, test } from '@playwright/test';
import { keyCloakSignIn } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Header', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('checks all the elements are visible', async ({ page }) => {
const header = page.locator('header').first();
await expect(header.getByAltText('Marianne Logo')).toBeVisible();
await expect(
header.getByAltText('Freedom Equality Fraternity Logo'),
).toBeVisible();
await expect(header.getByAltText('Gouvernement Logo')).toBeVisible();
await expect(header.getByAltText('Docs Logo')).toBeVisible();
await expect(header.locator('h2').getByText('Docs')).toHaveCSS(
+9 -5
View File
@@ -1,15 +1,18 @@
{
"name": "app-e2e",
"version": "1.0.0",
"version": "1.2.1",
"private": true,
"scripts": {
"lint": "eslint . --ext .ts",
"install": "playwright install --with-deps",
"install-playwright": "playwright install --with-deps",
"test": "playwright test",
"test:ui": "yarn test --ui"
"test:ui": "yarn test --ui",
"test:ui::firefox": "yarn test:ui --project=firefox",
"test:ui::webkit": "yarn test:ui --project=webkit",
"test:ui::chromium": "yarn test:ui --project=chromium"
},
"devDependencies": {
"@playwright/test": "1.45.0",
"@playwright/test": "1.46.1",
"@types/node": "*",
"@types/pdf-parse": "1.1.4",
"eslint-config-impress": "*",
@@ -17,6 +20,7 @@
},
"dependencies": {
"convert-stream": "1.0.2",
"pdf-parse": "^1.1.1"
"jsdom": "24.1.1",
"pdf-parse": "1.1.1"
}
}
@@ -48,6 +48,7 @@ export default defineConfig({
use: {
...devices['Desktop Chrome'],
locale: 'en-US',
timezoneId: 'Europe/Paris',
storageState: 'playwright/.auth/user-chromium.json',
},
dependencies: ['setup'],
@@ -57,9 +58,20 @@ export default defineConfig({
use: {
...devices['Desktop Safari'],
locale: 'en-US',
timezoneId: 'Europe/Paris',
storageState: 'playwright/.auth/user-webkit.json',
},
dependencies: ['setup'],
},
{
name: 'firefox',
use: {
...devices['Desktop Firefox'],
locale: 'en-US',
timezoneId: 'Europe/Paris',
storageState: 'playwright/.auth/user-firefox.json',
},
dependencies: ['setup'],
},
],
});
+3
View File
@@ -0,0 +1,3 @@
NEXT_PUBLIC_THEME=dsfr
NEXT_PUBLIC_SIGNALING_URL=
NEXT_PUBLIC_API_ORIGIN=
+1
View File
@@ -1 +1,2 @@
NEXT_PUBLIC_API_ORIGIN=http://test.jest
NEXT_PUBLIC_THEME=test-theme
@@ -8,6 +8,10 @@ server {
try_files $uri index.html $uri/ =404;
}
location ~ ^/docs/(.*)/versions/(.*)/$ {
error_page 404 /docs/[id]/versions/[versionId]/;
}
location /docs/ {
error_page 404 /docs/[id]/;
}
+14
View File
@@ -39,6 +39,12 @@ const config = {
xxs: '320px',
xs: '480px',
},
logo: {
src: '',
widthHeader: '',
widthFooter: '',
alt: '',
},
},
components: {
datagrid: {
@@ -268,6 +274,12 @@ const config = {
base: 'Marianne',
},
},
logo: {
src: '/assets/logo-gouv.svg',
widthHeader: '110px',
widthFooter: '220px',
alt: 'Gouvernement Logo',
},
},
components: {
alert: {
@@ -344,6 +356,7 @@ const config = {
'border-color': 'var(--c--theme--colors--primary-text)',
'box-shadow-color': 'var(--c--theme--colors--primary-text)',
'value-color': 'var(--c--theme--colors--primary-text)',
'font-size': '14px',
},
'forms-labelledbox': {
'label-color': {
@@ -351,6 +364,7 @@ const config = {
},
},
'forms-select': {
'item-font-size': '14px',
'border-radius': '4px',
'border-radius-hover': '4px',
'background-color': '#ffffff',
+21 -21
View File
@@ -1,12 +1,12 @@
{
"name": "app-impress",
"version": "1.0.0",
"version": "1.2.1",
"private": true,
"scripts": {
"dev": "next dev",
"build": "prettier --check . && yarn stylelint && next build",
"build:ci": "cp .env.development .env.local && yarn build",
"build-theme": "cunningham -g css,ts -o src/cunningham --utility-classes",
"build-theme": "cunningham -g css,ts -o src/cunningham --utility-classes && yarn prettier",
"start": "npx -y serve@latest out",
"lint": "tsc --noEmit && next lint",
"prettier": "prettier --write .",
@@ -15,36 +15,36 @@
"test:watch": "jest --watch"
},
"dependencies": {
"@blocknote/core": "0.14.2",
"@blocknote/mantine": "0.14.2",
"@blocknote/react": "0.14.2",
"@gouvfr-lasuite/integration": "1.0.1",
"@blocknote/core": "*",
"@blocknote/mantine": "*",
"@blocknote/react": "*",
"@gouvfr-lasuite/integration": "1.0.2",
"@openfun/cunningham-react": "2.9.3",
"@tanstack/react-query": "5.48.0",
"i18next": "23.11.5",
"@tanstack/react-query": "5.51.24",
"i18next": "23.14.0",
"idb": "8.0.0",
"lodash": "4.17.21",
"luxon": "3.4.4",
"next": "14.2.4",
"luxon": "3.5.0",
"next": "14.2.5",
"react": "*",
"react-aria-components": "1.2.1",
"react-aria-components": "1.3.1",
"react-dom": "*",
"react-i18next": "14.1.2",
"react-i18next": "15.0.1",
"react-select": "5.8.0",
"styled-components": "6.1.11",
"styled-components": "6.1.12",
"y-webrtc": "10.3.0",
"yjs": "*",
"zustand": "4.5.4"
"zustand": "4.5.5"
},
"devDependencies": {
"@svgr/webpack": "8.1.0",
"@tanstack/react-query-devtools": "5.48.0",
"@testing-library/dom": "10.2.0",
"@testing-library/jest-dom": "6.4.6",
"@tanstack/react-query-devtools": "5.51.24",
"@testing-library/dom": "10.4.0",
"@testing-library/jest-dom": "6.4.8",
"@testing-library/react": "16.0.0",
"@testing-library/user-event": "14.5.2",
"@types/jest": "29.5.12",
"@types/lodash": "4.17.5",
"@types/lodash": "4.17.7",
"@types/luxon": "3.4.2",
"@types/node": "*",
"@types/react": "18.3.3",
@@ -56,10 +56,10 @@
"jest": "29.7.0",
"jest-environment-jsdom": "29.7.0",
"node-fetch": "2.7.0",
"prettier": "3.3.2",
"stylelint": "16.6.1",
"prettier": "3.3.3",
"stylelint": "16.8.2",
"stylelint-config-standard": "36.0.1",
"stylelint-prettier": "5.0.0",
"stylelint-prettier": "5.0.2",
"typescript": "*",
"workbox-webpack-plugin": "7.1.0"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

@@ -0,0 +1,241 @@
<?xml version="1.0" encoding="utf-8"?>
<svg
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="30 24 260 146.5"
>
<style type="text/css">
.st0 {
fill: #ffffff;
}
.st1 {
fill: #000091;
}
.st2 {
fill: #e1000f;
}
.st3 {
fill: #9c9b9b;
}
</style>
<g id="Calque_1">
<path
id="Devise_Républicaine_1_"
d="M100.5,150.8c0.6,0,1.1,0.4,0.8,1.5l-2.7,0.6C99.1,151.8,99.9,150.8,100.5,150.8 M102,155.2
h-0.5c-0.7,0.8-1.4,1.4-2.1,1.4c-0.7,0-1.1-0.4-1.1-1.4c0-0.4,0-0.8,0.1-1.2l4.3-1.4c0.8-2-0.2-2.9-1.4-2.9c-2,0-4.4,3.4-4.4,6.4
c0,1.3,0.6,2.1,1.6,2.1C99.8,158.2,101,157,102,155.2 M101.2,148.9l3-2.8v-0.3h-1.6l-1.9,3.2H101.2z M91.9,150.9h1.4l-2.3,6.2
c-0.2,0.5,0.1,1.1,0.6,1.1c1.3,0,3.4-1.3,4.1-3h-0.4c-0.6,0.6-1.7,1.4-2.6,1.6l2.1-5.8h2.1l0.3-0.9h-2.1l0.8-2.2h-0.8l-1.5,2.2
l-1.8,0.2V150.9z M89.9,150.6c0.2-0.6-0.2-0.9-0.5-0.9c-1.2,0-2.7,1.1-3.3,2.7h0.4c0.4-0.6,1.1-1.2,1.7-1.3l-2.4,6.2
c-0.2,0.6,0.2,0.9,0.5,0.9c1.2,0,2.6-1.1,3.2-2.7h-0.4c-0.4,0.6-1.1,1.2-1.7,1.3L89.9,150.6z M90.4,147.5c0.6,0,1-0.5,1-1
c0-0.6-0.5-1-1-1c-0.6,0-1,0.5-1,1C89.3,147,89.8,147.5,90.4,147.5 M76.6,157c-0.3,0.7,0,1.2,0.7,1.2c0.4,0,0.6-0.1,0.8-0.6
l1.7-4.4c0.8-0.9,2.3-1.9,3-1.9c0.5,0,0.4,0.4,0.1,0.9l-2.5,4.9c-0.2,0.5,0.1,1.1,0.6,1.1c1.2,0,2.7-1.1,3.3-2.7h-0.4
c-0.4,0.6-1.1,1.2-1.7,1.3l2.2-4.4c0.3-0.6,0.4-1.1,0.4-1.5c0-0.7-0.4-1.2-1.2-1.2c-1.1,0-2.2,1.2-3.5,2.7v-1.2
c0-0.8-0.3-1.6-1-1.6c-0.5,0-0.9,0.4-1.3,1v0.2c0.8,0,1.2,1.2,0.6,2.4L76.6,157z M76.6,151.6c0.3-1,0.1-1.9-0.6-1.9
c-0.9,0-1.2,0.7-2.1,2.7v-1.2c0-0.8-0.3-1.6-1-1.6c-0.9,0-1.7,1.4-2.3,2.7H71c0.4-0.6,0.8-1,1.1-1c0.4,0,0.6,0.6,0,1.9l-1.7,3.7
c-0.3,0.7,0,1.2,0.7,1.2c0.4,0,0.6-0.1,0.8-0.6l1.7-4.4c0.5-0.6,0.9-1.1,1.4-1.6H76.6z M67,150.8c0.6,0,1.1,0.4,0.8,1.5l-2.7,0.6
C65.6,151.8,66.4,150.8,67,150.8 M68.5,155.2H68c-0.7,0.8-1.4,1.4-2.1,1.4c-0.7,0-1.1-0.4-1.1-1.4c0-0.4,0-0.8,0.1-1.2l4.3-1.4
c0.8-2-0.2-2.9-1.4-2.9c-2,0-4.4,3.4-4.4,6.4c0,1.3,0.6,2.1,1.6,2.1C66.3,158.2,67.5,157,68.5,155.2 M58.3,150.9h1.4l-2.3,6.2
c-0.2,0.5,0.1,1.1,0.6,1.1c1.3,0,3.4-1.3,4.1-3h-0.4c-0.6,0.6-1.7,1.4-2.6,1.6l2.1-5.8h2.1l0.3-0.9h-2.1l0.8-2.2h-0.8l-1.5,2.2
l-1.8,0.2V150.9z M50.5,155.7c0-1.9,2.1-4.5,3.3-4.5c0.3,0,0.5,0,0.7,0.1l-1.2,3.3c-0.7,0.9-1.8,1.9-2.3,1.9
C50.7,156.6,50.5,156.3,50.5,155.7 M57,149.3l-0.7,0l-0.7,0.7h-0.2c-3.6,0-6.6,4-6.6,6.9c0,0.8,0.5,1.3,1.3,1.3
c0.9,0,1.8-1.3,2.8-2.7l0,0.5c-0.1,1.4,0.3,2.2,1.1,2.2c0.9,0,1.7-1.4,2.3-2.7h-0.4c-0.4,0.6-0.8,1-1.1,1c-0.3,0-0.6-0.6,0-1.9
L57,149.3z M49.5,151.6c0.3-1,0.1-1.9-0.6-1.9c-0.9,0-1.2,0.7-2.1,2.7v-1.2c0-0.8-0.3-1.6-1-1.6c-0.9,0-1.7,1.4-2.3,2.7h0.4
c0.4-0.6,0.8-1,1.1-1c0.4,0,0.6,0.6,0,1.9l-1.7,3.7c-0.3,0.7,0,1.2,0.7,1.2c0.4,0,0.6-0.1,0.8-0.6l1.7-4.4c0.5-0.6,0.9-1.1,1.4-1.6
H49.5z M37.5,157.9l0.2-0.5c-2.1-0.4-2.3-0.4-1.5-2.6l0.8-2.3h2.3c1,0,1,0.4,0.9,1.5h0.6l1.4-3.8h-0.6c-0.5,0.9-0.9,1.5-2,1.5h-2.3
l1.2-3.3c0.4-1,0.6-1.3,2-1.3h1c1.4,0,1.6,0.4,1.6,1.9h0.6l0.5-2.6h-8.6l-0.2,0.5c1.7,0.3,1.8,0.5,1,2.6l-1.9,5.1
c-0.8,2.1-1.1,2.3-3,2.6l-0.1,0.5H37.5z M79.7,131.4c0.6,0,1.1,0.4,0.8,1.5l-2.7,0.6C78.3,132.3,79.1,131.4,79.7,131.4 M81.2,135.7
h-0.5c-0.7,0.8-1.4,1.4-2.1,1.4c-0.7,0-1.1-0.4-1.1-1.4c0-0.4,0-0.8,0.1-1.2l4.3-1.4c0.8-2-0.2-2.9-1.4-2.9c-2,0-4.4,3.4-4.4,6.4
c0,1.3,0.6,2.1,1.6,2.1C79,138.7,80.2,137.6,81.2,135.7 M80.3,129.4l3-2.8v-0.3h-1.6l-1.9,3.2H80.3z M71,131.4h1.4l-2.3,6.2
c-0.2,0.5,0.1,1.1,0.6,1.1c1.3,0,3.4-1.3,4.1-3h-0.4c-0.6,0.6-1.7,1.4-2.6,1.6l2.1-5.8h2.1l0.3-0.9h-2.1l0.8-2.2h-0.8l-1.5,2.2
l-1.8,0.2V131.4z M69.1,131.1c0.2-0.6-0.2-0.9-0.5-0.9c-1.2,0-2.7,1.1-3.3,2.7h0.4c0.4-0.6,1.1-1.2,1.7-1.3l-2.4,6.2
c-0.2,0.6,0.2,0.9,0.5,0.9c1.2,0,2.6-1.1,3.2-2.7h-0.4c-0.4,0.6-1.1,1.2-1.7,1.3L69.1,131.1z M69.5,128c0.6,0,1-0.5,1-1
c0-0.6-0.5-1-1-1c-0.6,0-1,0.5-1,1C68.5,127.6,68.9,128,69.5,128 M61.2,137.3l4.3-11.4l-0.1-0.2l-2.7,0.3v0.3l0.5,0.4
c0.5,0.4,0.3,0.7-0.1,1.9l-3.4,8.9c-0.2,0.5,0.1,1.1,0.6,1.1c1.2,0,2.6-1.1,3.2-2.7h-0.4C62.7,136.6,61.8,137.2,61.2,137.3
M53,136.3c0-1.9,2.1-4.5,3.3-4.5c0.3,0,0.5,0,0.7,0.1l-1.2,3.3c-0.7,0.9-1.8,1.9-2.3,1.9C53.1,137.1,53,136.8,53,136.3
M59.5,129.9l-0.7,0l-0.7,0.7H58c-3.6,0-6.6,4-6.6,6.9c0,0.8,0.5,1.3,1.3,1.3c0.9,0,1.8-1.3,2.8-2.7l0,0.5
c-0.1,1.4,0.3,2.2,1.1,2.2c0.9,0,1.7-1.4,2.3-2.7h-0.4c-0.4,0.6-0.8,1-1.1,1c-0.3,0-0.6-0.6,0-1.9L59.5,129.9z M43.7,140.2
c0-0.8,0.8-1.3,1.9-1.8c0.4,0.2,0.9,0.4,1.7,0.6c1.2,0.4,1.6,0.5,1.6,0.9c0,0.8-1.3,1.3-3.1,1.3C44.4,141.3,43.7,141,43.7,140.2
M46.9,135.2c-0.5,0-0.7-0.4-0.7-0.9c0-1.4,0.7-3.4,1.9-3.4c0.5,0,0.7,0.4,0.7,0.9C48.8,133.1,48,135.2,46.9,135.2 M50.3,139.4
c0-1-0.9-1.4-2.3-1.8c-1.2-0.4-1.8-0.5-1.8-0.9c0-0.3,0.3-0.7,0.8-1c2-0.1,3.4-1.9,3.4-3.5c0-0.3-0.1-0.6-0.2-0.9h1.7l0.3-0.9h-2.7
c-0.3-0.2-0.7-0.3-1.1-0.3c-2.2,0-3.6,1.9-3.6,3.4c0,1.2,0.7,1.9,1.7,2.1c-1,0.5-1.6,1-1.6,1.6c0,0.4,0.1,0.6,0.4,0.8
c-2.3,0.7-3.3,1.5-3.3,2.6c0,1.1,1.4,1.5,3.1,1.5C47.9,142.3,50.3,140.7,50.3,139.4 M39.4,132.8c1,0,1,0.4,0.9,1.5h0.6l1.4-3.8
h-0.6c-0.5,0.9-0.9,1.5-2,1.5h-2.3l1.1-3.1c0.4-1,0.6-1.2,2-1.2h1c1.4,0,1.6,0.4,1.6,1.8h0.6l0.5-2.6h-8.6l-0.2,0.5
c1.7,0.3,1.8,0.5,1,2.6l-1.9,5.1c-0.8,2.1-1.1,2.3-3,2.6l-0.1,0.5H41l1.7-2.7H42c-1.1,1-2.5,2-4.4,2c-2.5,0-2.3-0.1-1.5-2.4
l0.9-2.5H39.4z M40.6,126.2l3-2.1v-0.3h-1.8l-1.7,2.4H40.6z M78.7,111.9c0.6,0,1.1,0.4,0.8,1.5l-2.7,0.6
C77.3,112.8,78,111.9,78.7,111.9 M80.2,116.2h-0.5c-0.7,0.8-1.4,1.4-2.1,1.4c-0.7,0-1.1-0.4-1.1-1.4c0-0.4,0-0.8,0.1-1.2l4.3-1.4
c0.8-2-0.2-2.9-1.4-2.9c-2,0-4.4,3.4-4.4,6.4c0,1.3,0.6,2.1,1.6,2.1C77.9,119.2,79.2,118.1,80.2,116.2 M79.3,110l3-2.8v-0.3h-1.6
l-1.9,3.2H79.3z M70.4,111.9h1.1l-2.3,6.2c-0.2,0.5,0.1,1.1,0.6,1.1c1.3,0,3.4-1.3,4.1-3h-0.4c-0.6,0.6-1.7,1.4-2.6,1.6l2.1-5.8
h2.1l0.3-0.9h-2.1l0.8-2.2h-0.8l-1.5,2.2l-1.5,0.2V111.9z M69.2,112.6c0.3-1,0.1-1.9-0.6-1.9c-0.9,0-1.2,0.7-2.1,2.7v-1.2
c0-0.8-0.3-1.6-1-1.6c-0.9,0-1.7,1.4-2.3,2.7h0.4c0.4-0.6,0.8-1,1.1-1c0.4,0,0.6,0.6,0,1.9l-1.7,3.7c-0.3,0.7,0,1.2,0.7,1.2
c0.4,0,0.6-0.1,0.8-0.6l1.7-4.4c0.5-0.6,0.9-1.1,1.4-1.6H69.2z M59.7,111.9c0.6,0,1.1,0.4,0.8,1.5l-2.7,0.6
C58.3,112.8,59.1,111.9,59.7,111.9 M61.2,116.2h-0.5c-0.7,0.8-1.4,1.4-2.1,1.4c-0.7,0-1.1-0.4-1.1-1.4c0-0.4,0-0.8,0.1-1.2l4.3-1.4
c0.8-2-0.2-2.9-1.4-2.9c-2,0-4.4,3.4-4.4,6.4c0,1.3,0.6,2.1,1.6,2.1C59,119.2,60.2,118.1,61.2,116.2 M50.6,118c-0.4,0-1-0.4-1-0.7
c0-0.1,0.2-0.6,0.4-1.2l0.7-1.9c0.7-0.9,1.9-1.8,2.5-1.8c0.4,0,0.7,0.2,0.7,0.8C53.9,114.9,52.3,118,50.6,118 M55.5,112.5
c0-1.3-0.5-1.7-1.3-1.7c-1.1,0-2.1,1.2-3.1,2.6l2.6-6.8l-0.1-0.2l-2.7,0.3v0.3l0.5,0.4c0.5,0.4,0.3,0.8-0.1,1.9l-2.8,7.2
c-0.2,0.5-0.5,1.2-0.5,1.3c0,0.7,1,1.4,1.9,1.4C52,119.2,55.5,115.4,55.5,112.5 M47,111.6c0.2-0.6-0.2-0.9-0.5-0.9
c-1.2,0-2.7,1.1-3.3,2.7h0.4c0.4-0.6,1.1-1.2,1.7-1.3l-2.4,6.2c-0.2,0.6,0.2,0.9,0.5,0.9c1.2,0,2.6-1.1,3.2-2.7h-0.4
c-0.4,0.6-1.1,1.2-1.7,1.3L47,111.6z M47.5,108.5c0.6,0,1-0.5,1-1c0-0.6-0.5-1-1-1c-0.6,0-1,0.5-1,1
C46.4,108.1,46.9,108.5,47.5,108.5 M41.2,107.5h-5.7l-0.2,0.5c1.7,0.3,1.8,0.5,1,2.6l-1.8,5.1c-0.8,2.1-1.1,2.3-3,2.6l-0.1,0.5H40
l1.9-3.3h-0.7c-1.1,1.2-2.3,2.6-4.2,2.6c-1.4,0-1.6-0.2-0.8-2.4l1.8-5.1c0.8-2.1,1.1-2.3,3-2.6L41.2,107.5z"
/>
<path
d="M40.9,88.2c1,0,1.9-0.2,2.7-0.5c0.8-0.3,1.5-0.8,2.1-1.3v-3.5h-5.3v-3.8h9.4v8.8c-1,1.3-2.2,2.3-3.8,3.1s-3.3,1.2-5.2,1.2
c-1.7,0-3.2-0.3-4.6-0.9c-1.4-0.6-2.6-1.4-3.6-2.4c-1-1-1.8-2.1-2.3-3.5c-0.6-1.3-0.8-2.7-0.8-4.2c0-1.5,0.3-2.9,0.8-4.2
c0.5-1.3,1.3-2.5,2.2-3.5c1-1,2.1-1.8,3.5-2.4s2.8-0.9,4.5-0.9c1.9,0,3.5,0.4,5,1.2c1.5,0.8,2.7,1.8,3.7,3L46,77
c-0.6-0.8-1.3-1.5-2.3-2.1s-2-0.8-3.1-0.8c-1,0-1.9,0.2-2.7,0.5c-0.8,0.4-1.5,0.9-2.1,1.5c-0.6,0.6-1,1.4-1.4,2.2
c-0.3,0.9-0.5,1.8-0.5,2.8s0.2,1.9,0.5,2.8c0.3,0.9,0.8,1.6,1.4,2.2c0.6,0.6,1.4,1.1,2.2,1.5C39,88,39.9,88.2,40.9,88.2z M64,70.3
c1.6,0,3.1,0.3,4.4,0.9s2.5,1.4,3.5,2.4c1,1,1.7,2.1,2.2,3.5c0.5,1.3,0.8,2.7,0.8,4.2c0,1.5-0.3,2.9-0.8,4.2
c-0.5,1.3-1.3,2.5-2.2,3.5c-1,1-2.1,1.8-3.5,2.4s-2.8,0.9-4.4,0.9c-1.6,0-3.1-0.3-4.5-0.9s-2.5-1.4-3.5-2.4c-1-1-1.7-2.1-2.2-3.5
c-0.5-1.3-0.8-2.7-0.8-4.2c0-1.5,0.3-2.9,0.8-4.2c0.5-1.3,1.3-2.5,2.2-3.5c1-1,2.1-1.8,3.5-2.4S62.4,70.3,64,70.3z M64,88.2
c1,0,1.9-0.2,2.7-0.5c0.8-0.4,1.5-0.9,2.1-1.5c0.6-0.6,1-1.4,1.4-2.2s0.5-1.8,0.5-2.8s-0.2-1.9-0.5-2.8c-0.3-0.9-0.8-1.6-1.4-2.2
c-0.6-0.6-1.3-1.1-2.1-1.5c-0.8-0.4-1.7-0.5-2.7-0.5c-1,0-1.9,0.2-2.7,0.5c-0.8,0.4-1.5,0.9-2.1,1.5c-0.6,0.6-1,1.4-1.4,2.2
c-0.3,0.9-0.5,1.8-0.5,2.8s0.2,1.9,0.5,2.8c0.3,0.9,0.8,1.6,1.4,2.2c0.6,0.6,1.3,1.1,2.1,1.5C62.1,88,63,88.2,64,88.2z M91.1,83.8
V70.9h4.2v12.6c0,2.7-0.8,4.8-2.3,6.4c-1.5,1.5-3.5,2.3-6.1,2.3c-2.6,0-4.6-0.8-6.1-2.3s-2.2-3.7-2.2-6.4V70.9h4.2v12.9
c0,1.4,0.4,2.5,1.1,3.2c0.7,0.8,1.8,1.2,3.1,1.2c1.3,0,2.3-0.4,3-1.2C90.8,86.3,91.1,85.2,91.1,83.8z M97.9,70.9h4.5l6.1,16.1
l6.1-16.1h4.5l-7.8,20.7h-5.5L97.9,70.9z M122.2,91.5V70.9h12v3.6h-7.8v4.8h6.7v3.6h-6.7V88h7.8v3.6H122.2z M139.1,91.5V70.9h6.3
c2.3,0,4.1,0.6,5.4,1.7c1.3,1.1,2,2.6,2,4.5c0,1.2-0.3,2.3-0.9,3.2c-0.6,0.9-1.4,1.6-2.4,2.1l6.5,9.1h-5l-5.5-8.3h-2.2v8.3H139.1z
M145.7,74.4h-2.4v5.2h2.4c0.9,0,1.6-0.2,2.1-0.7c0.5-0.5,0.7-1.1,0.7-1.9c0-0.8-0.2-1.4-0.7-1.9C147.2,74.7,146.6,74.4,145.7,74.4
z M158.6,91.5V70.9h5.4l9.2,14.8V70.9h4.2v20.7H172l-9.2-14.8v14.8H158.6z M183.1,91.5V70.9h12v3.6h-7.8v4.8h6.7v3.6h-6.7V88h7.8
v3.6H183.1z M200,91.5V70.9h5.3l5,8.5l5-8.5h5.3v20.7h-4.2V76.8l-4.6,7.6h-3l-4.6-7.6v14.7H200z M226.3,91.5V70.9h12v3.6h-7.8v4.8
h6.7v3.6h-6.7V88h7.8v3.6H226.3z M243.2,91.5V70.9h5.4l9.2,14.8V70.9h4.2v20.7h-5.4l-9.2-14.8v14.8H243.2z M265.7,74.7v-3.8h16.9
v3.8h-6.4v16.8H272V74.7H265.7z"
/>
<g id="Marianne">
<path
id="Fond_2_"
class="st0"
d="M63.6,53.4c0.3-0.3,0.6-0.6,0.9-1h0c0.6-0.6,1.1-1.3,1.8-1.8c0.2-0.2,0.4-0.3,0.6-0.5
c0.1-0.1,0.1-0.2,0.1-0.2c-0.3,0.1-0.4,0.3-0.7,0.4c-0.1,0-0.1-0.1-0.1-0.1c0.2-0.1,0.4-0.3,0.6-0.4c0,0,0,0,0,0
c-0.1,0-0.1-0.1-0.1-0.1c-0.7-0.1-1.2,0.4-1.7,0.8c-0.1,0.1-0.2-0.1-0.3-0.1c-0.8,0.3-1.4,1-2.2,1.3v-0.1c-0.3,0.1-0.6,0.3-1,0.4
c-0.5,0.1-0.9,0.1-1.3,0.1c-0.6,0.1-1.3,0.2-1.9,0.3c0,0,0,0-0.1,0c-0.3,0.1-0.7,0.2-1,0.4c0,0,0,0,0,0c0,0-0.1,0.1-0.1,0.1
c-0.1,0.1-0.2,0.2-0.4,0.3c-0.3,0.2-0.6,0.5-0.9,0.7c0,0-0.1,0-0.1,0c-0.3,0.3-0.6,0.6-0.9,0.8c0,0-0.1,0-0.2,0c0,0,0,0,0,0
c0,0,0,0,0-0.1c0-0.1,0.1-0.2,0.1-0.2c0.1-0.1,0.1-0.2,0.2-0.2c0.1-0.1,0.1-0.2,0.2-0.3c0,0,0-0.1,0-0.1c0,0,0,0-0.1,0
c0.3-0.3,0.6-0.5,0.9-0.7v0c0,0-0.1,0-0.1-0.1c0,0,0.1-0.1,0.1-0.1c0,0,0,0,0,0c0,0,0,0,0,0c-0.1,0.1-0.2,0.1-0.3,0.2
c-0.1,0.1-0.2,0.4-0.4,0.3c0,0-0.1,0-0.1,0c0,0,0,0-0.1,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0
c0,0,0,0,0,0c0,0,0,0,0-0.1c0,0,0,0,0,0c0,0,0-0.1,0.1-0.1c0,0,0,0,0-0.1c0,0,0-0.1,0.1-0.1c0,0,0-0.1,0-0.1
c0.1-0.1,0.2-0.2,0.4-0.3h0c0.2-0.1,0.4-0.2,0.6-0.3c0,0,0.1-0.1,0.1-0.1c-0.3,0.1-0.6,0.2-0.9,0.4c0,0-0.1,0-0.1,0
c0,0-0.1,0-0.1,0c0,0,0,0,0,0c0.1-0.1,0.2-0.2,0.3-0.3c0.1,0,0.1,0,0.1,0.1c1.7-1.3,4.1-1,6.1-1.7c0.2-0.1,0.3-0.2,0.5-0.3
c0.3-0.1,0.5-0.4,0.8-0.5c0.4-0.3,0.7-0.7,0.9-1.2c0-0.1-0.1-0.1-0.1-0.1c-0.7,0.8-1.5,1.3-2.4,1.8c-1.1,0.6-2.4,0.5-3.5,0.6
c0.1-0.1,0.2-0.1,0.3-0.1c0-0.2,0.1-0.2,0.2-0.3H59c0.1,0,0.1-0.1,0.1-0.1c0.1,0,0.3-0.1,0.2-0.1c-0.2-0.2-0.5,0.2-0.8,0
c0.1-0.1,0.1-0.3,0.2-0.3h0.2c0-0.1,0.1-0.2,0.1-0.2c0.8-0.5,1.6-0.9,2.3-1.3c-0.2,0-0.3,0.2-0.4,0.1c0.1,0,0-0.2,0.1-0.2
c0.6-0.2,1.1-0.5,1.7-0.7c-0.2,0-0.4,0.2-0.6,0c0.1-0.1,0.2-0.2,0.3-0.2v-0.2c0-0.1,0.1-0.1,0.1-0.1c-0.1,0-0.1-0.1-0.1-0.1
c0.1-0.1,0.2-0.1,0.3-0.2c-0.1,0-0.2,0-0.2-0.1c0.2-0.2,0.4-0.3,0.7-0.3c-0.1-0.1-0.2,0-0.2-0.1c0-0.1,0.1-0.1,0.1-0.1H63
c-0.1-0.1-0.1-0.2-0.1-0.2c0.3-0.4,0.3-0.9,0.5-1.3c-0.1,0-0.1,0-0.1-0.1c-0.5,0.6-1.4,0.8-2.2,1h-0.3c-0.3,0.1-0.6,0.1-0.9-0.1
c-0.2-0.1-0.3-0.3-0.5-0.4c-0.4-0.3-0.9-0.5-1.3-0.6c-1.3-0.4-2.7-0.6-4.1-0.6c0.6-0.3,1.2-0.3,1.9-0.5c0.9-0.3,1.8-0.6,2.7-0.5
c-0.2-0.1-0.4,0-0.5,0c-0.8-0.1-1.5,0.2-2.3,0.3c-0.5,0.1-1,0.3-1.6,0.4c-0.3,0.1-0.5,0.4-0.9,0.4v-0.2c0.5-0.6,1.2-1.3,2-1.3
c1-0.2,1.9,0,2.8,0.1c0.7,0.1,1.3,0.2,2,0.4c0.3,0,0.3,0.4,0.5,0.5c0.3,0.1,0.6,0,1,0.2c0-0.1-0.1-0.2,0-0.3
c0.2-0.2,0.5,0.1,0.7-0.1c0.4-0.3-0.4-0.7-0.6-1.1c0-0.1,0.1-0.1,0.1-0.1c0.4,0.4,0.7,0.8,1.3,1.1c0.3,0.1,0.9,0.3,0.8-0.1
c-0.3-0.6-0.8-1.1-1.2-1.6v-0.2c-0.1,0-0.1-0.1-0.2-0.1v-0.2c-0.2-0.1-0.2-0.3-0.3-0.5c-0.2-0.3-0.1-0.6-0.2-1
C62.1,39.3,62,39,62,38.7c-0.2-0.9-0.4-1.7-0.5-2.6c-0.1-1,0.6-1.8,1.1-2.7c0.4-0.6,0.8-1.3,1.5-1.7c0.2-0.6,0.6-1.2,1-1.7
c0.4-0.5,1.1-0.8,1.7-1.1c0.7-0.3,1.4-0.5,1.4-0.5l10.7,0c0,0,0.1,0,0.3,0.1c0.2,0.1,0.6,0.3,0.8,0.4c0.4,0.2,0.8,0.5,1,0.9
c0.1,0.2,0.3,0.5,0.2,0.7c-0.1,0.3-0.2,0.7-0.4,0.8c-0.3,0.2-0.7,0.2-1.1,0.1c-0.2,0-0.4-0.1-0.6-0.1c0.8,0.3,1.6,0.7,2.1,1.4
c0.1,0.1,0.3,0.2,0.5,0.2c0.1,0,0.1,0.1,0.1,0.2c-0.1,0.1-0.2,0.2-0.2,0.3h0.2c0.3-0.1,0.2-0.6,0.6-0.5c0.3,0.2,0.4,0.5,0.2,0.8
c-0.2,0.2-0.4,0.4-0.6,0.5c-0.1,0.1-0.1,0.3,0,0.4c0.2,0.2,0.2,0.4,0.3,0.6c0.2,0.4,0.2,0.8,0.4,1.2c0.2,0.8,0.4,1.6,0.4,2.4
c0,0.4-0.2,0.8-0.1,1.2c0.1,0.4,0.4,0.8,0.6,1.1c0.2,0.3,0.4,0.5,0.6,0.9c0.3,0.5,0.9,1.1,0.6,1.7c-0.2,0.4-0.8,0.3-1.1,0.5
c-0.3,0.3-0.1,0.7,0.1,1c0.3,0.5-0.3,0.8-0.7,1c0.1,0.2,0.3,0.1,0.4,0.2c0.1,0.3,0.3,0.4,0.2,0.7c-0.2,0.3-0.9,0.5-0.5,1
c0.2,0.4,0.1,0.8-0.1,1.2c-0.2,0.5-0.6,0.7-1,0.8c-0.3,0.1-0.7,0.1-1,0.1c-0.1-0.1-0.2-0.1-0.3-0.1c-0.9-0.1-1.8-0.4-2.7-0.4
c-0.3,0.1-0.5,0.1-0.7,0.2c-0.2,0.2-0.5,0.4-0.6,0.6c0,0,0,0,0,0c0,0-0.1,0.1-0.1,0.1c0,0,0,0.1-0.1,0.1c0,0,0,0,0,0.1
c-0.2,0.2-0.3,0.4-0.4,0.6c0,0,0,0,0,0c0,0,0,0.1,0,0.1c-0.2,0.3-0.3,0.7-0.4,1c-0.4,1.2-0.2,2.3,0.1,2.5c0.1,0.1,1.8,0.6,2.9,1.1
c0.6,0.2,0.9,0.4,1.3,0.6l-22,0c1-0.7,2-1.1,3.5-1.8C61.6,54.6,63.1,53.9,63.6,53.4 M55.4,49.5c-0.1,0-0.3,0.1-0.3-0.1
c0.1-0.3,0.4-0.3,0.6-0.4c0.1-0.1,0.3-0.2,0.4-0.1c0.1,0.2,0.3,0.1,0.4,0.2C56.2,49.5,55.8,49.4,55.4,49.5 M47.2,48.3
c0,0-0.1-0.1-0.1-0.1c0.7-0.9,1.2-1.8,1.7-2.7c0.7-0.4,1.3-0.9,1.8-1.5c0.9-1,1.9-1.8,3-2.4c0.4-0.2,1-0.1,1.4,0.1
c-0.2,0.2-0.4,0.2-0.6,0.3c-0.1,0-0.1,0-0.2-0.1c0.1-0.1,0.1-0.1,0.1-0.2c-0.5,0.6-1.3,0.9-1.7,1.6c-0.3,0.5-0.5,1.2-1.2,1.4
c-0.2,0.1,0.1-0.2-0.1-0.1C49.6,45.7,48.5,46.9,47.2,48.3 M51.6,44.8c-0.1,0.1-0.1,0.1-0.2,0.2c-0.1,0.1-0.1,0.2-0.2,0.2
c-0.1,0-0.1,0-0.1-0.1c0.1-0.2,0.2-0.4,0.4-0.5C51.6,44.7,51.6,44.8,51.6,44.8 M54.1,52.8c0,0.1-0.1,0.1-0.1,0.2
c0.1,0,0.1,0,0.1,0.1c-0.1,0.1-0.2,0.2-0.4,0.3c0,0,0,0-0.1,0c-0.1,0.1-0.1,0.1-0.2,0.2c-0.1,0.1-0.4,0-0.3-0.1
c0.1-0.1,0.3-0.3,0.4-0.4c0.1-0.1,0.2-0.1,0.2-0.2c0,0,0.1-0.1,0.1-0.1C53.9,52.8,54.2,52.7,54.1,52.8 M53.2,52.4
C53.2,52.4,53.1,52.4,53.2,52.4c-0.2,0.2-0.4,0.3-0.6,0.4c-0.2,0.1-0.5,0.2-0.7,0.3c0,0,0,0,0,0c0,0-0.1,0-0.1,0
c-0.2,0.1-0.4,0.3-0.5,0.4c0,0,0,0-0.1,0.1c0,0,0,0,0,0c0,0,0,0,0,0c0,0-0.1,0.1-0.1,0.1c0,0,0,0,0,0c0,0,0,0,0,0
c0,0-0.1,0.1-0.1,0.1c0,0,0,0.1-0.1,0.1c0,0-0.1,0-0.1,0c0,0,0,0,0,0c0,0-0.1,0-0.1,0c0,0-0.1,0-0.1,0c0,0,0,0,0,0c0,0,0,0,0,0
c-0.1,0.1-0.1,0.1-0.2,0.2c-0.1,0.1-0.2,0.2-0.3,0.3c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0.1
l0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0-0.1,0-0.1l0,0c0,0,0,0,0,0c0.1-0.1,0.1-0.1,0.2-0.2c0,0,0,0,0,0c0,0,0,0,0.1-0.1
c0,0,0.1-0.1,0.1-0.1c0,0,0,0,0,0c0.1-0.1,0.1-0.2,0.2-0.2c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0.1-0.1,0.1-0.1c0,0,0-0.1,0.1-0.1
c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0-0.1,0.1-0.1c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0-0.1c0,0,0,0,0,0c0,0,0-0.1,0-0.1
c0,0,0,0,0,0c0.1-0.1,0.1-0.2,0.2-0.3c0,0,0,0,0,0c-0.1,0-0.1,0.1-0.2,0.2c-0.1,0-0.2,0-0.1-0.1c0,0,0.1-0.1,0.1-0.1c0,0,0,0,0,0
c0.1-0.1,0.2-0.2,0.3-0.3c0.1,0,0.1-0.1,0.2-0.1c0,0,0,0,0,0c0,0,0.1-0.1,0.1-0.1c0,0,0,0,0,0c0.5-0.5,1.4-0.5,2.1-0.8
c0.3-0.1,0.6,0.1,0.9,0c0.2,0,0.3,0,0.5,0.1C54,51.8,53.6,52.1,53.2,52.4 M54.3,48.7c-0.1-0.1,0.2,0,0.2-0.1H54
c-0.1,0-0.1-0.1-0.1-0.1c-0.3,0.1-0.6,0.2-0.9,0.2c-0.4,0.1-0.7,0.4-1.1,0.5c-0.6,0.2-1.1,0.7-1.7,0.9c-0.1,0-0.1-0.1-0.1-0.1
c0.1-0.2,0.3-0.2,0.4-0.4c0-0.1,0-0.1-0.1-0.1c0.4-0.6,1-0.9,1.6-1.4v-0.2c0.2-0.2,0.4-0.3,0.5-0.6c0.1-0.2,0.3-0.4,0.5-0.5
c-0.1-0.1-0.2-0.1-0.2-0.2c-0.2,0-0.4,0.1-0.6-0.1c0.1-0.1,0.2-0.2,0.3-0.2c0,0-0.1,0-0.1-0.1c-0.1-0.1,0.1-0.2,0.3-0.3
c0.2-0.1,0.5-0.1,0.6-0.2c-0.4-0.1-0.8,0.1-1.2-0.1c0.3-0.7,0.7-1.3,1.3-1.6c0.1,0,0.2,0,0.2,0.1c0,0.3-0.2,0.5-0.4,0.5
c0.4,0.1,0.9,0.1,1.3,0.3c-0.1,0.1-0.2,0.1-0.2,0.1c0.3,0.2,0.6,0.1,0.9,0.3c-0.2,0.2-0.3,0-0.5,0c1.7,0.5,3.4,0.9,4.8,1.9
c-1.2,0.6-2.4,0.9-3.7,1.1c-0.2,0-0.3,0-0.4-0.1c0,0.1,0,0.2-0.1,0.2c-0.2,0-0.4,0-0.5,0.1C54.7,48.8,54.4,48.8,54.3,48.7"
/>
<path
id="Rouge_1_"
class="st1"
d="M63.6,53.4c0.3-0.3,0.6-0.6,0.9-1h0c0.6-0.6,1.1-1.3,1.8-1.8c0.2-0.2,0.4-0.3,0.6-0.5
c0.1-0.1,0.1-0.2,0.1-0.2c-0.3,0.1-0.4,0.3-0.7,0.4c-0.1,0-0.1-0.1-0.1-0.1c0.2-0.1,0.4-0.3,0.6-0.4c0,0,0,0,0,0
c-0.1,0-0.1-0.1-0.1-0.1c-0.7-0.1-1.2,0.4-1.7,0.8c-0.1,0.1-0.2-0.1-0.3-0.1c-0.8,0.3-1.4,1-2.2,1.3v-0.1c-0.3,0.1-0.6,0.3-1,0.4
c-0.5,0.1-0.9,0.1-1.3,0.1c-0.6,0.1-1.3,0.2-1.9,0.3c0,0,0,0-0.1,0c-0.3,0.1-0.7,0.2-1,0.4c0,0,0,0,0,0c0,0-0.1,0.1-0.1,0.1
c-0.1,0.1-0.2,0.2-0.4,0.3c-0.3,0.2-0.6,0.5-0.9,0.7c0,0-0.1,0-0.1,0c-0.3,0.3-0.6,0.6-0.9,0.8c0,0-0.1,0-0.2,0c0,0,0,0,0,0
c0,0,0,0,0-0.1c0-0.1,0.1-0.2,0.1-0.2c0.1-0.1,0.1-0.2,0.2-0.2c0.1-0.1,0.1-0.2,0.2-0.3c0,0,0-0.1,0-0.1c0,0,0,0-0.1,0
c0.3-0.3,0.6-0.5,0.9-0.7v0c0,0-0.1,0-0.1-0.1c0,0,0.1-0.1,0.1-0.1c0,0,0,0,0,0c0,0,0,0,0,0c-0.1,0.1-0.2,0.1-0.3,0.2
c-0.1,0.1-0.2,0.4-0.4,0.3c0,0-0.1,0-0.1,0c0,0,0,0-0.1,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0
c0,0,0,0,0,0c0,0,0,0,0-0.1c0,0,0,0,0,0c0,0,0-0.1,0.1-0.1c0,0,0,0,0-0.1c0,0,0-0.1,0.1-0.1c0,0,0-0.1,0-0.1
c0.1-0.1,0.2-0.2,0.4-0.3h0c0.2-0.1,0.4-0.2,0.6-0.3c0,0,0.1-0.1,0.1-0.1c-0.3,0.1-0.6,0.2-0.9,0.4c0,0-0.1,0-0.1,0
c0,0-0.1,0-0.1,0c0,0,0,0,0,0c0.1-0.1,0.2-0.2,0.3-0.3c0.1,0,0.1,0,0.1,0.1c1.7-1.3,4.1-1,6.1-1.7c0.2-0.1,0.3-0.2,0.5-0.3
c0.3-0.1,0.5-0.4,0.8-0.5c0.4-0.3,0.7-0.7,0.9-1.2c0-0.1-0.1-0.1-0.1-0.1c-0.7,0.8-1.5,1.3-2.4,1.8c-1.1,0.6-2.4,0.5-3.5,0.6
c0.1-0.1,0.2-0.1,0.3-0.1c0-0.2,0.1-0.2,0.2-0.3H59c0.1,0,0.1-0.1,0.1-0.1c0.1,0,0.3-0.1,0.2-0.1c-0.2-0.2-0.5,0.2-0.8,0
c0.1-0.1,0.1-0.3,0.2-0.3h0.2c0-0.1,0.1-0.2,0.1-0.2c0.8-0.5,1.6-0.9,2.3-1.3c-0.2,0-0.3,0.2-0.4,0.1c0.1,0,0-0.2,0.1-0.2
c0.6-0.2,1.1-0.5,1.7-0.7c-0.2,0-0.4,0.2-0.6,0c0.1-0.1,0.2-0.2,0.3-0.2v-0.2c0-0.1,0.1-0.1,0.1-0.1c-0.1,0-0.1-0.1-0.1-0.1
c0.1-0.1,0.2-0.1,0.3-0.2c-0.1,0-0.2,0-0.2-0.1c0.2-0.2,0.4-0.3,0.7-0.3c-0.1-0.1-0.2,0-0.2-0.1c0-0.1,0.1-0.1,0.1-0.1H63
c-0.1-0.1-0.1-0.2-0.1-0.2c0.3-0.4,0.3-0.9,0.5-1.3c-0.1,0-0.1,0-0.1-0.1c-0.5,0.6-1.4,0.8-2.2,1h-0.3c-0.3,0.1-0.6,0.1-0.9-0.1
c-0.2-0.1-0.3-0.3-0.5-0.4c-0.4-0.3-0.9-0.5-1.3-0.6c-1.3-0.4-2.7-0.6-4.1-0.6c0.6-0.3,1.2-0.3,1.9-0.5c0.9-0.3,1.8-0.6,2.7-0.5
c-0.2-0.1-0.4,0-0.5,0c-0.8-0.1-1.5,0.2-2.3,0.3c-0.5,0.1-1,0.3-1.6,0.4c-0.3,0.1-0.5,0.4-0.9,0.4V44c0.5-0.6,1.2-1.3,2-1.3
c1-0.2,1.9,0,2.8,0.1c0.7,0.1,1.3,0.2,2,0.4c0.3,0,0.3,0.4,0.5,0.5c0.3,0.1,0.6,0,1,0.2c0-0.1-0.1-0.2,0-0.3
c0.2-0.2,0.5,0.1,0.7-0.1c0.4-0.3-0.4-0.7-0.6-1.1c0-0.1,0.1-0.1,0.1-0.1c0.4,0.4,0.7,0.8,1.3,1.1c0.3,0.1,0.9,0.3,0.8-0.1
c-0.3-0.6-0.8-1.1-1.2-1.6v-0.2c-0.1,0-0.1-0.1-0.2-0.1v-0.2c-0.2-0.1-0.2-0.3-0.3-0.5c-0.2-0.3-0.1-0.6-0.2-1
C62.1,39.3,62,39,62,38.7c-0.2-0.9-0.4-1.7-0.5-2.6c-0.1-1,0.6-1.8,1.1-2.7c0.4-0.6,0.8-1.3,1.5-1.7c0.2-0.6,0.6-1.2,1-1.7
c0.4-0.5,1.1-0.8,1.7-1.1c0.7-0.3,1.4-0.5,1.4-0.5H31.4v28.3h26c1-0.7,2-1.1,3.5-1.8C61.6,54.6,63.1,53.9,63.6,53.4 M55.4,49.5
c-0.1,0-0.3,0.1-0.3-0.1c0.1-0.3,0.4-0.3,0.6-0.4c0.1-0.1,0.3-0.2,0.4-0.1c0.1,0.2,0.3,0.1,0.4,0.2C56.2,49.5,55.8,49.4,55.4,49.5
M47.2,48.3c0,0-0.1-0.1-0.1-0.1c0.7-0.9,1.2-1.8,1.7-2.7c0.7-0.4,1.3-0.9,1.8-1.5c0.9-1,1.9-1.8,3-2.4c0.4-0.2,1-0.1,1.4,0.1
c-0.2,0.2-0.4,0.2-0.6,0.3c-0.1,0-0.1,0-0.2-0.1c0.1-0.1,0.1-0.1,0.1-0.2c-0.5,0.6-1.3,0.9-1.7,1.6c-0.3,0.5-0.5,1.2-1.2,1.4
c-0.2,0.1,0.1-0.2-0.1-0.1C49.7,45.7,48.5,46.9,47.2,48.3 M51.6,44.8c-0.1,0.1-0.1,0.1-0.2,0.2c-0.1,0.1-0.1,0.2-0.2,0.2
c-0.1,0-0.1,0-0.1-0.1c0.1-0.2,0.2-0.4,0.4-0.5C51.6,44.7,51.6,44.8,51.6,44.8 M54.1,52.8c0,0.1-0.1,0.1-0.1,0.2
c0.1,0,0.1,0,0.1,0.1c-0.1,0.1-0.2,0.2-0.4,0.3c0,0,0,0-0.1,0c-0.1,0.1-0.1,0.1-0.2,0.2c-0.1,0.1-0.4,0-0.3-0.1
c0.1-0.1,0.3-0.3,0.4-0.4c0.1-0.1,0.2-0.1,0.2-0.2c0,0,0.1-0.1,0.1-0.1C53.9,52.8,54.2,52.7,54.1,52.8 M53.2,52.4
C53.2,52.4,53.2,52.4,53.2,52.4c-0.2,0.2-0.4,0.3-0.6,0.4c-0.2,0.1-0.5,0.2-0.7,0.3c0,0,0,0,0,0c0,0-0.1,0-0.1,0
c-0.2,0.1-0.4,0.3-0.5,0.4c0,0,0,0-0.1,0.1c0,0,0,0,0,0c0,0,0,0,0,0c0,0-0.1,0.1-0.1,0.1c0,0,0,0,0,0c0,0,0,0,0,0
c0,0-0.1,0.1-0.1,0.1c0,0,0,0.1-0.1,0.1c0,0-0.1,0-0.1,0c0,0,0,0,0,0c0,0-0.1,0-0.1,0c0,0-0.1,0-0.1,0c0,0,0,0,0,0c0,0,0,0,0,0
c-0.1,0.1-0.1,0.1-0.2,0.2c-0.1,0.1-0.2,0.2-0.3,0.3c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0.1
l0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0-0.1,0-0.1l0,0c0,0,0,0,0,0c0.1-0.1,0.1-0.1,0.2-0.2c0,0,0,0,0,0c0,0,0,0,0.1-0.1
c0,0,0.1-0.1,0.1-0.1c0,0,0,0,0,0c0.1-0.1,0.1-0.2,0.2-0.2c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0.1-0.1,0.1-0.1c0,0,0-0.1,0.1-0.1
c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0-0.1,0.1-0.1c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0-0.1c0,0,0,0,0,0c0,0,0-0.1,0-0.1
c0,0,0,0,0,0c0.1-0.1,0.1-0.2,0.2-0.3c0,0,0,0,0,0c-0.1,0-0.1,0.1-0.2,0.2c-0.1,0-0.2,0-0.1-0.1c0,0,0.1-0.1,0.1-0.1c0,0,0,0,0,0
c0.1-0.1,0.2-0.2,0.3-0.3c0.1,0,0.1-0.1,0.2-0.1c0,0,0,0,0,0c0,0,0.1-0.1,0.1-0.1c0,0,0,0,0,0c0.5-0.5,1.4-0.5,2.1-0.8
c0.3-0.1,0.6,0.1,0.9,0c0.2,0,0.3,0,0.5,0.1C54,51.8,53.6,52.1,53.2,52.4 M54.3,48.7c-0.1-0.1,0.2,0,0.2-0.1H54
c-0.1,0-0.1-0.1-0.1-0.1c-0.3,0.1-0.6,0.2-0.9,0.2c-0.4,0.1-0.7,0.4-1.1,0.5c-0.6,0.2-1.1,0.7-1.7,0.9c-0.1,0-0.1-0.1-0.1-0.1
c0.1-0.2,0.3-0.2,0.4-0.4c0-0.1,0-0.1-0.1-0.1c0.4-0.6,1-0.9,1.6-1.4v-0.2c0.2-0.2,0.4-0.3,0.5-0.6c0.1-0.2,0.3-0.4,0.5-0.5
c-0.1-0.1-0.2-0.1-0.2-0.2c-0.2,0-0.4,0.1-0.6-0.1c0.1-0.1,0.2-0.2,0.3-0.2c0,0-0.1,0-0.1-0.1c-0.1-0.1,0.1-0.2,0.3-0.3
c0.2-0.1,0.5-0.1,0.6-0.2c-0.4-0.1-0.8,0.1-1.2-0.1c0.3-0.7,0.7-1.3,1.3-1.6c0.1,0,0.2,0,0.2,0.1c0,0.3-0.2,0.5-0.4,0.5
c0.4,0.1,0.9,0.1,1.3,0.3c-0.1,0.1-0.2,0.1-0.2,0.1c0.3,0.2,0.6,0.1,0.9,0.3c-0.2,0.2-0.3,0-0.5,0c1.7,0.5,3.4,0.9,4.8,1.9
c-1.2,0.6-2.4,0.9-3.7,1.1c-0.2,0-0.3,0-0.4-0.1c0,0.1,0,0.2-0.1,0.2c-0.2,0-0.4,0-0.5,0.1C54.7,48.8,54.4,48.8,54.3,48.7"
/>
<path
id="Bleu_1_"
class="st2"
d="M109.3,28.4H78.9c0,0,0.1,0,0.3,0.1c0.2,0.1,0.6,0.3,0.8,0.4c0.4,0.2,0.8,0.5,1,0.9
c0.1,0.2,0.3,0.5,0.2,0.7c-0.1,0.3-0.2,0.7-0.4,0.8c-0.3,0.2-0.7,0.2-1.1,0.1c-0.2,0-0.4-0.1-0.6-0.1c0.8,0.3,1.6,0.7,2.1,1.4
c0.1,0.1,0.3,0.2,0.5,0.2c0.1,0,0.1,0.1,0.1,0.2c-0.1,0.1-0.2,0.2-0.2,0.3h0.2c0.3-0.1,0.2-0.6,0.6-0.5c0.3,0.2,0.4,0.5,0.2,0.8
c-0.2,0.2-0.4,0.4-0.6,0.5c-0.1,0.1-0.1,0.3,0,0.4c0.2,0.2,0.2,0.4,0.3,0.6c0.2,0.4,0.2,0.8,0.4,1.2c0.2,0.8,0.4,1.6,0.4,2.4
c0,0.4-0.2,0.8-0.1,1.2c0.1,0.4,0.4,0.8,0.6,1.1c0.2,0.3,0.4,0.5,0.6,0.9c0.3,0.5,0.9,1.1,0.6,1.7c-0.2,0.4-0.8,0.3-1.1,0.5
c-0.3,0.3-0.1,0.7,0.1,1c0.3,0.5-0.3,0.8-0.7,1c0.1,0.2,0.3,0.1,0.4,0.2c0.1,0.3,0.3,0.4,0.2,0.7c-0.2,0.3-0.9,0.5-0.5,1
c0.2,0.4,0.1,0.8-0.1,1.2c-0.2,0.5-0.6,0.7-1,0.8c-0.3,0.1-0.7,0.1-1,0.1c-0.1-0.1-0.2-0.1-0.3-0.1c-0.9-0.1-1.8-0.4-2.7-0.4
c-0.3,0.1-0.5,0.1-0.7,0.2c-0.2,0.2-0.5,0.4-0.6,0.6c0,0,0,0,0,0c0,0-0.1,0.1-0.1,0.1c0,0,0,0.1-0.1,0.1c0,0,0,0,0,0.1
c-0.2,0.2-0.3,0.4-0.4,0.6c0,0,0,0,0,0c0,0,0,0.1,0,0.1c-0.2,0.3-0.3,0.7-0.4,1c-0.4,1.2-0.2,2.3,0.1,2.5c0.1,0.1,1.8,0.6,2.9,1.1
c0.6,0.2,0.9,0.4,1.3,0.6h29.9V28.4z"
/>
<path
id="Yeux_1_"
class="st3"
d="M80.7,38.7c0.2,0.1,0.5,0.1,0.5,0.2c-0.1,0.4-0.7,0.5-1.1,1H80c-0.2,0.1-0.1,0.4-0.3,0.4
c-0.2-0.1-0.3,0-0.5,0.1c0.2,0.2,0.5,0.4,0.8,0.3c0.1,0,0.2,0.1,0.2,0.2c0,0,0.1,0,0.1-0.1c0.1,0,0.1,0,0.1,0.1V41
c-0.2,0.2-0.4,0.1-0.6,0.2c0.4,0.1,0.9,0.1,1.2,0c0.3-0.1,0-0.6,0.2-0.9c-0.1,0,0-0.2-0.1-0.2c0.1-0.1,0.2-0.3,0.3-0.3
c0.1,0,0.3-0.1,0.3-0.2c0-0.1-0.2-0.2-0.2-0.3c0.3-0.2,0.6-0.5,0.5-0.9c-0.1-0.2-0.5-0.2-0.8-0.3c-0.3-0.1-0.6,0-0.9,0.1
c-0.3,0-0.5,0.2-0.8,0.2c-0.4,0.1-0.7,0.3-1,0.5c0.4-0.2,0.8-0.2,1.2-0.3C80.1,38.7,80.3,38.6,80.7,38.7"
/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 23 KiB

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

@@ -0,0 +1,46 @@
import {
DefinedInitialDataInfiniteOptions,
InfiniteData,
QueryKey,
UseQueryOptions,
useInfiniteQuery,
} from '@tanstack/react-query';
import { APIError } from './APIError';
import { APIList } from './types';
export type UseQueryOptionsAPI<Q> = UseQueryOptions<Q, APIError, Q>;
export type DefinedInitialDataInfiniteOptionsAPI<Q> =
DefinedInitialDataInfiniteOptions<
Q,
APIError,
InfiniteData<Q>,
QueryKey,
number
>;
/**
* @param param Used for infinite scroll pagination
* @param queryConfig
* @returns
*/
export const useAPIInfiniteQuery = <T, Q extends { next?: APIList<Q>['next'] }>(
key: string,
api: (props: T & { page: number }) => Promise<Q>,
param: T,
queryConfig?: DefinedInitialDataInfiniteOptionsAPI<Q>,
) => {
return useInfiniteQuery<Q, APIError, InfiniteData<Q>, QueryKey, number>({
initialPageParam: 1,
queryKey: [key, param],
queryFn: ({ pageParam }) =>
api({
...param,
page: pageParam,
}),
getNextPageParam(lastPage, allPages) {
return lastPage.next ? allPages.length + 1 : undefined;
},
...queryConfig,
});
};
@@ -1,4 +1,5 @@
export * from './APIError';
export * from './fetchApi';
export * from './helpers';
export * from './types';
export * from './utils';
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 21 KiB

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