Compare commits

..

59 Commits

Author SHA1 Message Date
Jacques ROUSSEL
0ab3cbbbb8 wip 2025-01-02 16:04:02 +01:00
Jacques ROUSSEL
d9d048a866 📝(self-hosted) add documentation
Add a documentation to deploy a self-hosted visio instance in a
standalone way (witout AI features)
2025-01-02 10:56:00 +01:00
Jacques ROUSSEL
cbc8cff62d 🐛(CI) add helm release action
In order to avoird code duplication we have to release a helm chart
2024-12-24 11:24:07 +01:00
Julien Bouquillon
33d1f3c151 ️(y-provider) reduce sentry tracesSampleRate
Reduce `tracesSampleRate` due to +120k daily events.
2024-12-20 09:52:43 +01:00
Julien Bouquillon
fc4eba2497 ️(frontend) reduce sentry tracesSampleRate
Reduce `tracesSampleRate` due to +120k daily events.
2024-12-20 09:52:43 +01:00
Dominik Kaminski
3e5f27c1d5 🔧(helm) add option to disable default tls setting
Sets an option for those who uses impress
with a different secretName in ingress.
2024-12-19 15:16:16 +01:00
Anthony LC
f2f64f7dd6 🔖(minor) release 1.10.0
Added:
- (backend) add server-to-server API endpoint
to create documents
- (email) white brand email
- (y-provider) create a markdown converter endpoint

Changed:
- ️(docker) improve y-provider image

Fixed:
- ️(e2e) reduce flakiness on e2e tests
2024-12-17 17:54:49 +01:00
Anthony LC
d842800df3 ✏️(email) change the quotation marks around role
The quotation marks around role have been changed
to the wrong ones. This commit fixes this issue.
2024-12-17 17:29:05 +01:00
Anthony LC
1af2ad0ec4 🔧(helm) add conf white brand email
Add the demo configuration for the
white brand email.
2024-12-17 17:29:05 +01:00
Anthony LC
67915151aa (e2e) add a test on doc creation server side
We recently added a new feature to the app, which
is the ability to create a document from server to
server.
Server A will send a request to Server B with
a markdown content, and Server B will create a
the document after converting the markdown to
yjs base64 format.
This test will check all the steps of the process
and assert that the document is displayed correctly
on the frontend in the blocknote editor.
2024-12-17 14:49:23 +01:00
Anthony LC
de25b36a01 🐛(CI) add chrome playwright install
In a recent commit we removed Chrome from the
install of playwright in the CI job test-e2e,
but it is needed, we put it back.
2024-12-17 14:12:41 +01:00
Anthony LC
59e74e6eeb 🐛(e2e) fix flaky tests
3 tests were flags are flaky or bringing flakiness.
We improved them.
2024-12-17 12:42:02 +01:00
Anthony LC
4e7f095b0f ️(e2e) set maxFailures with CI
If a test fails (retries included), the test runner
will stop after reaching maxFailures.
We will not have to wait for all tests to
run to see the results.
2024-12-17 12:42:02 +01:00
Anthony LC
cdea75b87f ️(ci) playwright install
Sometimes Playwwright installation fails on CI,
it seems to arrive when we update the dependency cache.
We will do a general install before installing the
playwright browser to be sure everything is in place,
it should be fast since we have the cache.
We move the playwright installation before setting
the docker container, so we will wait less if we have
to retry the test because of the Playwwright installation.
2024-12-17 12:42:02 +01:00
Anthony LC
6a0d2e21b5 🐛(frontend) fix rerendering when doc is saving
When the document is saved, the blocknote toolbar
was rerendering, causing the toolbar to close
some panels.
It was creating flakiness in the e2e tests, plus
it was not a good user experience.
This commit fixes this issue.
2024-12-17 12:42:02 +01:00
Anthony LC
b79d5fccbc ⬆️(dependencies) update js dependencies 2024-12-16 18:28:37 +01:00
Anthony LC
6d77cb1801 ️(docker) improve y-provider image
Improve y-provider image by having the
node_modules as small as possible.
We move split the Dockerfile and
add it to the y-provider folder,
it will be easier to read and maintain.
2024-12-16 17:39:45 +01:00
lebaudantoine
e4a45a556c 🐛(backend) fix bucket access in the tilt stack
S3 username was desynchronized with the helmfile. Leading to error,
when patching object or saving any update to the Minio bucket.

@rouja fixed it.
2024-12-16 17:17:42 +01:00
lebaudantoine
3ca39ceb8a ♻️(yprovider) support multiple API keys to separate responsibilities
Support for two API keys has been added to the YProvider microservice to
decouple responsibilities between the collaboration server and other
endpoints. This improves security by scoping keys to specific purposes and
ensures a clearer separation of concerns for easier management and debugging.
2024-12-16 17:17:42 +01:00
lebaudantoine
8a93122882 (yprovider) add test to prevent silent breaking changes
Per Quentin's request, added a test to ensure developers are warned
if the token format is updated, preventing backend compatibility issues.
2024-12-16 17:17:42 +01:00
lebaudantoine
8eb986591a 💡(backend) warm about the token nature of Yprovider microservice
Note to the future myself, using a raw token format is
not common. It should be refactor
2024-12-16 17:17:42 +01:00
lebaudantoine
c10808b611 ♻️(backend) generalize YProvider API config
Abstracted base URL and API key under 'y-provider' for
reuse in future endpoints, aligning with microservice naming.

Please note the YProvider API here is internal to the cluster.
In facts, we don't want these endpoints to be exposed by any ingress
2024-12-16 17:17:42 +01:00
lebaudantoine
ba63358098 🔧(backend) configure conversion microservice in dev
Update helm values to configure the conversion microservice while
creating document server to server.
2024-12-16 17:17:42 +01:00
lebaudantoine
52534db3e1 🐛(backend) fix issues with conversion microservice integration
Minor adjustments were needed after working in parallel on two PRs.
The microservice now accepts an API key without requiring it as a Bearer token.

A mistake in reading the microservice response was corrected after refactoring
the serializer to delegate logic to the converter microservice.
2024-12-16 17:17:42 +01:00
renovate[bot]
dc9b375ff5 ⬆️(dependencies) update python dependencies 2024-12-16 10:45:49 +01:00
renovate[bot]
65fdf115be ⬆️(dependencies) update django to v5.1.4 [SECURITY] 2024-12-13 21:28:11 +01:00
Anthony LC
ecb2b35ec8 (email) white brand email
The email was branded "La Suite Numérique",
we updated the template to make it generic, we
will use settings env variables to customize the
email for each brand.
2024-12-13 17:58:43 +01:00
renovate[bot]
2d13e0985e ⬆️(dependencies) update python dependencies 2024-12-12 18:56:25 +01:00
lebaudantoine
5014443f80 💩(y-provider) init a markdown converter endpoint
This code is quite poor. Sorry, I don't have much time working
on this feature. However, it should be functional.

I've reused the code we created for the Demo with Kasbarian.
I've not tested it yet with all corner case. Error handling
might be improved for sure, same for logging.

This endpoint is not modular. We could easily introduce options
to modify its behavior based on some options. YAGNI

I've added bearer token authentification, because it's unclear
how this micro service would be exposed. It's totally not required
if the microservice is not exposed through an Ingress.
2024-12-12 14:37:30 +01:00
lebaudantoine
3fef7596b3 (y-provider) create utils function toBase64
Add utility function to convert BitArray to Base64 string.
This is required for creating Base64-encoded documents,
as the frontend do.
2024-12-12 14:37:30 +01:00
lebaudantoine
19042907be (y-provider) add BlockNote server utils and yjs
Needed dependencies to mimic frontend code when generating a
document from a markdown string.

Will be used in the upcoming commits.
2024-12-12 14:37:30 +01:00
Samuel Paccoud - DINUM
5cdd06d432 (backend) add server-to-server API endpoint to create documents
We want trusted external applications to be able to create documents
via the API on behalf of any user. The user may or may not pre-exist
in our database and should be notified of the document creation by
email.
2024-12-12 14:01:46 +01:00
Samuel Paccoud - DINUM
47e23bff90 🔥(emails) remove dead template code
The email footer file is not being used and should be deleted.
2024-12-12 14:01:46 +01:00
Anthony LC
7dfc62b2c5 🔖(minor) release 1.9.0
Added:
- (backend) annotate number of accesses
on documents in list view
- (backend) allow users to mark/unmark
documents as favorite

Changed:
- 🔒️(collaboration) increase collaboration access security
- 🔨(frontend) encapsulated title to its own component
- ️(backend) optimize number of queries on
document list view
- ♻️(frontend) stop to use provider with version
- 🚚(collaboration) change the websocket key name

Fixed:
- 🐛(frontend) fix initial content with collaboration
- 🐛(frontend) Fix hidden menu on Firefox
- 🐛(backend) fix sanitize problem IA
2024-12-12 08:37:10 +01:00
Anthony LC
39c4af0a7c 🐛(frontend) hide cursor for authenticated users
When a authenticated user was in read-only mode,
the cursor was still visible.
This commit hides the cursor for authenticated users
in read-only mode.
2024-12-11 21:18:40 +01:00
Anthony LC
57c5c394f5 🐛(backend) improve sanitizer ai_services
The json response of the AI service is badly formatted.
This commit improves the sanitizer to try
to handle the response correctly.
2024-12-11 21:18:40 +01:00
Anthony LC
be6da38a08 🐛(frontend) only owner will make initial content
In some cases, the sync of the initial content
is not being done correctly.
We will let only the owner of the document
to make the initial content.
2024-12-11 21:18:40 +01:00
Anthony LC
fc36ed08f1 🐛(frontend) fix initial content with collaboration
The way the initial content was created was causing
issues with the collaboration server.
As soon a user started typing, the problem was gone.
This commit fixes that by letting Blocknote
managing the initial content, then we update the
Blocknote initial content with our initial content.
2024-12-11 16:08:13 +01:00
Anthony LC
ed90769081 🐛(backend) fix sanitize problem IA
Albert send us back a malformed IA json, the
sanitize function was not able to handle it correctly.
We add a try catch on it, to not use the sanitizer if
the json.loads fails.
2024-12-11 15:21:56 +01:00
Anthony LC
a8310fa0ff ️(frontend) remove debounce on useHeadings
We remove the debounce on useHeadings, it
decreases the user experience and it's not
necessary a big performance improvement.
2024-12-11 14:54:41 +01:00
Anthony LC
a902e31521 🔧(helm) add ingress collaboration api
We need to keep the stickyness between the
collaboration api and the ws server, to do so,
we will use "upstream-hash-by: $arg_room", meaning
that the stickyness will be based on the room query.
We need to ahve 2 ingress to handle the
"collaboration_auth", only the ws routes has to
use the "collaboration_auth" subrequest.
2024-12-11 14:54:41 +01:00
Anthony LC
932ab13d97 📈(collaboration) add sentry
Add sentry to the collaboration server.
It will be used to log errors and exceptions.
2024-12-11 14:54:41 +01:00
Anthony LC
94a1ba7989 (backend) notify collaboration server
When an access is updated or removed, the
collaboration server is notified to reset the
access connection; by being disconnected, the
accesses will automatically reconnect by passing
by the ngnix subrequest, and so get the good
rights.
We do the same system when the document link is
updated, except here we reset every access
connection.
2024-12-11 14:54:41 +01:00
Anthony LC
bfecdbf83a (y-provider) add tests for y-provider server
We add jest tests for the y-provider server.
The CI will be able to run the tests.
2024-12-11 14:54:41 +01:00
Anthony LC
ba1cfc3c27 (y-provider) endpoint POST /collaboration/api/reset-connections
We want to be able to reset the connections of a document.
To do this, we need to be able to send a
request to the collaboration server.
To do so, we added the endpoint
POST "/collaboration/api/reset-connections"
to the collaboration server thanks to "express".
2024-12-11 14:54:41 +01:00
Samuel Paccoud - DINUM
2cba228a67 🧑‍💻(helm) rename minio root user password
Using "impress" as the name of minio's root user in Tilt's
dev environment, was triggering obfuscation of the logs in Tilt's
console each time the word "impress" was used.
This made the logs hard to read.
2024-12-11 14:54:41 +01:00
Samuel Paccoud - DINUM
66553ee236 (backend) add subrequest auth view for collaboration server
We need to improve security on the access to The collaboration server
We can use the same pattern as for media files leveraging the nginx
subrequest feature.
2024-12-11 14:54:41 +01:00
Samuel Paccoud - DINUM
64674b6a73 ♻️(backend) rename, factorize and improve the subrequest media auth view
We want to use the same pattern for the websocket collaboration service
authorization as what we use for media files.

This addition comes in the next commit but doing it efficiently
required factorizing some code with the media auth view.
2024-12-11 14:54:41 +01:00
Anthony LC
a9def8cb18 ♻️(frontend) create useHeadings hook
- We create the useHeadings hook to manage the
headings of the document and staty DRY.
- We use the headings store in IconOpenPanelEditor
and TableContent, to avoid prop drilling.
- We add a debounce on the onEditorContentChange
to improve a bit the performance.
2024-12-06 15:23:16 +01:00
Anthony LC
69186e9a26 🩺(CI) wait for services to be ready
We add a check to be sure all the services are
ready before starting the e2e tests.
2024-12-06 15:23:16 +01:00
Anthony LC
f606826098 ♻️(frontend) stop to use provider with version
Version are not editable, we don't need to activate
the collaboration provider for them.
Simplify the code by removing the provider
from the version.
2024-12-06 15:23:16 +01:00
Anthony LC
aff036d9fb 🚚(collaboration) change the websocket url name
We will have 2 urls targeting the server, better
to improve the naming to avoid confusion.
2024-12-06 15:23:16 +01:00
Anthony LC
57ed08994b 🔊(changelog) add missing logs
Some logs were missing or not at the good place.
This commit replaces them correctly.
2024-12-06 15:23:16 +01:00
rvveber
131eefa1ac 🔨(frontend) encapsulate title component
in order to modularize in the future
the title component is encapsulated.
2024-12-06 14:16:24 +01:00
Anthony LC
b4e639cc24 ♻️(frontend) adapt Blocknote button
Last upgrade of Blocknote changes the editor
method getSelection, the blocks were not being
selected in certain cases.
We updated the methods to select the blocks
correctly.
2024-12-05 23:34:06 +01:00
Samuel Paccoud - DINUM
ba962af914 ⬆️(backend) bump openai library version as it breaks tests
This looks like an instability in the openai library's definition
of dependencies.
2024-12-05 23:34:06 +01:00
Anthony LC
76514a6e2b 🏷️(frontend) adapt typing with recent upgrade
An upgrade to @sentry/nextjs@8.42.0 changed
some typing. It is not from @sentry/types but
from @sentry/core now.
2024-12-05 23:34:06 +01:00
Anthony LC
b69a5342d9 ⬇️(dependency) downgrade workbox-webpack-plugin to 7.1.0
In the 1.8.0 we experienced issues with the service
worker not updating properly. We suspect that the
workbox-webpack-plugin is the cause of this issue.
Better to downgrade to the last version that worked
until we have time to investigate the issue.
We add workbox-webpack-plugin to the renovate.json
file to avoid future updates.
2024-12-05 23:34:06 +01:00
renovate[bot]
c25682f199 ⬆️(dependencies) update js dependencies 2024-12-05 23:34:06 +01:00
192 changed files with 10990 additions and 5776 deletions

View File

@@ -6,7 +6,6 @@ on:
push:
branches:
- 'main'
- 'main-new-ui'
tags:
- 'v*'
pull_request:
@@ -157,7 +156,7 @@ jobs:
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '-f src/frontend/Dockerfile --target y-provider'
docker-build-args: '-f src/frontend/servers/y-provider/Dockerfile --target y-provider'
docker-image-name: 'docker.io/lasuite/impress-frontend:${{ github.sha }}'
continue-on-error: true
-
@@ -165,7 +164,7 @@ jobs:
uses: docker/build-push-action@v6
with:
context: .
file: ./src/frontend/Dockerfile
file: ./src/frontend/servers/y-provider/Dockerfile
target: y-provider
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }}

View File

@@ -19,7 +19,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "18.x"
node-version: "20.x"
- name: Restore the frontend cache
uses: actions/cache@v4
@@ -46,6 +46,11 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20.x"
- name: Restore the frontend cache
uses: actions/cache@v4
id: front-node_modules
@@ -54,7 +59,7 @@ jobs:
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
- name: Test App
run: cd src/frontend/ && yarn app:test
run: cd src/frontend/ && yarn test
lint-front:
runs-on: ubuntu-latest
@@ -90,11 +95,33 @@ jobs:
- name: Set e2e env variables
run: cat env.d/development/common.e2e.dist >> env.d/development/common.dist
- name: Install Playwright Browsers
run: cd src/frontend/apps/e2e && yarn install --frozen-lockfile && yarn install-playwright chromium
- name: Start Docker services
run: make bootstrap FLUSH_ARGS='--no-input' cache=
- name: Install Playwright Browsers
run: cd src/frontend/apps/e2e && yarn install-playwright chromium
# Tool to wait for a service to be ready
- name: Install Dockerize
run: |
curl -sSL https://github.com/jwilder/dockerize/releases/download/v0.8.0/dockerize-linux-amd64-v0.8.0.tar.gz | sudo tar -C /usr/local/bin -xzv
- name: Wait for services to be ready
run: |
printf "Minio check...\n"
dockerize -wait tcp://localhost:9000 -timeout 20s
printf "Keyclock check...\n"
dockerize -wait tcp://localhost:8080 -timeout 20s
printf "Server collaboration check...\n"
dockerize -wait tcp://localhost:4444 -timeout 20s
printf "Ngnix check...\n"
dockerize -wait tcp://localhost:8083 -timeout 20s
printf "DRF check...\n"
dockerize -wait tcp://localhost:8071 -timeout 20s
printf "Postgres Keyclock check...\n"
dockerize -wait tcp://localhost:5433 -timeout 20s
printf "Postgres back check...\n"
dockerize -wait tcp://localhost:15432 -timeout 20s
- name: Run e2e tests
run: cd src/frontend/ && yarn e2e:test --project='chromium'
@@ -124,12 +151,12 @@ jobs:
- name: Set e2e env variables
run: cat env.d/development/common.e2e.dist >> env.d/development/common.dist
- name: Install Playwright Browsers
run: cd src/frontend/apps/e2e && yarn install --frozen-lockfile && yarn install-playwright firefox webkit chromium
- name: Start Docker services
run: make bootstrap FLUSH_ARGS='--no-input' cache=
- 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

View File

@@ -0,0 +1,36 @@
name: Release Chart
run-name: Release Chart
on:
push:
jobs:
release:
# depending on default permission settings for your org (contents being read-only or read-write for workloads), you will have to add permissions
# see: https://docs.github.com/en/actions/security-guides/automatic-token-authentication#modifying-the-permissions-for-the-github_token
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Cleanup
run: rm -rf ./src/helm/extra
- name: Install Helm
uses: azure/setup-helm@v4
env:
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
- name: Run chart-releaser
uses: helm/chart-releaser-action@v1.6.0
with:
charts_dir: ./src/helm
skip_existing: True
mark_as_latest: False
env:
CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
CR_GIT_REPO: numerique-gouv/helm-repo

View File

@@ -10,21 +10,51 @@ and this project adheres to
## [Unreleased]
## Added
- ✨(frontend) WIP: New ui
- 💄(frontend) Add left panel #420
- 💄(frontend) updating the header and leftpanel for responsive #421
- 💄(frontend) update DocsGrid component #431
- 💄(frontend) update DocsGridOptions component #432
- 💄(frontend) update DocHeader ui #446
- 💄(frontend) update doc versioning ui #463
- 💄(frontend) update doc summary ui #473
- 🐛(frontend) fix doc grid button #478
- 💄(frontend) update doc home left panel #475
- 🐛(frontend) update doc editor height #481
🔧(helm) add option to disable default tls setting by @dominikkaminski #519
## [1.10.0] - 2024-12-17
## Added
- ✨(backend) add server-to-server API endpoint to create documents #467
- ✨(email) white brand email #412
- ✨(y-provider) create a markdown converter endpoint #488
## Changed
- ⚡️(docker) improve y-provider image #422
## Fixed
- ⚡️(e2e) reduce flakiness on e2e tests #511
## [1.9.0] - 2024-12-11
## Added
- ✨(backend) annotate number of accesses on documents in list view #429
- ✨(backend) allow users to mark/unmark documents as favorite #429
## Changed
- 🔒️(collaboration) increase collaboration access security #472
- 🔨(frontend) encapsulated title to its own component #474
- ⚡️(backend) optimize number of queries on document list view #429
- ♻️(frontend) stop to use provider with version #480
- 🚚(collaboration) change the websocket key name #480
## Fixed
- 🐛(frontend) fix initial content with collaboration #484
- 🐛(frontend) Fix hidden menu on Firefox #468
- 🐛(backend) fix sanitize problem IA #490
## [1.8.2] - 2024-11-28
## Changed
- ♻️(SW) change strategy html caching #460
@@ -41,23 +71,17 @@ and this project adheres to
## Added
- ✨(backend) annotate number of accesses on documents in list view #411
- ✨(backend) allow users to mark/unmark documents as favorite #411
- 🌐(backend) add German translation #259
- 🌐(frontend) add German translation #255
- ✨(frontend) add a broadcast store #387
- ✨(backend) config endpoint #425
- 💄(frontend) update DocsGrid component #431
- ✨(backend) whitelist pod's IP address #443
- ✨(backend) config endpoint #425
- ✨(frontend) config endpoint #424
- ✨(frontend) add sentry #424
- ✨(frontend) add crisp chatbot #450
## Changed
- ⚡️(backend) optimize number of queries on document list view #411
- 🚸(backend) improve users similarity search and sort results #391
- ♻️(frontend) simplify stores #402
- ✨(frontend) update $css Box props type to add styled components RuleSet #423
@@ -301,7 +325,9 @@ and this project adheres to
- 🚀 Impress, project to manage your documents easily and collaboratively.
[unreleased]: https://github.com/numerique-gouv/impress/compare/v1.8.2...main
[unreleased]: https://github.com/numerique-gouv/impress/compare/v1.10.0...main
[v1.10.0]: https://github.com/numerique-gouv/impress/releases/v1.10.0
[v1.9.0]: https://github.com/numerique-gouv/impress/releases/v1.9.0
[v1.8.2]: https://github.com/numerique-gouv/impress/releases/v1.8.2
[v1.8.1]: https://github.com/numerique-gouv/impress/releases/v1.8.1
[v1.8.0]: https://github.com/numerique-gouv/impress/releases/v1.8.0

View File

@@ -122,8 +122,8 @@ logs: ## display app-dev logs (follow mode)
run: ## start the wsgi (production) and development server
@$(COMPOSE) up --force-recreate -d celery-dev
@$(COMPOSE) up --force-recreate -d nginx
@$(COMPOSE) up --force-recreate -d y-provider
@$(COMPOSE) up --force-recreate -d nginx
@echo "Wait for postgresql to be up..."
@$(WAIT_DB)
.PHONY: run

View File

@@ -20,7 +20,7 @@ docker_build(
docker_build(
'localhost:5001/impress-y-provider:latest',
context='..',
dockerfile='../src/frontend/Dockerfile',
dockerfile='../src/frontend/servers/y-provider/Dockerfile',
only=['./src/frontend/', './docker/', './.dockerignore'],
target = 'y-provider',
live_update=[

View File

@@ -1,103 +1,3 @@
#!/bin/sh
set -o errexit
CURRENT_DIR=$(pwd)
echo "0. Create ca"
# 0. Create ca
mkcert -install
cd /tmp
mkcert "127.0.0.1.nip.io" "*.127.0.0.1.nip.io"
cd $CURRENT_DIR
echo "1. Create registry container unless it already exists"
# 1. Create registry container unless it already exists
reg_name='kind-registry'
reg_port='5001'
if [ "$(docker inspect -f '{{.State.Running}}' "${reg_name}" 2>/dev/null || true)" != 'true' ]; then
docker run \
-d --restart=unless-stopped -p "127.0.0.1:${reg_port}:5000" --network bridge --name "${reg_name}" \
registry:2
fi
echo "2. Create kind cluster with containerd registry config dir enabled"
# 2. Create kind cluster with containerd registry config dir enabled
# TODO: kind will eventually enable this by default and this patch will
# be unnecessary.
#
# See:
# https://github.com/kubernetes-sigs/kind/issues/2875
# https://github.com/containerd/containerd/blob/main/docs/cri/config.md#registry-configuration
# See: https://github.com/containerd/containerd/blob/main/docs/hosts.md
cat <<EOF | kind create cluster --config=-
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
containerdConfigPatches:
- |-
[plugins."io.containerd.grpc.v1.cri".registry]
config_path = "/etc/containerd/certs.d"
nodes:
- role: control-plane
image: kindest/node:v1.27.3
kubeadmConfigPatches:
- |
kind: InitConfiguration
nodeRegistration:
kubeletExtraArgs:
node-labels: "ingress-ready=true"
extraPortMappings:
- containerPort: 80
hostPort: 80
protocol: TCP
- containerPort: 443
hostPort: 443
protocol: TCP
- role: worker
image: kindest/node:v1.27.3
- role: worker
image: kindest/node:v1.27.3
EOF
echo "3. Add the registry config to the nodes"
# 3. Add the registry config to the nodes
#
# This is necessary because localhost resolves to loopback addresses that are
# network-namespace local.
# In other words: localhost in the container is not localhost on the host.
#
# We want a consistent name that works from both ends, so we tell containerd to
# alias localhost:${reg_port} to the registry container when pulling images
REGISTRY_DIR="/etc/containerd/certs.d/localhost:${reg_port}"
for node in $(kind get nodes); do
docker exec "${node}" mkdir -p "${REGISTRY_DIR}"
cat <<EOF | docker exec -i "${node}" cp /dev/stdin "${REGISTRY_DIR}/hosts.toml"
[host."http://${reg_name}:5000"]
EOF
done
echo "4. Connect the registry to the cluster network if not already connected"
# 4. Connect the registry to the cluster network if not already connected
# This allows kind to bootstrap the network but ensures they're on the same network
if [ "$(docker inspect -f='{{json .NetworkSettings.Networks.kind}}' "${reg_name}")" = 'null' ]; then
docker network connect "kind" "${reg_name}"
fi
echo "5. Document the local registry"
# 5. Document the local registry
# https://github.com/kubernetes/enhancements/tree/master/keps/sig-cluster-lifecycle/generic/1755-communicating-a-local-registry
cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: ConfigMap
metadata:
name: local-registry-hosting
namespace: kube-public
data:
localRegistryHosting.v1: |
host: "localhost:${reg_port}"
help: "https://kind.sigs.k8s.io/docs/user/local-registry/"
EOF
echo "6. Install ingress-nginx"
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/kind/deploy.yaml
kubectl -n ingress-nginx create secret tls mkcert --key /tmp/127.0.0.1.nip.io+1-key.pem --cert /tmp/127.0.0.1.nip.io+1.pem
kubectl -n ingress-nginx patch deployments.apps ingress-nginx-controller --type 'json' -p '[{"op": "add", "path": "/spec/template/spec/containers/0/args/-", "value":"--default-ssl-certificate=ingress-nginx/mkcert"}]'
curl https://raw.githubusercontent.com/numerique-gouv/tools/refs/heads/main/kind/create_cluster.sh | bash -s -- impress

View File

@@ -118,6 +118,7 @@ services:
depends_on:
- keycloak
- app-dev
- y-provider
frontend-dev:
user: "${DOCKER_USER:-1000}"
@@ -158,15 +159,13 @@ services:
user: ${DOCKER_USER:-1000}
build:
context: .
dockerfile: ./src/frontend/Dockerfile
dockerfile: ./src/frontend/servers/y-provider/Dockerfile
target: y-provider
restart: unless-stopped
env_file:
- env.d/development/common
ports:
- "4444:4444"
volumes:
- ./src/frontend/servers/y-provider:/home/frontend/servers/y-provider
- /home/frontend/servers/y-provider/node_modules/
- /home/frontend/servers/y-provider/dist/
kc_postgresql:
image: postgres:14.3

View File

@@ -4,9 +4,58 @@ server {
server_name localhost;
charset utf-8;
# Proxy auth for collaboration server
location /collaboration/ws/ {
# Collaboration Auth request configuration
auth_request /collaboration-auth;
auth_request_set $authHeader $upstream_http_authorization;
auth_request_set $canEdit $upstream_http_x_can_edit;
auth_request_set $userId $upstream_http_x_user_id;
# Pass specific headers from the auth response
proxy_set_header Authorization $authHeader;
proxy_set_header X-Can-Edit $canEdit;
proxy_set_header X-User-Id $userId;
# Ensure WebSocket upgrade
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
# Collaboration server
proxy_pass http://y-provider:4444;
# Set appropriate timeout for WebSocket
proxy_read_timeout 86400;
proxy_send_timeout 86400;
# Preserve original host and additional headers
proxy_set_header Host $host;
}
location /collaboration-auth {
proxy_pass http://app-dev:8000/api/v1.0/documents/collaboration-auth/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Original-URL $request_uri;
# Prevent the body from being passed
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Original-Method $request_method;
}
location /collaboration/api/ {
# Collaboration server
proxy_pass http://y-provider:4444;
proxy_set_header Host $host;
}
# Proxy auth for media
location /media/ {
# Auth request configuration
auth_request /auth;
auth_request /media-auth;
auth_request_set $authHeader $upstream_http_authorization;
auth_request_set $authDate $upstream_http_x_amz_date;
auth_request_set $authContentSha256 $upstream_http_x_amz_content_sha256;
@@ -21,8 +70,8 @@ server {
proxy_set_header Host minio:9000;
}
location /auth {
proxy_pass http://app-dev:8000/api/v1.0/documents/retrieve-auth/;
location /media-auth {
proxy_pass http://app-dev:8000/api/v1.0/documents/media-auth/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

View File

@@ -0,0 +1,156 @@
image:
repository: lasuite/impress-backend
pullPolicy: Always
tag: "latest"
backend:
replicas: 1
envVars:
COLLABORATION_API_URL: https://impress.127.0.0.1.nip.io/collaboration/api/
COLLABORATION_SERVER_SECRET: my-secret
DJANGO_CSRF_TRUSTED_ORIGINS: https://impress.127.0.0.1.nip.io
DJANGO_CONFIGURATION: Feature
DJANGO_ALLOWED_HOSTS: impress.127.0.0.1.nip.io
DJANGO_SERVER_TO_SERVER_API_TOKENS: secret-api-key
DJANGO_SECRET_KEY: AgoodOrAbadKey
DJANGO_SETTINGS_MODULE: impress.settings
DJANGO_SUPERUSER_PASSWORD: admin
DJANGO_EMAIL_BRAND_NAME: "La Suite Numérique"
DJANGO_EMAIL_HOST: "mailcatcher"
DJANGO_EMAIL_LOGO_IMG: https://impress.127.0.0.1.nip.io/assets/logo-suite-numerique.png
DJANGO_EMAIL_PORT: 1025
DJANGO_EMAIL_USE_SSL: False
LOGGING_LEVEL_HANDLERS_CONSOLE: ERROR
LOGGING_LEVEL_LOGGERS_ROOT: INFO
LOGGING_LEVEL_LOGGERS_APP: INFO
OIDC_OP_JWKS_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/certs
OIDC_OP_AUTHORIZATION_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/auth
OIDC_OP_TOKEN_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/token
OIDC_OP_USER_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/userinfo
OIDC_OP_LOGOUT_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/session/end
OIDC_RP_CLIENT_ID: impress
OIDC_RP_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
OIDC_RP_SIGN_ALGO: RS256
OIDC_RP_SCOPES: "openid email"
OIDC_VERIFY_SSL: False
USER_OIDC_FIELD_TO_SHORTNAME: "given_name"
USER_OIDC_FIELDS_TO_FULLNAME: "given_name,usual_name"
OIDC_REDIRECT_ALLOWED_HOSTS: https://impress.127.0.0.1.nip.io
OIDC_AUTH_REQUEST_EXTRA_PARAMS: "{'acr_values': 'eidas1'}"
LOGIN_REDIRECT_URL: https://impress.127.0.0.1.nip.io
LOGIN_REDIRECT_URL_FAILURE: https://impress.127.0.0.1.nip.io
LOGOUT_REDIRECT_URL: https://impress.127.0.0.1.nip.io
DB_HOST: postgresql
DB_NAME: impress
DB_USER: dinum
DB_PASSWORD: pass
DB_PORT: 5432
POSTGRES_DB: impress
POSTGRES_USER: dinum
POSTGRES_PASSWORD: pass
REDIS_URL: redis://default:pass@redis-master:6379/1
AWS_S3_ENDPOINT_URL: http://minio.impress.svc.cluster.local:9000
AWS_S3_ACCESS_KEY_ID: root
AWS_S3_SECRET_ACCESS_KEY: password
AWS_STORAGE_BUCKET_NAME: impress-media-storage
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
Y_PROVIDER_API_BASE_URL: http://impress-y-provider:443/api/
Y_PROVIDER_API_KEY: my-secret
migrate:
command:
- "/bin/sh"
- "-c"
- |
python manage.py migrate --no-input &&
python manage.py create_demo --force
restartPolicy: Never
command:
- "gunicorn"
- "-c"
- "/usr/local/etc/gunicorn/impress.py"
- "impress.wsgi:application"
- "--reload"
createsuperuser:
command:
- "/bin/sh"
- "-c"
- |
python manage.py createsuperuser --email admin@example.com --password admin
restartPolicy: Never
# Exra volume to manage our local custom CA and avoid to set ssl_verify: false
extraVolumeMounts:
- name: certs
mountPath: /usr/local/lib/python3.12/site-packages/certifi/cacert.pem
subPath: cacert.pem
# Exra volume to manage our local custom CA and avoid to set ssl_verify: false
extraVolumes:
- name: certs
configMap:
name: certifi
items:
- key: cacert.pem
path: cacert.pem
frontend:
envVars:
PORT: 8080
NEXT_PUBLIC_API_ORIGIN: https://impress.127.0.0.1.nip.io
replicas: 1
image:
repository: lasuite/impress-frontend
pullPolicy: Always
tag: "latest"
yProvider:
replicas: 1
image:
repository: lasuite/impress-y-provider
pullPolicy: Always
tag: "latest"
envVars:
COLLABORATION_LOGGING: true
COLLABORATION_SERVER_ORIGIN: https://impress.127.0.0.1.nip.io
COLLABORATION_SERVER_SECRET: my-secret
Y_PROVIDER_API_KEY: my-secret
ingress:
enabled: true
host: impress.127.0.0.1.nip.io
ingressCollaborationWS:
enabled: true
host: impress.127.0.0.1.nip.io
annotations:
nginx.ingress.kubernetes.io/auth-url: https://impress.127.0.0.1.nip.io/api/v1.0/documents/collaboration-auth/
ingressCollaborationApi:
enabled: true
host: impress.127.0.0.1.nip.io
ingressAdmin:
enabled: true
host: impress.127.0.0.1.nip.io
ingressMedia:
enabled: true
host: impress.127.0.0.1.nip.io
annotations:
nginx.ingress.kubernetes.io/auth-url: https://impress.127.0.0.1.nip.io/api/v1.0/documents/media-auth/
nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Amz-Date, X-Amz-Content-SHA256"
nginx.ingress.kubernetes.io/upstream-vhost: minio.impress.svc.cluster.local:9000
nginx.ingress.kubernetes.io/rewrite-target: /impress-media-storage/$1
serviceMedia:
host: minio.impress.svc.cluster.local
port: 9000

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,8 @@
auth:
rootUser: root
rootPassword: password
provisioning:
enabled: true
buckets:
- name: impress-media-storage
versioning: true

View File

@@ -0,0 +1,7 @@
auth:
username: dinum
password: pass
database: impress
tls:
enabled: true
autoGenerated: true

View File

@@ -0,0 +1,4 @@
auth:
password: pass
architecture: standalone

231
docs/installation.md Normal file
View File

@@ -0,0 +1,231 @@
# Installation on a k8s cluster
This document is a step-by-step guide that describes how to install Docs on a k8s cluster without AI features.
## Prerequisites
- k8s cluster with an nginx-ingress controller
- an OIDC provider (if you don't have one, we will provide an example)
- a PostgreSQL server (if you don't have one, we will provide an example)
- a Memcached server (if you don't have one, we will provide an example)
- a S3 bucket (if you don't have one, we will provide an example)
### Test cluster
If you do not have a test cluster, you can install everything on a local kind cluster. In this case, the simplest way is to use our script **bin/start-kind.sh**.
To be able to use the script, you will need to install:
- Docker (https://docs.docker.com/desktop/)
- Kind (https://kind.sigs.k8s.io/docs/user/quick-start/#installation)
- Mkcert (https://github.com/FiloSottile/mkcert#installation)
- Helm (https://helm.sh/docs/intro/quickstart/#install-helm)
```
./bin/start-kind.sh
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 4700 100 4700 0 0 92867 0 --:--:-- --:--:-- --:--:-- 94000
0. Create ca
The local CA is already installed in the system trust store! 👍
The local CA is already installed in the Firefox and/or Chrome/Chromium trust store! 👍
Created a new certificate valid for the following names 📜
- "127.0.0.1.nip.io"
- "*.127.0.0.1.nip.io"
Reminder: X.509 wildcards only go one level deep, so this won't match a.b.127.0.0.1.nip.io
The certificate is at "./127.0.0.1.nip.io+1.pem" and the key at "./127.0.0.1.nip.io+1-key.pem" ✅
It will expire on 24 March 2027 🗓
1. Create registry container unless it already exists
2. Create kind cluster with containerd registry config dir enabled
Creating cluster "suite" ...
✓ Ensuring node image (kindest/node:v1.27.3) 🖼
✓ Preparing nodes 📦
✓ Writing configuration 📜
✓ Starting control-plane 🕹️
✓ Installing CNI 🔌
✓ Installing StorageClass 💾
Set kubectl context to "kind-suite"
You can now use your cluster with:
kubectl cluster-info --context kind-suite
Thanks for using kind! 😊
3. Add the registry config to the nodes
4. Connect the registry to the cluster network if not already connected
5. Document the local registry
configmap/local-registry-hosting created
Warning: resource configmaps/coredns is missing the kubectl.kubernetes.io/last-applied-configuration annotation which is required by kubectl apply. kubectl apply should only be used on resources created declaratively by either kubectl create --save-config or kubectl apply. The missing annotation will be patched automatically.
configmap/coredns configured
deployment.apps/coredns restarted
6. Install ingress-nginx
namespace/ingress-nginx created
serviceaccount/ingress-nginx created
serviceaccount/ingress-nginx-admission created
role.rbac.authorization.k8s.io/ingress-nginx created
role.rbac.authorization.k8s.io/ingress-nginx-admission created
clusterrole.rbac.authorization.k8s.io/ingress-nginx created
clusterrole.rbac.authorization.k8s.io/ingress-nginx-admission created
rolebinding.rbac.authorization.k8s.io/ingress-nginx created
rolebinding.rbac.authorization.k8s.io/ingress-nginx-admission created
clusterrolebinding.rbac.authorization.k8s.io/ingress-nginx created
clusterrolebinding.rbac.authorization.k8s.io/ingress-nginx-admission created
configmap/ingress-nginx-controller created
service/ingress-nginx-controller created
service/ingress-nginx-controller-admission created
deployment.apps/ingress-nginx-controller created
job.batch/ingress-nginx-admission-create created
job.batch/ingress-nginx-admission-patch created
ingressclass.networking.k8s.io/nginx created
validatingwebhookconfiguration.admissionregistration.k8s.io/ingress-nginx-admission created
secret/mkcert created
deployment.apps/ingress-nginx-controller patched
7. Setup namespace
namespace/impress created
Context "kind-suite" modified.
secret/mkcert created
$ kubectl -n ingress-nginx get po
NAME READY STATUS RESTARTS AGE
ingress-nginx-admission-create-t55ph 0/1 Completed 0 2m56s
ingress-nginx-admission-patch-94dvt 0/1 Completed 1 2m56s
ingress-nginx-controller-57c548c4cd-2rx47 1/1 Running 0 2m56s
```
When your k8s cluster is ready (the ingress nginx controller is up), you can start the deployment. This cluster is special because it uses the *.127.0.0.1.nip.io domain and mkcert certificates to have full HTTPS support and easy domain name management.
Please remember that *.127.0.0.1.nip.io will always resolve to 127.0.0.1, except in the k8s cluster where we configure CoreDNS to answer with the ingress-nginx service IP.
## Preparation
### What will you use to authenticate your users ?
Docs uses OIDC, so if you already have an OIDC provider, obtain the necessary information to use it. In the next step, we will see how to configure Django (and thus Docs) to use it. If you do not have a provider, we will show you how to deploy a local Keycloak instance (this is not a production deployment, just a demo).
```
$ kubectl create namespace impress
$ kubectl config set-context --current --namespace=impress
$ helm install keycloak oci://registry-1.docker.io/bitnamicharts/keycloak -f examples/keycloak.values.yaml
$ #wait until
$ kubectl get po
NAME READY STATUS RESTARTS AGE
keycloak-0 1/1 Running 0 6m48s
keycloak-postgresql-0 1/1 Running 0 6m48s
```
From here the important informations you will need are :
```
OIDC_OP_JWKS_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/certs
OIDC_OP_AUTHORIZATION_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/auth
OIDC_OP_TOKEN_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/token
OIDC_OP_USER_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/userinfo
OIDC_OP_LOGOUT_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/session/end
OIDC_RP_CLIENT_ID: impress
OIDC_RP_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
OIDC_RP_SIGN_ALGO: RS256
OIDC_RP_SCOPES: "openid email"
```
You can find these values in **examples/keycloak.values.yaml**
### Find redis server connexion values
Impress need a redis so we will start by deploying a redis :
```
$ helm install redis oci://registry-1.docker.io/bitnamicharts/redis -f examples/redis.values.yaml
$ kubectl get po
NAME READY STATUS RESTARTS AGE
keycloak-0 1/1 Running 0 26m
keycloak-postgresql-0 1/1 Running 0 26m
redis-master-0 1/1 Running 0 35s
```
### Find postgresql connexion values
Impress uses a postgresql db as backend so if you have a provider, obtain the necessary information to use it. If you do not have, you can install a postgresql testing environment as follow:
```
$ helm install postgresql oci://registry-1.docker.io/bitnamicharts/postgresql -f examples/postgresql.values.yaml
$ kubectl get po
NAME READY STATUS RESTARTS AGE
keycloak-0 1/1 Running 0 28m
keycloak-postgresql-0 1/1 Running 0 28m
postgresql-0 1/1 Running 0 14m
redis-master-0 1/1 Running 0 42s
```
From here important informations you will need are :
```
DB_HOST: postgres-postgresql
DB_NAME: impress
DB_USER: dinum
DB_PASSWORD: pass
DB_PORT: 5432
POSTGRES_DB: impress
POSTGRES_USER: dinum
POSTGRES_PASSWORD: pass
```
### Find s3 bucket connexion values
Impress uses a s3 bucket to store documents so if you have a provider obtain the necessary information to use it. If you do not have, you can install a local minio testing environment as follow:
```
$ helm install minio oci://registry-1.docker.io/bitnamicharts/minio -f examples/minio.values.yaml
$ kubectl get po
NAME READY STATUS RESTARTS AGE
keycloak-0 1/1 Running 0 38m
keycloak-postgresql-0 1/1 Running 0 38m
minio-84f5c66895-bbhsk 1/1 Running 0 42s
minio-provisioning-2b5sq 0/1 Completed 0 42s
postgresql-0 1/1 Running 0 24m
redis-master-0 1/1 Running 0 10m
```
## Deployment
Now you are ready to deploy Impress without AI. AI requiered more dependancies (openai API). To deploy impress you need to provide all previous informations to the helm chart.
```
$ helm repo add impress https://numerique-gouv.github.io/impress/
$ helm repo update
$ helm install impress impress/docs -f examples/impress.values.yaml
$ kubectl get po
NAME READY STATUS RESTARTS AGE
impress-docs-backend-96558758d-xtkbp 0/1 Running 0 79s
impress-docs-backend-createsuperuser-r7ltc 0/1 Completed 0 79s
impress-docs-backend-migrate-c949s 0/1 Completed 0 79s
impress-docs-frontend-6749f644f7-p5s42 1/1 Running 0 79s
impress-docs-y-provider-6947fd8f54-78f2l 1/1 Running 0 79s
keycloak-0 1/1 Running 0 48m
keycloak-postgresql-0 1/1 Running 0 48m
minio-84f5c66895-bbhsk 1/1 Running 0 10m
minio-provisioning-2b5sq 0/1 Completed 0 10m
postgresql-0 1/1 Running 0 34m
redis-master-0 1/1 Running 0 20m
```
## Test your deployment
In order to test your deployment you have to login to your instance. If you use exclusively our examples you can do :
```
$ kubectl get ingress
NAME CLASS HOSTS ADDRESS PORTS AGE
impress-docs <none> impress.127.0.0.1.nip.io localhost 80, 443 114s
impress-docs-admin <none> impress.127.0.0.1.nip.io localhost 80, 443 114s
impress-docs-collaboration-api <none> impress.127.0.0.1.nip.io localhost 80, 443 114s
impress-docs-media <none> impress.127.0.0.1.nip.io localhost 80, 443 114s
impress-docs-ws <none> impress.127.0.0.1.nip.io localhost 80, 443 114s
keycloak <none> keycloak.127.0.0.1.nip.io localhost 80 49m
```
You can use impress on https://impress.127.0.0.1.nip.io. The provisionning user in keycloak is impress/impress.

View File

@@ -16,7 +16,9 @@ PYTHONPATH=/app
# impress settings
# Mail
DJANGO_EMAIL_BRAND_NAME="La Suite Numérique"
DJANGO_EMAIL_HOST="mailcatcher"
DJANGO_EMAIL_LOGO_IMG="http://localhost:3000/assets/logo-suite-numerique.png"
DJANGO_EMAIL_PORT=1025
# Backend url
@@ -53,7 +55,10 @@ AI_API_KEY=password
AI_MODEL=llama
# Collaboration
COLLABORATION_SERVER_URL=ws://localhost:4444
COLLABORATION_API_URL=http://nginx:8083/collaboration/api/
COLLABORATION_SERVER_ORIGIN=http://localhost:3000
COLLABORATION_SERVER_SECRET=my-secret
COLLABORATION_WS_URL=ws://localhost:8083/collaboration/ws/
# Frontend
FRONTEND_THEME=dsfr

View File

@@ -1,3 +1,6 @@
# For the CI job test-e2e
SUSTAINED_THROTTLE_RATES="200/hour"
BURST_THROTTLE_RATES="200/minute"
DJANGO_SERVER_TO_SERVER_API_TOKENS=test-e2e
Y_PROVIDER_API_KEY=yprovider-api-key
Y_PROVIDER_API_BASE_URL=http://y-provider:4444/api/

View File

@@ -13,7 +13,13 @@
"enabled": false,
"groupName": "ignored js dependencies",
"matchManagers": ["npm"],
"matchPackageNames": ["fetch-mock", "node", "node-fetch", "eslint"]
"matchPackageNames": [
"fetch-mock",
"node",
"node-fetch",
"eslint",
"workbox-webpack-plugin"
]
}
]
}

View File

@@ -4,6 +4,7 @@ import mimetypes
from django.conf import settings
from django.db.models import Q
from django.utils.functional import lazy
from django.utils.translation import gettext_lazy as _
import magic
@@ -11,6 +12,10 @@ from rest_framework import exceptions, serializers
from core import enums, models
from core.services.ai_services import AI_ACTIONS
from core.services.converter_services import (
ConversionError,
YdocConverter,
)
class UserSerializer(serializers.ModelSerializer):
@@ -227,6 +232,96 @@ class DocumentSerializer(ListDocumentSerializer):
return value
class ServerCreateDocumentSerializer(serializers.Serializer):
"""
Serializer for creating a document from a server-to-server request.
Expects 'content' as a markdown string, which is converted to our internal format
via a Node.js microservice. The conversion is handled automatically, so third parties
only need to provide markdown.
Both "sub" and "email" are required because the external app calling doesn't know
if the user will pre-exist in Docs database. If the user pre-exist, we will ignore the
submitted "email" field and use the email address set on the user account in our database
"""
# Document
title = serializers.CharField(required=True)
content = serializers.CharField(required=True)
# User
sub = serializers.CharField(
required=True, validators=[models.User.sub_validator], max_length=255
)
email = serializers.EmailField(required=True)
language = serializers.ChoiceField(
required=False, choices=lazy(lambda: settings.LANGUAGES, tuple)()
)
# Invitation
message = serializers.CharField(required=False)
subject = serializers.CharField(required=False)
def create(self, validated_data):
"""Create the document and associate it with the user or send an invitation."""
language = validated_data.get("language", settings.LANGUAGE_CODE)
# Get the user based on the sub (unique identifier)
try:
user = models.User.objects.get(sub=validated_data["sub"])
except (models.User.DoesNotExist, KeyError):
user = None
email = validated_data["email"]
else:
email = user.email
language = user.language or language
try:
document_content = YdocConverter().convert_markdown(
validated_data["content"]
)
except ConversionError as err:
raise exceptions.APIException(detail="could not convert content") from err
document = models.Document.objects.create(
title=validated_data["title"],
content=document_content,
creator=user,
)
if user:
# Associate the document with the pre-existing user
models.DocumentAccess.objects.create(
document=document,
role=models.RoleChoices.OWNER,
user=user,
)
else:
# The user doesn't exist in our database: we need to invite him/her
models.Invitation.objects.create(
document=document,
email=email,
role=models.RoleChoices.OWNER,
)
# Notify the user about the newly created document
subject = validated_data.get("subject") or _(
"A new document was created on your behalf!"
)
context = {
"message": validated_data.get("message")
or _("You have been granted ownership of a new document:"),
"title": subject,
}
document.send_email(subject, [email], context, language)
return document
def update(self, instance, validated_data):
"""
This serializer does not support updates.
"""
raise NotImplementedError("Update is not supported for this serializer.")
class LinkDocumentSerializer(BaseResourceSerializer):
"""
Serialize link configuration for documents.

View File

@@ -1,6 +1,7 @@
"""API endpoints"""
# pylint: disable=too-many-lines
import logging
import re
import uuid
from urllib.parse import urlparse
@@ -10,10 +11,10 @@ from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.search import TrigramSimilarity
from django.core.exceptions import ValidationError
from django.core.files.storage import default_storage
from django.db import models as db
from django.db.models import (
Count,
Exists,
Min,
OuterRef,
Q,
Subquery,
@@ -21,48 +22,37 @@ from django.db.models import (
)
from django.http import Http404
import rest_framework as drf
from botocore.exceptions import ClientError
from django_filters import rest_framework as filters
from rest_framework import (
decorators,
exceptions,
metadata,
mixins,
pagination,
status,
views,
viewsets,
)
from rest_framework import (
filters as drf_filters,
)
from rest_framework import (
response as drf_response,
)
from django_filters import rest_framework as drf_filters
from rest_framework import filters, status
from rest_framework import response as drf_response
from rest_framework.permissions import AllowAny
from core import enums, models
from core import authentication, enums, models
from core.services.ai_services import AIService
from core.services.collaboration_services import CollaborationService
from . import permissions, serializers, utils
from .filters import DocumentFilter
logger = logging.getLogger(__name__)
ATTACHMENTS_FOLDER = "attachments"
UUID_REGEX = (
r"[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}"
)
FILE_EXT_REGEX = r"\.[a-zA-Z]{3,4}"
MEDIA_URL_PATTERN = re.compile(
f"{settings.MEDIA_URL:s}({UUID_REGEX:s})/"
f"({ATTACHMENTS_FOLDER:s}/{UUID_REGEX:s}{FILE_EXT_REGEX:s})$"
MEDIA_STORAGE_URL_PATTERN = re.compile(
f"{settings.MEDIA_URL:s}(?P<pk>{UUID_REGEX:s})/"
f"(?P<key>{ATTACHMENTS_FOLDER:s}/{UUID_REGEX:s}{FILE_EXT_REGEX:s})$"
)
COLLABORATION_WS_URL_PATTERN = re.compile(rf"(?:^|&)room=(?P<pk>{UUID_REGEX})(?:&|$)")
# pylint: disable=too-many-ancestors
ATTACHMENTS_FOLDER = "attachments"
class NestedGenericViewSet(viewsets.GenericViewSet):
class NestedGenericViewSet(drf.viewsets.GenericViewSet):
"""
A generic Viewset aims to be used in a nested route context.
e.g: `/api/v1.0/resource_1/<resource_1_pk>/resource_2/<resource_2_pk>/`
@@ -134,7 +124,7 @@ class SerializerPerActionMixin:
return self.serializer_classes.get(self.action, self.default_serializer_class)
class Pagination(pagination.PageNumberPagination):
class Pagination(drf.pagination.PageNumberPagination):
"""Pagination to display no more than 100 objects per page sorted by creation date."""
ordering = "-created_on"
@@ -143,7 +133,7 @@ class Pagination(pagination.PageNumberPagination):
class UserViewSet(
mixins.UpdateModelMixin, viewsets.GenericViewSet, mixins.ListModelMixin
drf.mixins.UpdateModelMixin, drf.viewsets.GenericViewSet, drf.mixins.ListModelMixin
):
"""User ViewSet"""
@@ -184,7 +174,7 @@ class UserViewSet(
return queryset
@decorators.action(
@drf.decorators.action(
detail=False,
methods=["get"],
url_name="me",
@@ -196,7 +186,7 @@ class UserViewSet(
Return information on currently logged user
"""
context = {"request": request}
return drf_response.Response(
return drf.response.Response(
self.serializer_class(request.user, context=context).data
)
@@ -231,7 +221,7 @@ class ResourceAccessViewsetMixin:
teams = user.teams
user_roles_query = (
queryset.filter(
Q(user=user) | Q(team__in=teams),
db.Q(user=user) | db.Q(team__in=teams),
**{self.resource_field_name: self.kwargs["resource_id"]},
)
.values(self.resource_field_name)
@@ -245,11 +235,13 @@ class ResourceAccessViewsetMixin:
# access instances pointing to the logged-in user)
queryset = (
queryset.filter(
Q(**{f"{self.resource_field_name}__accesses__user": user})
| Q(**{f"{self.resource_field_name}__accesses__team__in": teams}),
db.Q(**{f"{self.resource_field_name}__accesses__user": user})
| db.Q(
**{f"{self.resource_field_name}__accesses__team__in": teams}
),
**{self.resource_field_name: self.kwargs["resource_id"]},
)
.annotate(user_roles=Subquery(user_roles_query))
.annotate(user_roles=db.Subquery(user_roles_query))
.distinct()
)
return queryset
@@ -264,9 +256,9 @@ class ResourceAccessViewsetMixin:
instance.role == "owner"
and resource.accesses.filter(role="owner").count() == 1
):
return drf_response.Response(
return drf.response.Response(
{"detail": "Cannot delete the last owner access for the resource."},
status=status.HTTP_403_FORBIDDEN,
status=drf.status.HTTP_403_FORBIDDEN,
)
return super().destroy(request, *args, **kwargs)
@@ -287,12 +279,12 @@ class ResourceAccessViewsetMixin:
and resource.accesses.filter(role=models.RoleChoices.OWNER).count() == 1
):
message = "Cannot change the role to a non-owner role for the last owner access."
raise exceptions.PermissionDenied({"detail": message})
raise drf.exceptions.PermissionDenied({"detail": message})
serializer.save()
class DocumentMetadata(metadata.SimpleMetadata):
class DocumentMetadata(drf.metadata.SimpleMetadata):
"""Custom metadata class to add information"""
def determine_metadata(self, request, view):
@@ -310,10 +302,10 @@ class DocumentMetadata(metadata.SimpleMetadata):
class DocumentViewSet(
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
drf.mixins.CreateModelMixin,
drf.mixins.DestroyModelMixin,
drf.mixins.UpdateModelMixin,
drf.viewsets.GenericViewSet,
):
"""
Document ViewSet for managing documents.
@@ -333,7 +325,7 @@ class DocumentViewSet(
- GET /api/v1.0/documents/?is_creator_me=false&title=hello
"""
filter_backends = [filters.DjangoFilterBackend, drf_filters.OrderingFilter]
filter_backends = [drf_filters.DjangoFilterBackend, filters.OrderingFilter]
filterset_class = DocumentFilter
metadata_class = DocumentMetadata
ordering = ["-updated_at"]
@@ -389,11 +381,11 @@ class DocumentViewSet(
if user.is_authenticated:
queryset = queryset.filter(
Q(accesses__user=user)
| Q(accesses__team__in=user.teams)
db.Q(accesses__user=user)
| db.Q(accesses__team__in=user.teams)
| (
Q(link_traces__user=user)
& ~Q(link_reach=models.LinkReachChoices.RESTRICTED)
db.Q(link_traces__user=user)
& ~db.Q(link_reach=models.LinkReachChoices.RESTRICTED)
)
)
else:
@@ -405,7 +397,7 @@ class DocumentViewSet(
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True)
return drf_response.Response(serializer.data)
return drf.response.Response(serializer.data)
def retrieve(self, request, *args, **kwargs):
"""
@@ -428,7 +420,7 @@ class DocumentViewSet(
# The trace already exists, so we just pass without doing anything
pass
return drf_response.Response(serializer.data)
return drf.response.Response(serializer.data)
def perform_create(self, serializer):
"""Set the current user as creator and owner of the newly created object."""
@@ -439,7 +431,31 @@ class DocumentViewSet(
role=models.RoleChoices.OWNER,
)
@decorators.action(detail=True, methods=["get"], url_path="versions")
@drf.decorators.action(
authentication_classes=[authentication.ServerToServerAuthentication],
detail=False,
methods=["post"],
permission_classes=[],
url_path="create-for-owner",
)
def create_for_owner(self, request):
"""
Create a document on behalf of a specified owner (pre-existing user or invited).
"""
# Deserialize and validate the data
serializer = serializers.ServerCreateDocumentSerializer(data=request.data)
if not serializer.is_valid():
return drf_response.Response(
serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
document = serializer.save()
return drf_response.Response(
{"id": str(document.id)}, status=status.HTTP_201_CREATED
)
@drf.decorators.action(detail=True, methods=["get"], url_path="versions")
def versions_list(self, request, *args, **kwargs):
"""
Return the document's versions but only those created after the user got access
@@ -447,7 +463,7 @@ class DocumentViewSet(
"""
user = request.user
if not user.is_authenticated:
raise exceptions.PermissionDenied("Authentication required.")
raise drf.exceptions.PermissionDenied("Authentication required.")
# Validate query parameters using dedicated serializer
serializer = serializers.VersionFilterSerializer(data=request.query_params)
@@ -458,13 +474,13 @@ class DocumentViewSet(
# Users should not see version history dating from before they gained access to the
# document. Filter to get the minimum access date for the logged-in user
access_queryset = document.accesses.filter(
Q(user=user) | Q(team__in=user.teams)
).aggregate(min_date=Min("created_at"))
db.Q(user=user) | db.Q(team__in=user.teams)
).aggregate(min_date=db.Min("created_at"))
# Handle the case where the user has no accesses
min_datetime = access_queryset["min_date"]
if not min_datetime:
return exceptions.PermissionDenied(
return drf.exceptions.PermissionDenied(
"Only users with specific access can see version history"
)
@@ -474,9 +490,9 @@ class DocumentViewSet(
page_size=serializer.validated_data.get("page_size"),
)
return drf_response.Response(versions_data)
return drf.response.Response(versions_data)
@decorators.action(
@drf.decorators.action(
detail=True,
methods=["get", "delete"],
url_path="versions/(?P<version_id>[0-9a-f-]{36})",
@@ -497,7 +513,7 @@ class DocumentViewSet(
min_datetime = min(
access.created_at
for access in document.accesses.filter(
Q(user=user) | Q(team__in=user.teams),
db.Q(user=user) | db.Q(team__in=user.teams),
)
)
if response["LastModified"] < min_datetime:
@@ -505,11 +521,11 @@ class DocumentViewSet(
if request.method == "DELETE":
response = document.delete_version(version_id)
return drf_response.Response(
return drf.response.Response(
status=response["ResponseMetadata"]["HTTPStatusCode"]
)
return drf_response.Response(
return drf.response.Response(
{
"content": response["Body"].read().decode("utf-8"),
"last_modified": response["LastModified"],
@@ -517,7 +533,7 @@ class DocumentViewSet(
}
)
@decorators.action(detail=True, methods=["put"], url_path="link-configuration")
@drf.decorators.action(detail=True, methods=["put"], url_path="link-configuration")
def link_configuration(self, request, *args, **kwargs):
"""Update link configuration with specific rights (cf get_abilities)."""
# Check permissions first
@@ -530,9 +546,13 @@ class DocumentViewSet(
serializer.is_valid(raise_exception=True)
serializer.save()
return drf_response.Response(serializer.data, status=status.HTTP_200_OK)
@decorators.action(detail=True, methods=["post", "delete"], url_path="favorite")
# Notify collaboration server about the link updated
CollaborationService().reset_connections(str(document.id))
return drf.response.Response(serializer.data, status=drf.status.HTTP_200_OK)
@drf.decorators.action(detail=True, methods=["post", "delete"], url_path="favorite")
def favorite(self, request, *args, **kwargs):
"""
Mark or unmark the document as a favorite for the logged-in user based on the HTTP method.
@@ -546,13 +566,13 @@ class DocumentViewSet(
try:
models.DocumentFavorite.objects.create(document=document, user=user)
except ValidationError:
return drf_response.Response(
return drf.response.Response(
{"detail": "Document already marked as favorite"},
status=status.HTTP_200_OK,
status=drf.status.HTTP_200_OK,
)
return drf_response.Response(
return drf.response.Response(
{"detail": "Document marked as favorite"},
status=status.HTTP_201_CREATED,
status=drf.status.HTTP_201_CREATED,
)
# Handle DELETE method to unmark as favorite
@@ -560,16 +580,16 @@ class DocumentViewSet(
document=document, user=user
).delete()
if deleted:
return drf_response.Response(
return drf.response.Response(
{"detail": "Document unmarked as favorite"},
status=status.HTTP_204_NO_CONTENT,
status=drf.status.HTTP_204_NO_CONTENT,
)
return drf_response.Response(
return drf.response.Response(
{"detail": "Document was already not marked as favorite"},
status=status.HTTP_200_OK,
status=drf.status.HTTP_200_OK,
)
@decorators.action(detail=True, methods=["post"], url_path="attachment-upload")
@drf.decorators.action(detail=True, methods=["post"], url_path="attachment-upload")
def attachment_upload(self, request, *args, **kwargs):
"""Upload a file related to a given document"""
# Check permissions first
@@ -594,15 +614,15 @@ class DocumentViewSet(
file, default_storage.bucket_name, key, ExtraArgs=extra_args
)
return drf_response.Response(
{"file": f"{settings.MEDIA_URL:s}{key:s}"}, status=status.HTTP_201_CREATED
return drf.response.Response(
{"file": f"{settings.MEDIA_URL:s}{key:s}"},
status=drf.status.HTTP_201_CREATED,
)
@decorators.action(detail=False, methods=["get"], url_path="retrieve-auth")
def retrieve_auth(self, request, *args, **kwargs):
def _authorize_subrequest(self, request, pattern):
"""
This view is used by an Nginx subrequest to control access to a document's
attachment file.
Shared method to authorize access based on the original URL of an Nginx subrequest
and user permissions. Returns a dictionary of URL parameters if authorized.
The original url is passed by nginx in the "HTTP_X_ORIGINAL_URL" header.
See corresponding ingress configuration in Helm chart and read about the
@@ -614,33 +634,108 @@ class DocumentViewSet(
a 403 error). Note that we return 403 errors without any further details for security
reasons.
Parameters:
- pattern: The regex pattern to extract identifiers from the URL.
Returns:
- A dictionary of URL parameters if the request is authorized.
Raises:
- PermissionDenied if authorization fails.
"""
# Extract the original URL from the request header
original_url = request.META.get("HTTP_X_ORIGINAL_URL")
if not original_url:
logger.debug("Missing HTTP_X_ORIGINAL_URL header in subrequest")
raise drf.exceptions.PermissionDenied()
parsed_url = urlparse(original_url)
match = pattern.search(parsed_url.path)
# If the path does not match the pattern, try to extract the parameters from the query
if not match:
match = pattern.search(parsed_url.query)
if not match:
logger.debug(
"Subrequest URL '%s' did not match pattern '%s'",
parsed_url.path,
pattern,
)
raise drf.exceptions.PermissionDenied()
try:
url_params = match.groupdict()
except (ValueError, AttributeError) as exc:
logger.debug("Failed to extract parameters from subrequest URL: %s", exc)
raise drf.exceptions.PermissionDenied() from exc
pk = url_params.get("pk")
if not pk:
logger.debug("Document ID (pk) not found in URL parameters: %s", url_params)
raise drf.exceptions.PermissionDenied()
# Fetch the document and check if the user has access
try:
document, _created = models.Document.objects.get_or_create(pk=pk)
except models.Document.DoesNotExist as exc:
logger.debug("Document with ID '%s' does not exist", pk)
raise drf.exceptions.PermissionDenied() from exc
user_abilities = document.get_abilities(request.user)
if not user_abilities.get(self.action, False):
logger.debug(
"User '%s' lacks permission for document '%s'", request.user, pk
)
raise drf.exceptions.PermissionDenied()
logger.debug(
"Subrequest authorization successful. Extracted parameters: %s", url_params
)
return url_params, user_abilities, request.user.id
@drf.decorators.action(detail=False, methods=["get"], url_path="media-auth")
def media_auth(self, request, *args, **kwargs):
"""
This view is used by an Nginx subrequest to control access to a document's
attachment file.
When we let the request go through, we compute authorization headers that will be added to
the request going through thanks to the nginx.ingress.kubernetes.io/auth-response-headers
annotation. The request will then be proxied to the object storage backend who will
respond with the file after checking the signature included in headers.
"""
original_url = urlparse(request.META.get("HTTP_X_ORIGINAL_URL"))
match = MEDIA_URL_PATTERN.search(original_url.path)
url_params, _, _ = self._authorize_subrequest(
request, MEDIA_STORAGE_URL_PATTERN
)
pk, key = url_params.values()
try:
pk, attachment_key = match.groups()
except AttributeError as excpt:
raise exceptions.PermissionDenied() from excpt
# Generate S3 authorization headers using the extracted URL parameters
request = utils.generate_s3_authorization_headers(f"{pk:s}/{key:s}")
# Check permission
try:
document = models.Document.objects.get(pk=pk)
except models.Document.DoesNotExist as excpt:
raise exceptions.PermissionDenied() from excpt
return drf.response.Response("authorized", headers=request.headers, status=200)
if not document.get_abilities(request.user).get("retrieve", False):
raise exceptions.PermissionDenied()
@drf.decorators.action(detail=False, methods=["get"], url_path="collaboration-auth")
def collaboration_auth(self, request, *args, **kwargs):
"""
This view is used by an Nginx subrequest to control access to a document's
collaboration server.
"""
_, user_abilities, user_id = self._authorize_subrequest(
request, COLLABORATION_WS_URL_PATTERN
)
can_edit = user_abilities["partial_update"]
# Generate authorization headers and return an authorization to proceed with the request
request = utils.generate_s3_authorization_headers(f"{pk:s}/{attachment_key:s}")
return drf_response.Response("authorized", headers=request.headers, status=200)
# Add the collaboration server secret token to the headers
headers = {
"Authorization": settings.COLLABORATION_SERVER_SECRET,
"X-Can-Edit": str(can_edit),
"X-User-Id": str(user_id),
}
@decorators.action(
return drf.response.Response("authorized", headers=headers, status=200)
@drf.decorators.action(
detail=True,
methods=["post"],
name="Apply a transformation action on a piece of text with AI",
@@ -666,9 +761,9 @@ class DocumentViewSet(
response = AIService().transform(text, action)
return drf_response.Response(response, status=status.HTTP_200_OK)
return drf.response.Response(response, status=drf.status.HTTP_200_OK)
@decorators.action(
@drf.decorators.action(
detail=True,
methods=["post"],
name="Translate a piece of text with AI",
@@ -695,17 +790,17 @@ class DocumentViewSet(
response = AIService().translate(text, language)
return drf_response.Response(response, status=status.HTTP_200_OK)
return drf.response.Response(response, status=drf.status.HTTP_200_OK)
class DocumentAccessViewSet(
ResourceAccessViewsetMixin,
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
drf.mixins.CreateModelMixin,
drf.mixins.DestroyModelMixin,
drf.mixins.ListModelMixin,
drf.mixins.RetrieveModelMixin,
drf.mixins.UpdateModelMixin,
drf.viewsets.GenericViewSet,
):
"""
API ViewSet for all interactions with document accesses.
@@ -743,24 +838,46 @@ class DocumentAccessViewSet(
access = serializer.save()
language = self.request.headers.get("Content-Language", "en-us")
access.document.email_invitation(
language,
access.document.send_invitation_email(
access.user.email,
access.role,
self.request.user,
language,
)
def perform_update(self, serializer):
"""Update an access to the document and notify the collaboration server."""
access = serializer.save()
access_user_id = None
if access.user:
access_user_id = str(access.user.id)
# Notify collaboration server about the access change
CollaborationService().reset_connections(
str(access.document.id), access_user_id
)
def perform_destroy(self, instance):
"""Delete an access to the document and notify the collaboration server."""
instance.delete()
# Notify collaboration server about the access removed
CollaborationService().reset_connections(
str(instance.document.id), str(instance.user.id)
)
class TemplateViewSet(
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
drf.mixins.CreateModelMixin,
drf.mixins.DestroyModelMixin,
drf.mixins.RetrieveModelMixin,
drf.mixins.UpdateModelMixin,
drf.viewsets.GenericViewSet,
):
"""Template ViewSet"""
filter_backends = [drf_filters.OrderingFilter]
filter_backends = [drf.filters.OrderingFilter]
permission_classes = [
permissions.IsAuthenticatedOrSafe,
permissions.AccessPermission,
@@ -795,9 +912,9 @@ class TemplateViewSet(
user = self.request.user
if user.is_authenticated:
queryset = queryset.filter(
Q(accesses__user=user)
| Q(accesses__team__in=user.teams)
| Q(is_public=True)
db.Q(accesses__user=user)
| db.Q(accesses__team__in=user.teams)
| db.Q(is_public=True)
)
else:
queryset = queryset.filter(is_public=True)
@@ -808,7 +925,7 @@ class TemplateViewSet(
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True)
return drf_response.Response(serializer.data)
return drf.response.Response(serializer.data)
def perform_create(self, serializer):
"""Set the current user as owner of the newly created object."""
@@ -819,7 +936,7 @@ class TemplateViewSet(
role=models.RoleChoices.OWNER,
)
@decorators.action(
@drf.decorators.action(
detail=True,
methods=["post"],
url_path="generate-document",
@@ -842,8 +959,8 @@ class TemplateViewSet(
serializer = serializers.DocumentGenerationSerializer(data=request.data)
if not serializer.is_valid():
return drf_response.Response(
serializer.errors, status=status.HTTP_400_BAD_REQUEST
return drf.response.Response(
serializer.errors, status=drf.status.HTTP_400_BAD_REQUEST
)
body = serializer.validated_data["body"]
@@ -856,12 +973,12 @@ class TemplateViewSet(
class TemplateAccessViewSet(
ResourceAccessViewsetMixin,
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
drf.mixins.CreateModelMixin,
drf.mixins.DestroyModelMixin,
drf.mixins.ListModelMixin,
drf.mixins.RetrieveModelMixin,
drf.mixins.UpdateModelMixin,
drf.viewsets.GenericViewSet,
):
"""
API ViewSet for all interactions with template accesses.
@@ -896,12 +1013,12 @@ class TemplateAccessViewSet(
class InvitationViewset(
mixins.CreateModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.DestroyModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
drf.mixins.CreateModelMixin,
drf.mixins.ListModelMixin,
drf.mixins.RetrieveModelMixin,
drf.mixins.DestroyModelMixin,
drf.mixins.UpdateModelMixin,
drf.viewsets.GenericViewSet,
):
"""API ViewSet for user invitations to document.
@@ -953,7 +1070,7 @@ class InvitationViewset(
# Determine which role the logged-in user has in the document
user_roles_query = (
models.DocumentAccess.objects.filter(
Q(user=user) | Q(team__in=teams),
db.Q(user=user) | db.Q(team__in=teams),
document=self.kwargs["resource_id"],
)
.values("document")
@@ -964,18 +1081,18 @@ class InvitationViewset(
queryset = (
# The logged-in user should be administrator or owner to see its accesses
queryset.filter(
Q(
db.Q(
document__accesses__user=user,
document__accesses__role__in=models.PRIVILEGED_ROLES,
)
| Q(
| db.Q(
document__accesses__team__in=teams,
document__accesses__role__in=models.PRIVILEGED_ROLES,
),
)
# Abilities are computed based on logged-in user's role and
# the user role on each document access
.annotate(user_roles=Subquery(user_roles_query))
.annotate(user_roles=db.Subquery(user_roles_query))
.distinct()
)
return queryset
@@ -986,12 +1103,12 @@ class InvitationViewset(
language = self.request.headers.get("Content-Language", "en-us")
invitation.document.email_invitation(
language, invitation.email, invitation.role, self.request.user
invitation.document.send_invitation_email(
invitation.email, invitation.role, self.request.user, language
)
class ConfigView(views.APIView):
class ConfigView(drf.views.APIView):
"""API ViewSet for sharing some public settings."""
permission_classes = [AllowAny]
@@ -1002,7 +1119,7 @@ class ConfigView(views.APIView):
Return a dictionary of public settings.
"""
array_settings = [
"COLLABORATION_SERVER_URL",
"COLLABORATION_WS_URL",
"CRISP_WEBSITE_ID",
"ENVIRONMENT",
"FRONTEND_THEME",
@@ -1016,4 +1133,4 @@ class ConfigView(views.APIView):
if hasattr(settings, setting):
dict_settings[setting] = getattr(settings, setting)
return drf_response.Response(dict_settings)
return drf.response.Response(dict_settings)

View File

@@ -0,0 +1,52 @@
"""Custom authentication classes for the Impress core app"""
from django.conf import settings
from rest_framework.authentication import BaseAuthentication
from rest_framework.exceptions import AuthenticationFailed
class ServerToServerAuthentication(BaseAuthentication):
"""
Custom authentication class for server-to-server requests.
Validates the presence and correctness of the Authorization header.
"""
AUTH_HEADER = "Authorization"
TOKEN_TYPE = "Bearer" # noqa S105
def authenticate(self, request):
"""
Authenticate the server-to-server request by validating the Authorization header.
This method checks if the Authorization header is present in the request, ensures it
contains a valid token with the correct format, and verifies the token against the
list of allowed server-to-server tokens. If the header is missing, improperly formatted,
or contains an invalid token, an AuthenticationFailed exception is raised.
Returns:
None: If authentication is successful
(no user is authenticated for server-to-server requests).
Raises:
AuthenticationFailed: If the Authorization header is missing, malformed,
or contains an invalid token.
"""
auth_header = request.headers.get(self.AUTH_HEADER)
if not auth_header:
raise AuthenticationFailed("Authorization header is missing.")
# Validate token format and existence
auth_parts = auth_header.split(" ")
if len(auth_parts) != 2 or auth_parts[0] != self.TOKEN_TYPE:
raise AuthenticationFailed("Invalid authorization header.")
token = auth_parts[1]
if token not in settings.SERVER_TO_SERVER_API_TOKENS:
raise AuthenticationFailed("Invalid server-to-server token.")
# Authentication is successful, but no user is authenticated
def authenticate_header(self, request):
"""Return the WWW-Authenticate header value."""
return f"{self.TOKEN_TYPE} realm='Create document server to server'"

View File

@@ -0,0 +1,30 @@
# Generated by Django 5.1.2 on 2024-11-30 22:23
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0011_populate_creator_field_and_make_it_required'),
]
operations = [
migrations.AlterField(
model_name='document',
name='creator',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='documents_created', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='invitation',
name='issuer',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='user',
name='language',
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
),
]

View File

@@ -26,8 +26,8 @@ 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 cached_property, lazy
from django.utils.translation import get_language, override
from django.utils.translation import gettext_lazy as _
from django.utils.translation import override
import frontmatter
import markdown
@@ -239,6 +239,13 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
for invitation in valid_invitations
]
)
# Set creator of documents if not yet set (e.g. documents created via server-to-server API)
document_ids = [invitation.document_id for invitation in valid_invitations]
Document.objects.filter(id__in=document_ids, creator__isnull=True).update(
creator=self
)
valid_invitations.delete()
def email_user(self, subject, message, from_email=None, **kwargs):
@@ -342,7 +349,11 @@ class Document(BaseModel):
max_length=20, choices=LinkRoleChoices.choices, default=LinkRoleChoices.READER
)
creator = models.ForeignKey(
User, on_delete=models.RESTRICT, related_name="documents_created"
User,
on_delete=models.RESTRICT,
related_name="documents_created",
blank=True,
null=True,
)
_content = None
@@ -511,65 +522,86 @@ class Document(BaseModel):
is_owner_or_admin = bool(
roles.intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
)
is_editor = bool(RoleChoices.EDITOR in roles)
can_get = bool(roles)
can_update = is_owner_or_admin or RoleChoices.EDITOR in roles
return {
"accesses_manage": is_owner_or_admin,
"accesses_view": has_role,
"ai_transform": is_owner_or_admin or is_editor,
"ai_translate": is_owner_or_admin or is_editor,
"attachment_upload": is_owner_or_admin or is_editor,
"ai_transform": can_update,
"ai_translate": can_update,
"attachment_upload": can_update,
"collaboration_auth": can_get,
"destroy": RoleChoices.OWNER in roles,
"favorite": can_get and user.is_authenticated,
"link_configuration": is_owner_or_admin,
"invite_owner": RoleChoices.OWNER in roles,
"partial_update": is_owner_or_admin or is_editor,
"partial_update": can_update,
"retrieve": can_get,
"update": is_owner_or_admin or is_editor,
"media_auth": can_get,
"update": can_update,
"versions_destroy": is_owner_or_admin,
"versions_list": has_role,
"versions_retrieve": has_role,
}
def email_invitation(self, language, email, role, sender):
"""Send email invitation."""
sender_name = sender.full_name or sender.email
def send_email(self, subject, emails, context=None, language=None):
"""Generate and send email from a template."""
context = context or {}
domain = Site.objects.get_current().domain
language = language or get_language()
context.update(
{
"brandname": settings.EMAIL_BRAND_NAME,
"document": self,
"domain": domain,
"link": f"{domain}/docs/{self.id}/",
"logo_img": settings.EMAIL_LOGO_IMG,
}
)
try:
with override(language):
title = _(
"%(sender_name)s shared a document with you: %(document)s"
) % {
"sender_name": sender_name,
"document": self.title,
}
template_vars = {
"title": title,
"domain": domain,
"document": self,
"link": f"{domain}/docs/{self.id}/",
"sender_name": sender_name,
"sender_name_email": f"{sender.full_name} ({sender.email})"
if sender.full_name
else sender.email,
"role": RoleChoices(role).label.lower(),
}
msg_html = render_to_string("mail/html/invitation.html", template_vars)
msg_plain = render_to_string("mail/text/invitation.txt", template_vars)
with override(language):
msg_html = render_to_string("mail/html/invitation.html", context)
msg_plain = render_to_string("mail/text/invitation.txt", context)
subject = str(subject) # Force translation
try:
send_mail(
title,
subject.capitalize(),
msg_plain,
settings.EMAIL_FROM,
[email],
emails,
html_message=msg_html,
fail_silently=False,
)
except smtplib.SMTPException as exception:
logger.error("invitation to %s was not sent: %s", emails, exception)
except smtplib.SMTPException as exception:
logger.error("invitation to %s was not sent: %s", email, exception)
def send_invitation_email(self, email, role, sender, language=None):
"""Method allowing a user to send an email invitation to another user for a document."""
language = language or get_language()
role = RoleChoices(role).label
sender_name = sender.full_name or sender.email
sender_name_email = (
f"{sender.full_name:s} ({sender.email})"
if sender.full_name
else sender.email
)
with override(language):
context = {
"title": _("{name} shared a document with you!").format(
name=sender_name
),
"message": _(
'{name} invited you with the role "{role}" on the following document:'
).format(name=sender_name_email, role=role.lower()),
}
subject = _("{name} shared a document with you: {title}").format(
name=sender_name, title=self.title
)
self.send_email(subject, [email], context, language)
class LinkTrace(BaseModel):
@@ -710,15 +742,15 @@ class Template(BaseModel):
is_owner_or_admin = bool(
set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
)
is_editor = bool(RoleChoices.EDITOR in roles)
can_get = self.is_public or bool(roles)
can_update = is_owner_or_admin or RoleChoices.EDITOR in roles
return {
"destroy": RoleChoices.OWNER in roles,
"generate_document": can_get,
"accesses_manage": is_owner_or_admin,
"update": is_owner_or_admin or is_editor,
"partial_update": is_owner_or_admin or is_editor,
"update": can_update,
"partial_update": can_update,
"retrieve": can_get,
}
@@ -885,6 +917,8 @@ class Invitation(BaseModel):
User,
on_delete=models.CASCADE,
related_name="invitations",
blank=True,
null=True,
)
class Meta:

View File

@@ -67,10 +67,19 @@ class AIService:
)
content = response.choices[0].message.content
sanitized_content = re.sub(r"(?<!\\)\n", "\\\\n", content)
sanitized_content = re.sub(r"(?<!\\)\t", "\\\\t", sanitized_content)
json_response = json.loads(sanitized_content)
try:
sanitized_content = re.sub(r'\s*"answer"\s*:\s*', '"answer": ', content)
sanitized_content = re.sub(r"\s*\}", "}", sanitized_content)
sanitized_content = re.sub(r"(?<!\\)\n", "\\\\n", sanitized_content)
sanitized_content = re.sub(r"(?<!\\)\t", "\\\\t", sanitized_content)
json_response = json.loads(sanitized_content)
except (json.JSONDecodeError, IndexError):
try:
json_response = json.loads(content)
except json.JSONDecodeError as err:
raise RuntimeError("AI response is not valid JSON", content) from err
if "answer" not in json_response:
raise RuntimeError("AI response does not contain an answer")

View File

@@ -0,0 +1,43 @@
"""Collaboration services."""
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
import requests
class CollaborationService:
"""Service class for Collaboration related operations."""
def __init__(self):
"""Ensure that the collaboration configuration is set properly."""
if settings.COLLABORATION_API_URL is None:
raise ImproperlyConfigured("Collaboration configuration not set")
def reset_connections(self, room, user_id=None):
"""
Reset connections of a room in the collaboration server.
Reseting a connection means that the user will be disconnected and will
have to reconnect to the collaboration server, with updated rights.
"""
endpoint = "reset-connections"
# room is necessary as a parameter, it is easier to stick to the
# same pod thanks to a parameter
endpoint_url = f"{settings.COLLABORATION_API_URL}{endpoint}/?room={room}"
# Note: Collaboration microservice accepts only raw token, which is not recommended
headers = {"Authorization": settings.COLLABORATION_SERVER_SECRET}
if user_id:
headers["X-User-Id"] = user_id
try:
response = requests.post(endpoint_url, headers=headers, timeout=10)
except requests.RequestException as e:
raise requests.HTTPError("Failed to notify WebSocket server.") from e
if response.status_code != 200:
raise requests.HTTPError(
f"Failed to notify WebSocket server. Status code: {response.status_code}, "
f"Response: {response.text}"
)

View File

@@ -0,0 +1,78 @@
"""Converter services."""
from django.conf import settings
import requests
class ConversionError(Exception):
"""Base exception for conversion-related errors."""
class ValidationError(ConversionError):
"""Raised when the input validation fails."""
class ServiceUnavailableError(ConversionError):
"""Raised when the conversion service is unavailable."""
class InvalidResponseError(ConversionError):
"""Raised when the conversion service returns an invalid response."""
class MissingContentError(ConversionError):
"""Raised when the response is missing required content."""
class YdocConverter:
"""Service class for conversion-related operations."""
@property
def auth_header(self):
"""Build microservice authentication header."""
# Note: Yprovider microservice accepts only raw token, which is not recommended
return settings.Y_PROVIDER_API_KEY
def convert_markdown(self, text):
"""Convert a Markdown text into our internal format using an external microservice."""
if not text:
raise ValidationError("Input text cannot be empty")
try:
response = requests.post(
f"{settings.Y_PROVIDER_API_BASE_URL}{settings.CONVERSION_API_ENDPOINT}/",
json={
"content": text,
},
headers={
"Authorization": self.auth_header,
"Content-Type": "application/json",
},
timeout=settings.CONVERSION_API_TIMEOUT,
verify=settings.CONVERSION_API_SECURE,
)
response.raise_for_status()
conversion_response = response.json()
except requests.RequestException as err:
raise ServiceUnavailableError(
"Failed to connect to conversion service",
) from err
except ValueError as err:
raise InvalidResponseError(
"Could not parse conversion service response"
) from err
try:
document_content = conversion_response[
settings.CONVERSION_API_CONTENT_FIELD
]
except KeyError as err:
raise MissingContentError(
f"Response missing required field: {settings.CONVERSION_API_CONTENT_FIELD}"
) from err
return document_content

View File

@@ -11,6 +11,9 @@ from rest_framework.test import APIClient
from core import factories, models
from core.api import serializers
from core.tests.conftest import TEAM, USER, VIA
from core.tests.test_services_collaboration_services import ( # pylint: disable=unused-import
mock_reset_connections,
)
pytestmark = pytest.mark.django_db
@@ -316,7 +319,11 @@ def test_api_document_accesses_update_authenticated_reader_or_editor(
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_update_administrator_except_owner(via, mock_user_teams):
def test_api_document_accesses_update_administrator_except_owner(
via,
mock_user_teams,
mock_reset_connections, # pylint: disable=redefined-outer-name
):
"""
A user who is a direct administrator in a document should be allowed to update a user
access for this document, as long as they don't try to set the role to owner.
@@ -351,18 +358,21 @@ def test_api_document_accesses_update_administrator_except_owner(via, mock_user_
for field, value in new_values.items():
new_data = {**old_values, field: value}
response = client.put(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
)
if (
new_data["role"] == old_values["role"]
): # we are not really updating the role
if new_data["role"] == old_values["role"]:
response = client.put(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
)
assert response.status_code == 403
else:
assert response.status_code == 200
with mock_reset_connections(document.id, str(access.user_id)):
response = client.put(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
)
assert response.status_code == 200
access.refresh_from_db()
updated_values = serializers.DocumentAccessSerializer(instance=access).data
@@ -420,7 +430,11 @@ def test_api_document_accesses_update_administrator_from_owner(via, mock_user_te
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_update_administrator_to_owner(via, mock_user_teams):
def test_api_document_accesses_update_administrator_to_owner(
via,
mock_user_teams,
mock_reset_connections, # pylint: disable=redefined-outer-name
):
"""
A user who is an administrator in a document, should not be allowed to update
the user access of another user to grant document ownership.
@@ -457,16 +471,23 @@ def test_api_document_accesses_update_administrator_to_owner(via, mock_user_team
for field, value in new_values.items():
new_data = {**old_values, field: value}
response = client.put(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
)
# We are not allowed or not really updating the role
if field == "role" or new_data["role"] == old_values["role"]:
response = client.put(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
)
assert response.status_code == 403
else:
assert response.status_code == 200
with mock_reset_connections(document.id, str(access.user_id)):
response = client.put(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
)
assert response.status_code == 200
access.refresh_from_db()
updated_values = serializers.DocumentAccessSerializer(instance=access).data
@@ -474,7 +495,11 @@ def test_api_document_accesses_update_administrator_to_owner(via, mock_user_team
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_update_owner(via, mock_user_teams):
def test_api_document_accesses_update_owner(
via,
mock_user_teams,
mock_reset_connections, # pylint: disable=redefined-outer-name
):
"""
A user who is an owner in a document should be allowed to update
a user access for this document whatever the role.
@@ -507,18 +532,24 @@ def test_api_document_accesses_update_owner(via, mock_user_teams):
for field, value in new_values.items():
new_data = {**old_values, field: value}
response = client.put(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
)
if (
new_data["role"] == old_values["role"]
): # we are not really updating the role
response = client.put(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
)
assert response.status_code == 403
else:
assert response.status_code == 200
with mock_reset_connections(document.id, str(access.user_id)):
response = client.put(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
)
assert response.status_code == 200
access.refresh_from_db()
updated_values = serializers.DocumentAccessSerializer(instance=access).data
@@ -530,7 +561,11 @@ def test_api_document_accesses_update_owner(via, mock_user_teams):
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_update_owner_self(via, mock_user_teams):
def test_api_document_accesses_update_owner_self(
via,
mock_user_teams,
mock_reset_connections, # pylint: disable=redefined-outer-name
):
"""
A user who is owner of a document should be allowed to update
their own user access provided there are other owners in the document.
@@ -568,21 +603,23 @@ def test_api_document_accesses_update_owner_self(via, mock_user_teams):
# Add another owner and it should now work
factories.UserDocumentAccessFactory(document=document, role="owner")
response = client.put(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
data={
**old_values,
"role": new_role,
"user_id": old_values.get("user", {}).get("id")
if old_values.get("user") is not None
else None,
},
format="json",
)
user_id = str(access.user_id) if via == USER else None
with mock_reset_connections(document.id, user_id):
response = client.put(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
data={
**old_values,
"role": new_role,
"user_id": old_values.get("user", {}).get("id")
if old_values.get("user") is not None
else None,
},
format="json",
)
assert response.status_code == 200
access.refresh_from_db()
assert access.role == new_role
assert response.status_code == 200
access.refresh_from_db()
assert access.role == new_role
# Delete
@@ -656,7 +693,9 @@ def test_api_document_accesses_delete_reader_or_editor(via, role, mock_user_team
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_delete_administrators_except_owners(
via, mock_user_teams
via,
mock_user_teams,
mock_reset_connections, # pylint: disable=redefined-outer-name
):
"""
Users who are administrators in a document should be allowed to delete an access
@@ -685,12 +724,13 @@ def test_api_document_accesses_delete_administrators_except_owners(
assert models.DocumentAccess.objects.count() == 2
assert models.DocumentAccess.objects.filter(user=access.user).exists()
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
)
with mock_reset_connections(document.id, str(access.user_id)):
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 204
assert models.DocumentAccess.objects.count() == 1
assert response.status_code == 204
assert models.DocumentAccess.objects.count() == 1
@pytest.mark.parametrize("via", VIA)
@@ -729,7 +769,11 @@ def test_api_document_accesses_delete_administrator_on_owners(via, mock_user_tea
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_delete_owners(via, mock_user_teams):
def test_api_document_accesses_delete_owners(
via,
mock_user_teams,
mock_reset_connections, # pylint: disable=redefined-outer-name
):
"""
Users should be able to delete the document access of another user
for a document of which they are owner.
@@ -753,12 +797,13 @@ def test_api_document_accesses_delete_owners(via, mock_user_teams):
assert models.DocumentAccess.objects.count() == 2
assert models.DocumentAccess.objects.filter(user=access.user).exists()
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
)
with mock_reset_connections(document.id, str(access.user_id)):
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 204
assert models.DocumentAccess.objects.count() == 1
assert response.status_code == 204
assert models.DocumentAccess.objects.count() == 1
@pytest.mark.parametrize("via", VIA)

View File

@@ -171,10 +171,11 @@ def test_api_document_accesses_create_authenticated_administrator(via, mock_user
email = mail.outbox[0]
assert email.to == [other_user["email"]]
email_content = " ".join(email.body.split())
assert f"{user.full_name} shared a document with you!" in email_content
assert (
f"{user.full_name} shared a document with you: {document.title}"
in email_content
)
f"{user.full_name} ({user.email}) invited you with the role &quot;{role}&quot; "
f"on the following document: {document.title}"
) in email_content
assert "docs/" + str(document.id) + "/" in email_content
@@ -228,8 +229,9 @@ def test_api_document_accesses_create_authenticated_owner(via, mock_user_teams):
email = mail.outbox[0]
assert email.to == [other_user["email"]]
email_content = " ".join(email.body.split())
assert f"{user.full_name} shared a document with you!" in email_content
assert (
f"{user.full_name} shared a document with you: {document.title}"
in email_content
)
f"{user.full_name} ({user.email}) invited you with the role &quot;{role}&quot; "
f"on the following document: {document.title}"
) in email_content
assert "docs/" + str(document.id) + "/" in email_content

View File

@@ -7,6 +7,7 @@ from datetime import timedelta
from unittest import mock
from django.core import mail
from django.test import override_settings
from django.utils import timezone
import pytest
@@ -339,6 +340,7 @@ def test_api_document_invitations_create_authenticated_outsider():
assert response.status_code == 403
@override_settings(EMAIL_BRAND_NAME="My brand name", EMAIL_LOGO_IMG="my-img.jpg")
@pytest.mark.parametrize(
"inviting,invited,response_code",
(
@@ -402,10 +404,13 @@ def test_api_document_invitations_create_privileged_members(
email = mail.outbox[0]
assert email.to == ["guest@example.com"]
email_content = " ".join(email.body.split())
assert f"{user.full_name} shared a document with you!" in email_content
assert (
f"{user.full_name} shared a document with you: {document.title}"
in email_content
)
f"{user.full_name} ({user.email}) invited you with the role &quot;{invited}&quot; "
f"on the following document: {document.title}"
) in email_content
assert "My brand name" in email_content
assert "my-img.jpg" in email_content
else:
assert models.Invitation.objects.exists() is False
@@ -452,10 +457,7 @@ def test_api_document_invitations_create_email_from_content_language():
assert email.to == ["guest@example.com"]
email_content = " ".join(email.body.split())
assert (
f"{user.full_name} a partagé un document avec vous: {document.title}"
in email_content
)
assert f"{user.full_name} a partagé un document avec vous!" in email_content
def test_api_document_invitations_create_email_from_content_language_not_supported():
@@ -494,10 +496,7 @@ def test_api_document_invitations_create_email_from_content_language_not_support
assert email.to == ["guest@example.com"]
email_content = " ".join(email.body.split())
assert (
f"{user.full_name} shared a document with you: {document.title}"
in email_content
)
assert f"{user.full_name} shared a document with you!" in email_content
def test_api_document_invitations_create_email_full_name_empty():
@@ -535,10 +534,10 @@ def test_api_document_invitations_create_email_full_name_empty():
assert email.to == ["guest@example.com"]
email_content = " ".join(email.body.split())
assert f"{user.email} shared a document with you: {document.title}" in email_content
assert f"{user.email} shared a document with you!" in email_content
assert (
f'{user.email} invited you with the role "reader" on the '
f"following document : {document.title}" in email_content
f"{user.email.capitalize()} invited you with the role &quot;reader&quot; on the "
f"following document: {document.title}" in email_content
)

View File

@@ -0,0 +1,364 @@
"""
Tests for Documents API endpoint in impress's core app: create
"""
# pylint: disable=W0621
from unittest.mock import patch
from django.core import mail
from django.test import override_settings
import pytest
from rest_framework.test import APIClient
from core import factories
from core.models import Document, Invitation, User
from core.services.converter_services import ConversionError, YdocConverter
pytestmark = pytest.mark.django_db
@pytest.fixture
def mock_convert_markdown():
"""Mock YdocConverter.convert_markdown to return a converted content."""
with patch.object(
YdocConverter,
"convert_markdown",
return_value="Converted document content",
) as mock:
yield mock
def test_api_documents_create_for_owner_missing_token():
"""Requests with no token should not be allowed to create documents for owner."""
data = {
"title": "My Document",
"content": "Document content",
"sub": "123",
"email": "john.doe@example.com",
}
response = APIClient().post(
"/api/v1.0/documents/create-for-owner/", data, format="json"
)
assert response.status_code == 401
assert not Document.objects.exists()
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"])
def test_api_documents_create_for_owner_invalid_token():
"""Requests with an invalid token should not be allowed to create documents for owner."""
data = {
"title": "My Document",
"content": "Document content",
"sub": "123",
"email": "john.doe@example.com",
"language": "fr",
}
response = APIClient().post(
"/api/v1.0/documents/create-for-owner/",
data,
format="json",
HTTP_AUTHORIZATION="Bearer InvalidToken",
)
assert response.status_code == 401
assert not Document.objects.exists()
def test_api_documents_create_for_owner_authenticated_forbidden():
"""
Authenticated users should not be allowed to call create documents on behalf of other users.
This API endpoint is reserved for server-to-server calls.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
data = {
"title": "My Document",
"content": "Document content",
"sub": "123",
"email": "john.doe@example.com",
}
response = client.post(
"/api/v1.0/documents/create-for-owner/",
data,
format="json",
)
assert response.status_code == 401
assert not Document.objects.exists()
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"])
def test_api_documents_create_for_owner_missing_sub():
"""Requests with no sub should not be allowed to create documents for owner."""
data = {
"title": "My Document",
"content": "Document content",
"email": "john.doe@example.com",
}
response = APIClient().post(
"/api/v1.0/documents/create-for-owner/",
data,
format="json",
HTTP_AUTHORIZATION="Bearer DummyToken",
)
assert response.status_code == 400
assert not Document.objects.exists()
assert response.json() == {"sub": ["This field is required."]}
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"])
def test_api_documents_create_for_owner_missing_email():
"""Requests with no email should not be allowed to create documents for owner."""
data = {
"title": "My Document",
"content": "Document content",
"sub": "123",
}
response = APIClient().post(
"/api/v1.0/documents/create-for-owner/",
data,
format="json",
HTTP_AUTHORIZATION="Bearer DummyToken",
)
assert response.status_code == 400
assert not Document.objects.exists()
assert response.json() == {"email": ["This field is required."]}
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"])
def test_api_documents_create_for_owner_invalid_sub():
"""Requests with an invalid sub should not be allowed to create documents for owner."""
data = {
"title": "My Document",
"content": "Document content",
"sub": "123!!",
"email": "john.doe@example.com",
}
response = APIClient().post(
"/api/v1.0/documents/create-for-owner/",
data,
format="json",
HTTP_AUTHORIZATION="Bearer DummyToken",
)
assert response.status_code == 400
assert not Document.objects.exists()
assert response.json() == {
"sub": [
"Enter a valid sub. This value may contain only letters, "
"numbers, and @/./+/-/_/: characters."
]
}
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"])
def test_api_documents_create_for_owner_existing(mock_convert_markdown):
"""It should be possible to create a document on behalf of a pre-existing user."""
user = factories.UserFactory(language="en-us")
data = {
"title": "My Document",
"content": "Document content",
"sub": str(user.sub),
"email": "irrelevant@example.com", # Should be ignored since the user already exists
}
response = APIClient().post(
"/api/v1.0/documents/create-for-owner/",
data,
format="json",
HTTP_AUTHORIZATION="Bearer DummyToken",
)
assert response.status_code == 201
mock_convert_markdown.assert_called_once_with("Document content")
document = Document.objects.get()
assert response.json() == {"id": str(document.id)}
assert document.title == "My Document"
assert document.content == "Converted document content"
assert document.creator == user
assert document.accesses.filter(user=user, role="owner").exists()
assert Invitation.objects.exists() is False
assert len(mail.outbox) == 1
email = mail.outbox[0]
assert email.to == [user.email]
assert email.subject == "A new document was created on your behalf!"
email_content = " ".join(email.body.split())
assert "A new document was created on your behalf!" in email_content
assert (
"You have been granted ownership of a new document: My Document"
) in email_content
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"])
def test_api_documents_create_for_owner_new_user(mock_convert_markdown):
"""
It should be possible to create a document on behalf of new users by
passing only their email address.
"""
data = {
"title": "My Document",
"content": "Document content",
"sub": "123",
"email": "john.doe@example.com", # Should be used to create a new user
}
response = APIClient().post(
"/api/v1.0/documents/create-for-owner/",
data,
format="json",
HTTP_AUTHORIZATION="Bearer DummyToken",
)
assert response.status_code == 201
mock_convert_markdown.assert_called_once_with("Document content")
document = Document.objects.get()
assert response.json() == {"id": str(document.id)}
assert document.title == "My Document"
assert document.content == "Converted document content"
assert document.creator is None
assert document.accesses.exists() is False
invitation = Invitation.objects.get()
assert invitation.email == "john.doe@example.com"
assert invitation.role == "owner"
assert len(mail.outbox) == 1
email = mail.outbox[0]
assert email.to == ["john.doe@example.com"]
assert email.subject == "A new document was created on your behalf!"
email_content = " ".join(email.body.split())
assert "A new document was created on your behalf!" in email_content
assert (
"You have been granted ownership of a new document: My Document"
) in email_content
# The creator field on the document should be set when the user is created
user = User.objects.create(email="john.doe@example.com", password="!")
document.refresh_from_db()
assert document.creator == user
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"])
def test_api_documents_create_for_owner_with_custom_language(mock_convert_markdown):
"""
Test creating a document with a specific language.
Useful if the remote server knows the user's language.
"""
data = {
"title": "My Document",
"content": "Document content",
"sub": "123",
"email": "john.doe@example.com",
"language": "fr-fr",
}
response = APIClient().post(
"/api/v1.0/documents/create-for-owner/",
data,
format="json",
HTTP_AUTHORIZATION="Bearer DummyToken",
)
assert response.status_code == 201
mock_convert_markdown.assert_called_once_with("Document content")
assert len(mail.outbox) == 1
email = mail.outbox[0]
assert email.to == ["john.doe@example.com"]
assert email.subject == "Un nouveau document a été créé pour vous !"
email_content = " ".join(email.body.split())
assert "Un nouveau document a été créé pour vous !" in email_content
assert (
"Vous avez été déclaré propriétaire d&#x27;un nouveau document : My Document"
) in email_content
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"])
def test_api_documents_create_for_owner_with_custom_subject_and_message(
mock_convert_markdown,
):
"""It should be possible to customize the subject and message of the invitation email."""
data = {
"title": "My Document",
"content": "Document content",
"sub": "123",
"email": "john.doe@example.com",
"message": "mon message spécial",
"subject": "mon sujet spécial !",
}
response = APIClient().post(
"/api/v1.0/documents/create-for-owner/",
data,
format="json",
HTTP_AUTHORIZATION="Bearer DummyToken",
)
assert response.status_code == 201
mock_convert_markdown.assert_called_once_with("Document content")
assert len(mail.outbox) == 1
email = mail.outbox[0]
assert email.to == ["john.doe@example.com"]
assert email.subject == "Mon sujet spécial !"
email_content = " ".join(email.body.split())
assert "Mon sujet spécial !" in email_content
assert "Mon message spécial" in email_content
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"])
def test_api_documents_create_for_owner_with_converter_exception(
mock_convert_markdown,
):
"""It should be possible to customize the subject and message of the invitation email."""
mock_convert_markdown.side_effect = ConversionError("Conversion failed")
data = {
"title": "My Document",
"content": "Document content",
"sub": "123",
"email": "john.doe@example.com",
"message": "mon message spécial",
"subject": "mon sujet spécial !",
}
response = APIClient().post(
"/api/v1.0/documents/create-for-owner/",
data,
format="json",
HTTP_AUTHORIZATION="Bearer DummyToken",
)
mock_convert_markdown.assert_called_once_with("Document content")
assert response.status_code == 500
assert response.json() == {"detail": "could not convert content"}

View File

@@ -6,6 +6,9 @@ from rest_framework.test import APIClient
from core import factories, models
from core.api import serializers
from core.tests.conftest import TEAM, USER, VIA
from core.tests.test_services_collaboration_services import ( # pylint: disable=unused-import
mock_reset_connections,
)
pytestmark = pytest.mark.django_db
@@ -116,7 +119,10 @@ def test_api_documents_link_configuration_update_authenticated_related_forbidden
@pytest.mark.parametrize("role", ["administrator", "owner"])
@pytest.mark.parametrize("via", VIA)
def test_api_documents_link_configuration_update_authenticated_related_success(
via, role, mock_user_teams
via,
role,
mock_user_teams,
mock_reset_connections, # pylint: disable=redefined-outer-name
):
"""
A user who is administrator or owner of a document should be allowed to update
@@ -139,14 +145,16 @@ def test_api_documents_link_configuration_update_authenticated_related_success(
new_document_values = serializers.LinkDocumentSerializer(
instance=factories.DocumentFactory()
).data
response = client.put(
f"/api/v1.0/documents/{document.id!s}/link-configuration/",
new_document_values,
format="json",
)
assert response.status_code == 200
document = models.Document.objects.get(pk=document.pk)
document_values = serializers.LinkDocumentSerializer(instance=document).data
for key, value in document_values.items():
assert value == new_document_values[key]
with mock_reset_connections(document.id):
response = client.put(
f"/api/v1.0/documents/{document.id!s}/link-configuration/",
new_document_values,
format="json",
)
assert response.status_code == 200
document = models.Document.objects.get(pk=document.pk)
document_values = serializers.LinkDocumentSerializer(instance=document).data
for key, value in document_values.items():
assert value == new_document_values[key]

View File

@@ -20,7 +20,7 @@ from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
def test_api_documents_retrieve_auth_anonymous_public():
def test_api_documents_media_auth_anonymous_public():
"""Anonymous users should be able to retrieve attachments linked to a public document"""
document = factories.DocumentFactory(link_reach="public")
@@ -36,7 +36,7 @@ def test_api_documents_retrieve_auth_anonymous_public():
original_url = f"http://localhost/media/{key:s}"
response = APIClient().get(
"/api/v1.0/documents/retrieve-auth/", HTTP_X_ORIGINAL_URL=original_url
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 200
@@ -65,7 +65,7 @@ def test_api_documents_retrieve_auth_anonymous_public():
@pytest.mark.parametrize("reach", ["authenticated", "restricted"])
def test_api_documents_retrieve_auth_anonymous_authenticated_or_restricted(reach):
def test_api_documents_media_auth_anonymous_authenticated_or_restricted(reach):
"""
Anonymous users should not be allowed to retrieve attachments linked to a document
with link reach set to authenticated or restricted.
@@ -76,7 +76,7 @@ def test_api_documents_retrieve_auth_anonymous_authenticated_or_restricted(reach
media_url = f"http://localhost/media/{document.pk!s}/attachments/{filename:s}"
response = APIClient().get(
"/api/v1.0/documents/retrieve-auth/", HTTP_X_ORIGINAL_URL=media_url
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url
)
assert response.status_code == 403
@@ -84,7 +84,7 @@ def test_api_documents_retrieve_auth_anonymous_authenticated_or_restricted(reach
@pytest.mark.parametrize("reach", ["public", "authenticated"])
def test_api_documents_retrieve_auth_authenticated_public_or_authenticated(reach):
def test_api_documents_media_auth_authenticated_public_or_authenticated(reach):
"""
Authenticated users who are not related to a document should be able to retrieve
attachments related to a document with public or authenticated link reach.
@@ -107,7 +107,7 @@ def test_api_documents_retrieve_auth_authenticated_public_or_authenticated(reach
original_url = f"http://localhost/media/{key:s}"
response = client.get(
"/api/v1.0/documents/retrieve-auth/", HTTP_X_ORIGINAL_URL=original_url
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 200
@@ -135,7 +135,7 @@ def test_api_documents_retrieve_auth_authenticated_public_or_authenticated(reach
assert response.content.decode("utf-8") == "my prose"
def test_api_documents_retrieve_auth_authenticated_restricted():
def test_api_documents_media_auth_authenticated_restricted():
"""
Authenticated users who are not related to a document should not be allowed to
retrieve attachments linked to a document that is restricted.
@@ -150,7 +150,7 @@ def test_api_documents_retrieve_auth_authenticated_restricted():
media_url = f"http://localhost/media/{document.pk!s}/attachments/{filename:s}"
response = client.get(
"/api/v1.0/documents/retrieve-auth/", HTTP_X_ORIGINAL_URL=media_url
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url
)
assert response.status_code == 403
@@ -158,7 +158,7 @@ def test_api_documents_retrieve_auth_authenticated_restricted():
@pytest.mark.parametrize("via", VIA)
def test_api_documents_retrieve_auth_related(via, mock_user_teams):
def test_api_documents_media_auth_related(via, mock_user_teams):
"""
Users who have a specific access to a document, whatever the role, should be able to
retrieve related attachments.
@@ -186,7 +186,7 @@ def test_api_documents_retrieve_auth_related(via, mock_user_teams):
original_url = f"http://localhost/media/{key:s}"
response = client.get(
"/api/v1.0/documents/retrieve-auth/", HTTP_X_ORIGINAL_URL=original_url
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 200

View File

@@ -26,11 +26,13 @@ def test_api_documents_retrieve_anonymous_public():
"ai_transform": document.link_role == "editor",
"ai_translate": document.link_role == "editor",
"attachment_upload": document.link_role == "editor",
"collaboration_auth": True,
"destroy": False,
# Anonymous user can't favorite a document even with read access
"favorite": False,
"invite_owner": False,
"link_configuration": False,
"media_auth": True,
"partial_update": document.link_role == "editor",
"retrieve": True,
"update": document.link_role == "editor",
@@ -88,9 +90,11 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
"ai_transform": document.link_role == "editor",
"ai_translate": document.link_role == "editor",
"attachment_upload": document.link_role == "editor",
"collaboration_auth": True,
"destroy": False,
"favorite": True,
"invite_owner": False,
"media_auth": True,
"link_configuration": False,
"partial_update": document.link_role == "editor",
"retrieve": True,

View File

@@ -16,7 +16,7 @@ pytestmark = pytest.mark.django_db
@override_settings(
COLLABORATION_SERVER_URL="http://testcollab/",
COLLABORATION_WS_URL="http://testcollab/",
CRISP_WEBSITE_ID="123",
FRONTEND_THEME="test-theme",
MEDIA_BASE_URL="http://testserver/",
@@ -34,7 +34,7 @@ def test_api_config(is_authenticated):
response = client.get("/api/v1.0/config/")
assert response.status_code == HTTP_200_OK
assert response.json() == {
"COLLABORATION_SERVER_URL": "http://testcollab/",
"COLLABORATION_WS_URL": "http://testcollab/",
"CRISP_WEBSITE_ID": "123",
"ENVIRONMENT": "test",
"FRONTEND_THEME": "test-theme",

View File

@@ -33,11 +33,8 @@ def test_models_documents_id_unique():
def test_models_documents_creator_required():
"""The "creator" field should be required."""
with pytest.raises(ValidationError) as excinfo:
models.Document.objects.create()
assert excinfo.value.message_dict["creator"] == ["This field cannot be null."]
"""No field should be required on the Document model."""
models.Document.objects.create()
def test_models_documents_title_null():
@@ -98,9 +95,11 @@ def test_models_documents_get_abilities_forbidden(is_authenticated, reach, role)
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
"collaboration_auth": False,
"destroy": False,
"favorite": False,
"invite_owner": False,
"media_auth": False,
"link_configuration": False,
"partial_update": False,
"retrieve": False,
@@ -133,10 +132,12 @@ def test_models_documents_get_abilities_reader(is_authenticated, reach):
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
"collaboration_auth": True,
"destroy": False,
"favorite": is_authenticated,
"invite_owner": False,
"link_configuration": False,
"media_auth": True,
"partial_update": False,
"retrieve": True,
"update": False,
@@ -168,10 +169,12 @@ def test_models_documents_get_abilities_editor(is_authenticated, reach):
"ai_transform": True,
"ai_translate": True,
"attachment_upload": True,
"collaboration_auth": True,
"destroy": False,
"favorite": is_authenticated,
"invite_owner": False,
"link_configuration": False,
"media_auth": True,
"partial_update": True,
"retrieve": True,
"update": True,
@@ -192,10 +195,12 @@ def test_models_documents_get_abilities_owner():
"ai_transform": True,
"ai_translate": True,
"attachment_upload": True,
"collaboration_auth": True,
"destroy": True,
"favorite": True,
"invite_owner": True,
"link_configuration": True,
"media_auth": True,
"partial_update": True,
"retrieve": True,
"update": True,
@@ -215,10 +220,12 @@ def test_models_documents_get_abilities_administrator():
"ai_transform": True,
"ai_translate": True,
"attachment_upload": True,
"collaboration_auth": True,
"destroy": False,
"favorite": True,
"invite_owner": False,
"link_configuration": True,
"media_auth": True,
"partial_update": True,
"retrieve": True,
"update": True,
@@ -241,10 +248,12 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
"ai_transform": True,
"ai_translate": True,
"attachment_upload": True,
"collaboration_auth": True,
"destroy": False,
"favorite": True,
"invite_owner": False,
"link_configuration": False,
"media_auth": True,
"partial_update": True,
"retrieve": True,
"update": True,
@@ -269,10 +278,12 @@ def test_models_documents_get_abilities_reader_user(django_assert_num_queries):
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
"collaboration_auth": True,
"destroy": False,
"favorite": True,
"invite_owner": False,
"link_configuration": False,
"media_auth": True,
"partial_update": False,
"retrieve": True,
"update": False,
@@ -298,10 +309,12 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
"collaboration_auth": True,
"destroy": False,
"favorite": True,
"invite_owner": False,
"link_configuration": False,
"media_auth": True,
"partial_update": False,
"retrieve": True,
"update": False,
@@ -414,8 +427,8 @@ def test_models_documents__email_invitation__success():
assert len(mail.outbox) == 0
sender = factories.UserFactory(full_name="Test Sender", email="sender@example.com")
document.email_invitation(
"en", "guest@example.com", models.RoleChoices.EDITOR, sender
document.send_invitation_email(
"guest@example.com", models.RoleChoices.EDITOR, sender, "en"
)
# pylint: disable-next=no-member
@@ -428,8 +441,8 @@ def test_models_documents__email_invitation__success():
email_content = " ".join(email.body.split())
assert (
f'Test Sender (sender@example.com) invited you with the role "editor" '
f"on the following document : {document.title}" in email_content
f"Test Sender (sender@example.com) invited you with the role &quot;editor&quot; "
f"on the following document: {document.title}" in email_content
)
assert f"docs/{document.id}/" in email_content
@@ -446,11 +459,11 @@ def test_models_documents__email_invitation__success_fr():
sender = factories.UserFactory(
full_name="Test Sender2", email="sender2@example.com"
)
document.email_invitation(
"fr-fr",
document.send_invitation_email(
"guest2@example.com",
models.RoleChoices.OWNER,
sender,
"fr-fr",
)
# pylint: disable-next=no-member
@@ -463,8 +476,8 @@ def test_models_documents__email_invitation__success_fr():
email_content = " ".join(email.body.split())
assert (
f'Test Sender2 (sender2@example.com) vous a invité avec le rôle "propriétaire" '
f"sur le document suivant : {document.title}" in email_content
f"Test Sender2 (sender2@example.com) vous a invité avec le rôle &quot;propriétaire&quot; "
f"sur le document suivant: {document.title}" in email_content
)
assert f"docs/{document.id}/" in email_content
@@ -482,11 +495,11 @@ def test_models_documents__email_invitation__failed(mock_logger, _mock_send_mail
assert len(mail.outbox) == 0
sender = factories.UserFactory()
document.email_invitation(
"en",
document.send_invitation_email(
"guest3@example.com",
models.RoleChoices.ADMIN,
sender,
"en",
)
# No email has been sent
@@ -498,9 +511,9 @@ def test_models_documents__email_invitation__failed(mock_logger, _mock_send_mail
(
_,
email,
emails,
exception,
) = mock_logger.call_args.args
assert email == "guest3@example.com"
assert emails == ["guest3@example.com"]
assert isinstance(exception, smtplib.SMTPException)

View File

@@ -144,7 +144,7 @@ def test_models_invitationd_new_user_filter_expired_invitations():
).exists()
@pytest.mark.parametrize("num_invitations, num_queries", [(0, 3), (1, 6), (20, 6)])
@pytest.mark.parametrize("num_invitations, num_queries", [(0, 3), (1, 7), (20, 7)])
def test_models_invitationd_new_userd_user_creation_constant_num_queries(
django_assert_num_queries, num_invitations, num_queries
):

View File

@@ -102,3 +102,24 @@ def test_api_ai__success_sanitize(mock_create):
response = AIService().transform("hello", "prompt")
assert response == {"answer": "Salut\n \tle \nmonde"}
@override_settings(
AI_BASE_URL="http://example.com", AI_API_KEY="test-key", AI_MODEL="test-model"
)
@patch("openai.resources.chat.completions.Completions.create")
def test_api_ai__success_when_sanitize_fails(mock_create):
"""The AI request should work as expected even with badly formatted response."""
# pylint: disable=C0303
answer = """{
"answer" :
"Salut le monde"
}"""
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=answer))]
)
response = AIService().transform("hello", "prompt")
assert response == {"answer": "Salut le monde"}

View File

@@ -0,0 +1,185 @@
"""
This module contains tests for the CollaborationService class in the
core.services.collaboration_services module.
"""
import json
import re
from contextlib import contextmanager
from django.core.exceptions import ImproperlyConfigured
import pytest
import requests
import responses
from core.services.collaboration_services import CollaborationService
@pytest.fixture
def mock_reset_connections(settings):
"""
Creates a context manager to mock the reset-connections endpoint for collaboration services.
Args:
settings: A settings object that contains the configuration for the collaboration API.
Returns:
A context manager function that mocks the reset-connections endpoint.
The context manager function takes the following parameters:
document_id (str): The ID of the document for which connections are being reset.
user_id (str, optional): The ID of the user making the request. Defaults to None.
Usage:
with mock_reset_connections(settings)(document_id, user_id) as mock:
# Your test code here
The context manager performs the following actions:
- Mocks the reset-connections endpoint using responses.RequestsMock.
- Sets the COLLABORATION_API_URL and COLLABORATION_SERVER_SECRET in the settings.
- Verifies that the reset-connections endpoint is called exactly once.
- Checks that the request URL and headers are correct.
- If user_id is provided, checks that the X-User-Id header is correct.
"""
@contextmanager
def _mock_reset_connections(document_id, user_id=None):
with responses.RequestsMock() as rsps:
# Mock the reset-connections endpoint
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
endpoint_url = (
f"{settings.COLLABORATION_API_URL}reset-connections/?room={document_id}"
)
rsps.add(
responses.POST,
endpoint_url,
json={},
status=200,
)
yield
assert (
len(rsps.calls) == 1
), "Expected one call to reset-connections endpoint"
request = rsps.calls[0].request
assert request.url == endpoint_url, f"Unexpected URL called: {request.url}"
assert (
request.headers.get("Authorization")
== settings.COLLABORATION_SERVER_SECRET
), "Incorrect Authorization header"
if user_id:
assert (
request.headers.get("X-User-Id") == user_id
), "Incorrect X-User-Id header"
return _mock_reset_connections
def test_init_without_api_url(settings):
"""Test that ImproperlyConfigured is raised when COLLABORATION_API_URL is None."""
settings.COLLABORATION_API_URL = None
with pytest.raises(ImproperlyConfigured):
CollaborationService()
def test_init_with_api_url(settings):
"""Test that the service initializes correctly when COLLABORATION_API_URL is set."""
settings.COLLABORATION_API_URL = "http://example.com/"
service = CollaborationService()
assert isinstance(service, CollaborationService)
@responses.activate
def test_reset_connections_with_user_id(settings):
"""Test reset_connections with a provided user_id."""
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
service = CollaborationService()
room = "room1"
user_id = "user123"
endpoint_url = "http://example.com/reset-connections/?room=" + room
responses.add(responses.POST, endpoint_url, json={}, status=200)
service.reset_connections(room, user_id)
assert len(responses.calls) == 1
request = responses.calls[0].request
assert request.url == endpoint_url
assert request.headers.get("Authorization") == "secret-token"
assert request.headers.get("X-User-Id") == "user123"
@responses.activate
def test_reset_connections_without_user_id(settings):
"""Test reset_connections without a user_id."""
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
service = CollaborationService()
room = "room1"
user_id = None
endpoint_url = "http://example.com/reset-connections/?room=" + room
responses.add(
responses.POST,
endpoint_url,
json={},
status=200,
)
service.reset_connections(room, user_id)
assert len(responses.calls) == 1
request = responses.calls[0].request
assert request.url == endpoint_url
assert request.headers.get("Authorization") == "secret-token"
assert request.headers.get("X-User-Id") is None
@responses.activate
def test_reset_connections_non_200_response(settings):
"""Test that an HTTPError is raised when the response status is not 200."""
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
service = CollaborationService()
room = "room1"
user_id = "user123"
endpoint_url = "http://example.com/reset-connections/?room=" + room
response_body = {"error": "Internal Server Error"}
responses.add(responses.POST, endpoint_url, json=response_body, status=500)
expected_exception_message = re.escape(
"Failed to notify WebSocket server. Status code: 500, Response: "
) + re.escape(json.dumps(response_body))
with pytest.raises(requests.HTTPError, match=expected_exception_message):
service.reset_connections(room, user_id)
assert len(responses.calls) == 1
@responses.activate
def test_reset_connections_request_exception(settings):
"""Test that an HTTPError is raised when a RequestException occurs."""
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
service = CollaborationService()
room = "room1"
user_id = "user123"
endpoint_url = "http://example.com/reset-connections?room=" + room
responses.add(
responses.POST,
endpoint_url,
body=requests.exceptions.ConnectionError("Network error"),
)
with pytest.raises(requests.HTTPError, match="Failed to notify WebSocket server."):
service.reset_connections(room, user_id)
assert len(responses.calls) == 1

View File

@@ -0,0 +1,147 @@
"""Test converter services."""
from unittest.mock import MagicMock, patch
import pytest
import requests
from core.services.converter_services import (
InvalidResponseError,
MissingContentError,
ServiceUnavailableError,
ValidationError,
YdocConverter,
)
def test_auth_header(settings):
"""Test authentication header generation."""
settings.Y_PROVIDER_API_KEY = "test-key"
converter = YdocConverter()
assert converter.auth_header == "test-key"
def test_convert_markdown_empty_text():
"""Should raise ValidationError when text is empty."""
converter = YdocConverter()
with pytest.raises(ValidationError, match="Input text cannot be empty"):
converter.convert_markdown("")
@patch("requests.post")
def test_convert_markdown_service_unavailable(mock_post):
"""Should raise ServiceUnavailableError when service is unavailable."""
converter = YdocConverter()
mock_post.side_effect = requests.RequestException("Connection error")
with pytest.raises(
ServiceUnavailableError,
match="Failed to connect to conversion service",
):
converter.convert_markdown("test text")
@patch("requests.post")
def test_convert_markdown_http_error(mock_post):
"""Should raise ServiceUnavailableError when HTTP error occurs."""
converter = YdocConverter()
mock_response = MagicMock()
mock_response.raise_for_status.side_effect = requests.HTTPError("HTTP Error")
mock_post.return_value = mock_response
with pytest.raises(
ServiceUnavailableError,
match="Failed to connect to conversion service",
):
converter.convert_markdown("test text")
@patch("requests.post")
def test_convert_markdown_invalid_json_response(mock_post):
"""Should raise InvalidResponseError when response is not valid JSON."""
converter = YdocConverter()
mock_response = MagicMock()
mock_response.json.side_effect = ValueError("Invalid JSON")
mock_post.return_value = mock_response
with pytest.raises(
InvalidResponseError,
match="Could not parse conversion service response",
):
converter.convert_markdown("test text")
@patch("requests.post")
def test_convert_markdown_missing_content_field(mock_post, settings):
"""Should raise MissingContentError when response is missing required field."""
settings.CONVERSION_API_CONTENT_FIELD = "expected_field"
converter = YdocConverter()
mock_response = MagicMock()
mock_response.json.return_value = {"wrong_field": "content"}
mock_post.return_value = mock_response
with pytest.raises(
MissingContentError,
match="Response missing required field: expected_field",
):
converter.convert_markdown("test text")
@patch("requests.post")
def test_convert_markdown_full_integration(mock_post, settings):
"""Test full integration with all settings."""
settings.Y_PROVIDER_API_BASE_URL = "http://test.com/"
settings.Y_PROVIDER_API_KEY = "test-key"
settings.CONVERSION_API_ENDPOINT = "conversion-endpoint"
settings.CONVERSION_API_TIMEOUT = 5
settings.CONVERSION_API_CONTENT_FIELD = "content"
converter = YdocConverter()
expected_content = {"converted": "content"}
mock_response = MagicMock()
mock_response.json.return_value = {"content": expected_content}
mock_post.return_value = mock_response
result = converter.convert_markdown("test markdown")
assert result == expected_content
mock_post.assert_called_once_with(
"http://test.com/conversion-endpoint/",
json={"content": "test markdown"},
headers={
"Authorization": "test-key",
"Content-Type": "application/json",
},
timeout=5,
verify=False,
)
@patch("requests.post")
def test_convert_markdown_timeout(mock_post):
"""Should raise ServiceUnavailableError when request times out."""
converter = YdocConverter()
mock_post.side_effect = requests.Timeout("Request timed out")
with pytest.raises(
ServiceUnavailableError,
match="Failed to connect to conversion service",
):
converter.convert_markdown("test text")
def test_convert_markdown_none_input():
"""Should raise ValidationError when input is None."""
converter = YdocConverter()
with pytest.raises(ValidationError, match="Input text cannot be empty"):
converter.convert_markdown(None)

View File

@@ -65,6 +65,7 @@ class Base(Configuration):
# Security
ALLOWED_HOSTS = values.ListValue([])
SECRET_KEY = values.Value(None)
SERVER_TO_SERVER_API_TOKENS = values.ListValue([])
# Application definition
ROOT_URLCONF = "impress.urls"
@@ -351,9 +352,11 @@ class Base(Configuration):
# Mail
EMAIL_BACKEND = values.Value("django.core.mail.backends.smtp.EmailBackend")
EMAIL_BRAND_NAME = values.Value(None)
EMAIL_HOST = values.Value(None)
EMAIL_HOST_USER = values.Value(None)
EMAIL_HOST_PASSWORD = values.Value(None)
EMAIL_LOGO_IMG = values.Value(None)
EMAIL_PORT = values.PositiveIntegerValue(None)
EMAIL_USE_TLS = values.BooleanValue(False)
EMAIL_USE_SSL = values.BooleanValue(False)
@@ -372,8 +375,14 @@ class Base(Configuration):
SENTRY_DSN = values.Value(None, environ_name="SENTRY_DSN", environ_prefix=None)
# Collaboration
COLLABORATION_SERVER_URL = values.Value(
None, environ_name="COLLABORATION_SERVER_URL", environ_prefix=None
COLLABORATION_API_URL = values.Value(
None, environ_name="COLLABORATION_API_URL", environ_prefix=None
)
COLLABORATION_SERVER_SECRET = values.Value(
None, environ_name="COLLABORATION_SERVER_SECRET", environ_prefix=None
)
COLLABORATION_WS_URL = values.Value(
None, environ_name="COLLABORATION_WS_URL", environ_prefix=None
)
# Frontend
@@ -465,9 +474,22 @@ class Base(Configuration):
environ_prefix=None,
)
USER_OIDC_FIELDS_TO_FULLNAME = values.ListValue(
default=["first_name", "last_name"],
environ_name="USER_OIDC_FIELDS_TO_FULLNAME",
environ_prefix=None,
)
USER_OIDC_FIELD_TO_SHORTNAME = values.Value(
default="first_name",
environ_name="USER_OIDC_FIELD_TO_SHORTNAME",
environ_prefix=None,
)
ALLOW_LOGOUT_GET_METHOD = values.BooleanValue(
default=True, environ_name="ALLOW_LOGOUT_GET_METHOD", environ_prefix=None
)
# AI service
AI_API_KEY = values.Value(None, environ_name="AI_API_KEY", environ_prefix=None)
AI_BASE_URL = values.Value(None, environ_name="AI_BASE_URL", environ_prefix=None)
AI_MODEL = values.Value(None, environ_name="AI_MODEL", environ_prefix=None)
@@ -483,14 +505,35 @@ class Base(Configuration):
"day": 200,
}
USER_OIDC_FIELDS_TO_FULLNAME = values.ListValue(
default=["first_name", "last_name"],
environ_name="USER_OIDC_FIELDS_TO_FULLNAME",
# Y provider microservice
Y_PROVIDER_API_KEY = values.Value(
environ_name="Y_PROVIDER_API_KEY",
environ_prefix=None,
)
USER_OIDC_FIELD_TO_SHORTNAME = values.Value(
default="first_name",
environ_name="USER_OIDC_FIELD_TO_SHORTNAME",
Y_PROVIDER_API_BASE_URL = values.Value(
environ_name="Y_PROVIDER_API_BASE_URL",
environ_prefix=None,
)
# Conversion endpoint
CONVERSION_API_ENDPOINT = values.Value(
default="convert-markdown",
environ_name="CONVERSION_API_ENDPOINT",
environ_prefix=None,
)
CONVERSION_API_CONTENT_FIELD = values.Value(
default="content",
environ_name="CONVERSION_API_CONTENT_FIELD",
environ_prefix=None,
)
CONVERSION_API_TIMEOUT = values.Value(
default=30,
environ_name="CONVERSION_API_TIMEOUT",
environ_prefix=None,
)
CONVERSION_API_SECURE = values.Value(
default=False,
environ_name="CONVERSION_API_SECURE",
environ_prefix=None,
)

View File

@@ -2,46 +2,66 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-people\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-09-25 10:15+0000\n"
"PO-Revision-Date: 2024-09-25 10:21\n"
"POT-Creation-Date: 2024-12-17 15:50+0000\n"
"PO-Revision-Date: 2024-12-17 15:53\n"
"Last-Translator: \n"
"Language-Team: German\n"
"Language: de_DE\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Crowdin-Project: lasuite-people\n"
"X-Crowdin-Project-ID: 637934\n"
"X-Crowdin-Language: de\n"
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 8\n"
#: core/admin.py:32
#: core/admin.py:33
msgid "Personal info"
msgstr "Persönliche Angaben"
msgstr "Persönliche Daten"
#: core/admin.py:34
#: core/admin.py:46
msgid "Permissions"
msgstr "Berechtigungen"
#: core/admin.py:46
#: core/admin.py:58
msgid "Important dates"
msgstr "Wichtige Termine"
msgstr "Wichtige Daten"
#: core/api/serializers.py:253
#: core/api/filters.py:16
msgid "Creator is me"
msgstr ""
#: core/api/filters.py:19
msgid "Favorite"
msgstr ""
#: core/api/filters.py:22
msgid "Title"
msgstr ""
#: core/api/serializers.py:307
msgid "A new document was created on your behalf!"
msgstr ""
#: core/api/serializers.py:311
msgid "You have been granted ownership of a new document:"
msgstr ""
#: core/api/serializers.py:414
msgid "Body"
msgstr ""
msgstr "Inhalt"
#: core/api/serializers.py:256
#: core/api/serializers.py:417
msgid "Body type"
msgstr ""
msgstr "Typ"
#: core/api/serializers.py:262
#: core/api/serializers.py:423
msgid "Format"
msgstr ""
msgstr "Format"
#: core/authentication/backends.py:56
#: core/authentication/backends.py:57
msgid "Invalid response format or token verification failed"
msgstr ""
@@ -49,17 +69,17 @@ msgstr ""
msgid "User info contained no recognizable user identification"
msgstr ""
#: core/authentication/backends.py:101
msgid "Claims contained no recognizable user identification"
#: core/authentication/backends.py:88
msgid "User account is disabled"
msgstr ""
#: core/models.py:62 core/models.py:69
msgid "Reader"
msgstr "Leser"
msgstr "Lesen"
#: core/models.py:63 core/models.py:70
msgid "Editor"
msgstr "Bearbeiter"
msgstr "Bearbeiten"
#: core/models.py:71
msgid "Administrator"
@@ -67,283 +87,308 @@ msgstr "Administrator"
#: core/models.py:72
msgid "Owner"
msgstr "Eigentümer"
msgstr "Besitzer"
#: core/models.py:80
#: core/models.py:83
msgid "Restricted"
msgstr "Eingeschränkt"
msgstr "Beschränkt"
#: core/models.py:84
#: core/models.py:87
msgid "Authenticated"
msgstr "Authentifiziert"
#: core/models.py:86
#: core/models.py:89
msgid "Public"
msgstr "Öffentlich"
#: core/models.py:98
#: core/models.py:101
msgid "id"
msgstr ""
#: core/models.py:99
#: core/models.py:102
msgid "primary key for the record as UUID"
msgstr ""
#: core/models.py:105
#: core/models.py:108
msgid "created on"
msgstr ""
msgstr "Erstellt"
#: core/models.py:106
#: core/models.py:109
msgid "date and time at which a record was created"
msgstr ""
msgstr "Datum und Uhrzeit, an dem ein Datensatz erstellt wurde"
#: core/models.py:111
#: core/models.py:114
msgid "updated on"
msgstr ""
msgstr "Aktualisiert"
#: core/models.py:112
#: core/models.py:115
msgid "date and time at which a record was last updated"
msgstr "Datum und Uhrzeit, an dem zuletzt aktualisiert wurde"
#: core/models.py:135
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
msgstr ""
#: core/models.py:132
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters."
msgstr ""
#: core/models.py:138
#: core/models.py:141
msgid "sub"
msgstr ""
#: core/models.py:140
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
#: core/models.py:143
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
msgstr ""
#: core/models.py:148
msgid "identity email address"
#: core/models.py:152
msgid "full name"
msgstr ""
#: core/models.py:153
msgid "admin email address"
msgid "short name"
msgstr ""
#: core/models.py:155
msgid "identity email address"
msgstr ""
#: core/models.py:160
msgid "language"
msgstr ""
#: core/models.py:161
msgid "The language in which the user wants to see the interface."
msgid "admin email address"
msgstr ""
#: core/models.py:167
msgid "language"
msgstr "Sprache"
#: core/models.py:168
msgid "The language in which the user wants to see the interface."
msgstr ""
#: core/models.py:174
msgid "The timezone in which the user wants to see times."
msgstr ""
#: core/models.py:170
#: core/models.py:177
msgid "device"
msgstr ""
#: core/models.py:172
#: core/models.py:179
msgid "Whether the user is a device or a real user."
msgstr ""
#: core/models.py:175
#: core/models.py:182
msgid "staff status"
msgstr ""
#: core/models.py:177
#: core/models.py:184
msgid "Whether the user can log into this admin site."
msgstr ""
#: core/models.py:180
#: core/models.py:187
msgid "active"
msgstr ""
#: core/models.py:183
#: core/models.py:190
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: core/models.py:195
#: core/models.py:202
msgid "user"
msgstr ""
msgstr "Benutzer"
#: core/models.py:196
#: core/models.py:203
msgid "users"
msgstr ""
msgstr "Benutzer"
#: core/models.py:328 core/models.py:644
#: core/models.py:342 core/models.py:718
msgid "title"
msgstr ""
msgstr "Titel"
#: core/models.py:343
#: core/models.py:364
msgid "Document"
msgstr ""
msgstr "Dokument"
#: core/models.py:344
#: core/models.py:365
msgid "Documents"
msgstr ""
msgstr "Dokumente"
#: core/models.py:347
#: core/models.py:368
msgid "Untitled Document"
msgstr "Unbenanntes Dokument"
#: core/models.py:593
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
#: core/models.py:537
#, python-format
msgid "%(username)s shared a document with you: %(document)s"
msgstr "%(username)s hat ein Dokument mit Ihnen geteilt: %(document)s"
#: core/models.py:597
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr ""
#: core/models.py:580
#: core/models.py:600
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr ""
#: core/models.py:623
msgid "Document/user link trace"
msgstr ""
#: core/models.py:581
#: core/models.py:624
msgid "Document/user link traces"
msgstr ""
#: core/models.py:587
#: core/models.py:630
msgid "A link trace already exists for this document/user."
msgstr ""
#: core/models.py:608
#: core/models.py:653
msgid "Document favorite"
msgstr ""
#: core/models.py:654
msgid "Document favorites"
msgstr ""
#: core/models.py:660
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: core/models.py:682
msgid "Document/user relation"
msgstr ""
#: core/models.py:609
#: core/models.py:683
msgid "Document/user relations"
msgstr ""
#: core/models.py:615
#: core/models.py:689
msgid "This user is already in this document."
msgstr ""
msgstr "Dieser Benutzer befindet sich bereits in diesem Dokument."
#: core/models.py:621
#: core/models.py:695
msgid "This team is already in this document."
msgstr ""
msgstr "Dieses Team befindet sich bereits in diesem Dokument."
#: core/models.py:627 core/models.py:816
#: core/models.py:701 core/models.py:890
msgid "Either user or team must be set, not both."
msgstr ""
msgstr "Benutzer oder Team müssen gesetzt werden, nicht beides."
#: core/models.py:645
#: core/models.py:719
msgid "description"
msgstr ""
msgstr "Beschreibung"
#: core/models.py:646
#: core/models.py:720
msgid "code"
msgstr ""
msgstr "Code"
#: core/models.py:647
#: core/models.py:721
msgid "css"
msgstr ""
msgstr "CSS"
#: core/models.py:649
#: core/models.py:723
msgid "public"
msgstr ""
msgstr "öffentlich"
#: core/models.py:651
#: core/models.py:725
msgid "Whether this template is public for anyone to use."
msgstr ""
msgstr "Ob diese Vorlage für jedermann öffentlich ist."
#: core/models.py:657
#: core/models.py:731
msgid "Template"
msgstr ""
#: core/models.py:658
#: core/models.py:732
msgid "Templates"
msgstr ""
#: core/models.py:797
#: core/models.py:871
msgid "Template/user relation"
msgstr ""
#: core/models.py:798
#: core/models.py:872
msgid "Template/user relations"
msgstr ""
#: core/models.py:804
#: core/models.py:878
msgid "This user is already in this template."
msgstr ""
#: core/models.py:810
#: core/models.py:884
msgid "This team is already in this template."
msgstr ""
#: core/models.py:833
#: core/models.py:907
msgid "email address"
msgstr ""
#: core/models.py:850
#: core/models.py:926
msgid "Document invitation"
msgstr ""
#: core/models.py:851
#: core/models.py:927
msgid "Document invitations"
msgstr ""
#: core/models.py:868
#: core/models.py:944
msgid "This email is already associated to a registered user."
msgstr ""
#: core/templates/mail/html/invitation.html:160
#: core/templates/mail/html/invitation2.html:160
#: core/templates/mail/text/invitation.txt:3
#: core/templates/mail/text/invitation2.txt:3
msgid "La Suite Numérique"
#: core/templates/mail/html/hello.html:159 core/templates/mail/text/hello.txt:3
msgid "Company logo"
msgstr ""
#: core/templates/mail/html/invitation.html:190
#: core/templates/mail/text/invitation.txt:6
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
#, python-format
msgid " %(username)s shared a document with you ! "
msgstr " %(username)s hat ein Dokument mit Ihnen geteilt! "
msgid "Hello %(name)s"
msgstr ""
#: core/templates/mail/html/invitation.html:197
#: core/templates/mail/text/invitation.txt:8
#: 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 " %(username)s invited you as an %(role)s on the following document : "
msgstr " %(username)s hat Sie als %(role)s zum folgenden Dokument eingeladen: "
msgid "This mail has been sent to %(email)s by <a href=\"%(href)s\">%(name)s</a>"
msgstr ""
#: core/templates/mail/html/invitation.html:206
#: core/templates/mail/html/invitation2.html:211
#: core/templates/mail/html/invitation.html:162
#: core/templates/mail/text/invitation.txt:3
msgid "Logo email"
msgstr ""
#: core/templates/mail/html/invitation.html:209
#: core/templates/mail/text/invitation.txt:10
#: core/templates/mail/text/invitation2.txt:11
msgid "Open"
msgstr "Öffnen"
msgstr ""
#: core/templates/mail/html/invitation.html:223
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborate on your documents as a team. "
msgstr " Docs, Ihr neues unverzichtbares Werkzeug zum Organisieren, Teilen und Zusammenarbeiten an Dokumenten im Team. "
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr ""
#: core/templates/mail/html/invitation.html:230
#: core/templates/mail/html/invitation2.html:235
#: core/templates/mail/html/invitation.html:233
#: core/templates/mail/text/invitation.txt:16
#: core/templates/mail/text/invitation2.txt:17
msgid "Brought to you by La Suite Numérique"
msgstr "Bereitgestellt von La Suite Numérique"
#: core/templates/mail/html/invitation2.html:190
#, python-format
msgid "%(username)s shared a document with you"
msgstr "%(username)s hat ein Dokument mit Ihnen geteilt"
msgid " Brought to you by %(brandname)s "
msgstr ""
#: core/templates/mail/html/invitation2.html:197
#: core/templates/mail/text/invitation2.txt:8
#: core/templates/mail/text/hello.txt:8
#, python-format
msgid "%(username)s invited you as an %(role)s on the following document :"
msgstr "%(username)s hat Sie als %(role)s zum folgenden Dokument eingeladen:"
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
msgstr ""
#: core/templates/mail/html/invitation2.html:228
#: core/templates/mail/text/invitation2.txt:15
msgid "Docs, your new essential tool for organizing, sharing and collaborate on your document as a team."
msgstr "Docs, Ihr neues unverzichtbares Werkzeug zum Organisieren, Teilen und gemeinsamen Arbeiten an Dokumenten im Team."
#: impress/settings.py:177
#: impress/settings.py:236
msgid "English"
msgstr ""
#: impress/settings.py:178
#: impress/settings.py:237
msgid "French"
msgstr ""
#: impress/settings.py:176
#: impress/settings.py:238
msgid "German"
msgstr ""

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-people\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-10-15 07:19+0000\n"
"PO-Revision-Date: 2024-10-15 07:23\n"
"POT-Creation-Date: 2024-12-17 15:50+0000\n"
"PO-Revision-Date: 2024-12-17 15:53\n"
"Last-Translator: \n"
"Language-Team: English\n"
"Language: en_US\n"
@@ -29,15 +29,35 @@ msgstr ""
msgid "Important dates"
msgstr ""
#: core/api/serializers.py:253
#: core/api/filters.py:16
msgid "Creator is me"
msgstr ""
#: core/api/filters.py:19
msgid "Favorite"
msgstr ""
#: core/api/filters.py:22
msgid "Title"
msgstr ""
#: core/api/serializers.py:307
msgid "A new document was created on your behalf!"
msgstr ""
#: core/api/serializers.py:311
msgid "You have been granted ownership of a new document:"
msgstr ""
#: core/api/serializers.py:414
msgid "Body"
msgstr ""
#: core/api/serializers.py:256
#: core/api/serializers.py:417
msgid "Body type"
msgstr ""
#: core/api/serializers.py:262
#: core/api/serializers.py:423
msgid "Format"
msgstr ""
@@ -49,6 +69,10 @@ msgstr ""
msgid "User info contained no recognizable user identification"
msgstr ""
#: core/authentication/backends.py:88
msgid "User account is disabled"
msgstr ""
#: core/models.py:62 core/models.py:69
msgid "Reader"
msgstr ""
@@ -65,224 +89,246 @@ msgstr ""
msgid "Owner"
msgstr ""
#: core/models.py:80
#: core/models.py:83
msgid "Restricted"
msgstr ""
#: core/models.py:84
#: core/models.py:87
msgid "Authenticated"
msgstr ""
#: core/models.py:86
#: core/models.py:89
msgid "Public"
msgstr ""
#: core/models.py:98
#: core/models.py:101
msgid "id"
msgstr ""
#: core/models.py:99
#: core/models.py:102
msgid "primary key for the record as UUID"
msgstr ""
#: core/models.py:105
#: core/models.py:108
msgid "created on"
msgstr ""
#: core/models.py:106
#: core/models.py:109
msgid "date and time at which a record was created"
msgstr ""
#: core/models.py:111
#: core/models.py:114
msgid "updated on"
msgstr ""
#: core/models.py:112
#: core/models.py:115
msgid "date and time at which a record was last updated"
msgstr ""
#: core/models.py:132
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters."
#: core/models.py:135
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
msgstr ""
#: core/models.py:138
#: core/models.py:141
msgid "sub"
msgstr ""
#: core/models.py:140
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
msgstr ""
#: core/models.py:149
msgid "full name"
msgstr ""
#: core/models.py:150
msgid "short name"
#: core/models.py:143
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
msgstr ""
#: core/models.py:152
msgid "full name"
msgstr ""
#: core/models.py:153
msgid "short name"
msgstr ""
#: core/models.py:155
msgid "identity email address"
msgstr ""
#: core/models.py:157
#: core/models.py:160
msgid "admin email address"
msgstr ""
#: core/models.py:164
#: core/models.py:167
msgid "language"
msgstr ""
#: core/models.py:165
#: core/models.py:168
msgid "The language in which the user wants to see the interface."
msgstr ""
#: core/models.py:171
#: core/models.py:174
msgid "The timezone in which the user wants to see times."
msgstr ""
#: core/models.py:174
#: core/models.py:177
msgid "device"
msgstr ""
#: core/models.py:176
#: core/models.py:179
msgid "Whether the user is a device or a real user."
msgstr ""
#: core/models.py:179
#: core/models.py:182
msgid "staff status"
msgstr ""
#: core/models.py:181
#: core/models.py:184
msgid "Whether the user can log into this admin site."
msgstr ""
#: core/models.py:184
#: core/models.py:187
msgid "active"
msgstr ""
#: core/models.py:187
#: core/models.py:190
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: core/models.py:199
#: core/models.py:202
msgid "user"
msgstr ""
#: core/models.py:200
#: core/models.py:203
msgid "users"
msgstr ""
#: core/models.py:332 core/models.py:638
#: core/models.py:342 core/models.py:718
msgid "title"
msgstr ""
#: core/models.py:347
#: core/models.py:364
msgid "Document"
msgstr ""
#: core/models.py:348
#: core/models.py:365
msgid "Documents"
msgstr ""
#: core/models.py:351
#: core/models.py:368
msgid "Untitled Document"
msgstr ""
#: core/models.py:530
#, python-format
msgid "%(sender_name)s shared a document with you: %(document)s"
#: core/models.py:593
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
#: core/models.py:574
#: core/models.py:597
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr ""
#: core/models.py:600
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr ""
#: core/models.py:623
msgid "Document/user link trace"
msgstr ""
#: core/models.py:575
#: core/models.py:624
msgid "Document/user link traces"
msgstr ""
#: core/models.py:581
#: core/models.py:630
msgid "A link trace already exists for this document/user."
msgstr ""
#: core/models.py:602
#: core/models.py:653
msgid "Document favorite"
msgstr ""
#: core/models.py:654
msgid "Document favorites"
msgstr ""
#: core/models.py:660
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: core/models.py:682
msgid "Document/user relation"
msgstr ""
#: core/models.py:603
#: core/models.py:683
msgid "Document/user relations"
msgstr ""
#: core/models.py:609
#: core/models.py:689
msgid "This user is already in this document."
msgstr ""
#: core/models.py:615
#: core/models.py:695
msgid "This team is already in this document."
msgstr ""
#: core/models.py:621 core/models.py:810
#: core/models.py:701 core/models.py:890
msgid "Either user or team must be set, not both."
msgstr ""
#: core/models.py:639
#: core/models.py:719
msgid "description"
msgstr ""
#: core/models.py:640
#: core/models.py:720
msgid "code"
msgstr ""
#: core/models.py:641
#: core/models.py:721
msgid "css"
msgstr ""
#: core/models.py:643
#: core/models.py:723
msgid "public"
msgstr ""
#: core/models.py:645
#: core/models.py:725
msgid "Whether this template is public for anyone to use."
msgstr ""
#: core/models.py:651
#: core/models.py:731
msgid "Template"
msgstr ""
#: core/models.py:652
#: core/models.py:732
msgid "Templates"
msgstr ""
#: core/models.py:791
#: core/models.py:871
msgid "Template/user relation"
msgstr ""
#: core/models.py:792
#: core/models.py:872
msgid "Template/user relations"
msgstr ""
#: core/models.py:798
#: core/models.py:878
msgid "This user is already in this template."
msgstr ""
#: core/models.py:804
#: core/models.py:884
msgid "This team is already in this template."
msgstr ""
#: core/models.py:827
#: core/models.py:907
msgid "email address"
msgstr ""
#: core/models.py:844
#: core/models.py:926
msgid "Document invitation"
msgstr ""
#: core/models.py:845
#: core/models.py:927
msgid "Document invitations"
msgstr ""
#: core/models.py:862
#: core/models.py:944
msgid "This email is already associated to a registered user."
msgstr ""
@@ -308,36 +354,25 @@ msgstr ""
msgid "This mail has been sent to %(email)s by <a href=\"%(href)s\">%(name)s</a>"
msgstr ""
#: core/templates/mail/html/invitation.html:159
#: core/templates/mail/html/invitation.html:162
#: core/templates/mail/text/invitation.txt:3
msgid "La Suite Numérique"
msgid "Logo email"
msgstr ""
#: core/templates/mail/html/invitation.html:189
#: core/templates/mail/text/invitation.txt:6
#, python-format
msgid " %(sender_name)s shared a document with you ! "
msgstr ""
#: core/templates/mail/html/invitation.html:196
#: core/templates/mail/text/invitation.txt:8
#, python-format
msgid " %(sender_name_email)s invited you with the role \"%(role)s\" on the following document : "
msgstr ""
#: core/templates/mail/html/invitation.html:205
#: core/templates/mail/html/invitation.html:209
#: core/templates/mail/text/invitation.txt:10
msgid "Open"
msgstr ""
#: core/templates/mail/html/invitation.html:222
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr ""
#: core/templates/mail/html/invitation.html:229
#: core/templates/mail/html/invitation.html:233
#: core/templates/mail/text/invitation.txt:16
msgid "Brought to you by La Suite Numérique"
#, python-format
msgid " Brought to you by %(brandname)s "
msgstr ""
#: core/templates/mail/text/hello.txt:8
@@ -345,14 +380,15 @@ msgstr ""
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
msgstr ""
#: impress/settings.py:177
#: impress/settings.py:236
msgid "English"
msgstr ""
#: impress/settings.py:178
#: impress/settings.py:237
msgid "French"
msgstr ""
#: impress/settings.py:176
#: impress/settings.py:238
msgid "German"
msgstr ""

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-people\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-10-15 07:19+0000\n"
"PO-Revision-Date: 2024-10-15 07:23\n"
"POT-Creation-Date: 2024-12-17 15:50+0000\n"
"PO-Revision-Date: 2024-12-17 15:53\n"
"Last-Translator: \n"
"Language-Team: French\n"
"Language: fr_FR\n"
@@ -29,15 +29,35 @@ msgstr "Permissions"
msgid "Important dates"
msgstr "Dates importantes"
#: core/api/serializers.py:253
#: core/api/filters.py:16
msgid "Creator is me"
msgstr ""
#: core/api/filters.py:19
msgid "Favorite"
msgstr ""
#: core/api/filters.py:22
msgid "Title"
msgstr ""
#: core/api/serializers.py:307
msgid "A new document was created on your behalf!"
msgstr "Un nouveau document a été créé pour vous !"
#: core/api/serializers.py:311
msgid "You have been granted ownership of a new document:"
msgstr "Vous avez été déclaré propriétaire d'un nouveau document :"
#: core/api/serializers.py:414
msgid "Body"
msgstr ""
#: core/api/serializers.py:256
#: core/api/serializers.py:417
msgid "Body type"
msgstr ""
#: core/api/serializers.py:262
#: core/api/serializers.py:423
msgid "Format"
msgstr ""
@@ -49,6 +69,10 @@ msgstr ""
msgid "User info contained no recognizable user identification"
msgstr ""
#: core/authentication/backends.py:88
msgid "User account is disabled"
msgstr ""
#: core/models.py:62 core/models.py:69
msgid "Reader"
msgstr "Lecteur"
@@ -65,224 +89,246 @@ msgstr "Administrateur"
msgid "Owner"
msgstr "Propriétaire"
#: core/models.py:80
#: core/models.py:83
msgid "Restricted"
msgstr "Restreint"
#: core/models.py:84
#: core/models.py:87
msgid "Authenticated"
msgstr "Authentifié"
#: core/models.py:86
#: core/models.py:89
msgid "Public"
msgstr "Public"
#: core/models.py:98
#: core/models.py:101
msgid "id"
msgstr ""
#: core/models.py:99
#: core/models.py:102
msgid "primary key for the record as UUID"
msgstr ""
#: core/models.py:105
#: core/models.py:108
msgid "created on"
msgstr ""
#: core/models.py:106
#: core/models.py:109
msgid "date and time at which a record was created"
msgstr ""
#: core/models.py:111
#: core/models.py:114
msgid "updated on"
msgstr ""
#: core/models.py:112
#: core/models.py:115
msgid "date and time at which a record was last updated"
msgstr ""
#: core/models.py:132
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters."
#: core/models.py:135
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
msgstr ""
#: core/models.py:138
#: core/models.py:141
msgid "sub"
msgstr ""
#: core/models.py:140
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
msgstr ""
#: core/models.py:149
msgid "full name"
msgstr ""
#: core/models.py:150
msgid "short name"
#: core/models.py:143
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
msgstr ""
#: core/models.py:152
msgid "full name"
msgstr ""
#: core/models.py:153
msgid "short name"
msgstr ""
#: core/models.py:155
msgid "identity email address"
msgstr ""
#: core/models.py:157
#: core/models.py:160
msgid "admin email address"
msgstr ""
#: core/models.py:164
#: core/models.py:167
msgid "language"
msgstr ""
#: core/models.py:165
#: core/models.py:168
msgid "The language in which the user wants to see the interface."
msgstr ""
#: core/models.py:171
#: core/models.py:174
msgid "The timezone in which the user wants to see times."
msgstr ""
#: core/models.py:174
#: core/models.py:177
msgid "device"
msgstr ""
#: core/models.py:176
#: core/models.py:179
msgid "Whether the user is a device or a real user."
msgstr ""
#: core/models.py:179
#: core/models.py:182
msgid "staff status"
msgstr ""
#: core/models.py:181
#: core/models.py:184
msgid "Whether the user can log into this admin site."
msgstr ""
#: core/models.py:184
#: core/models.py:187
msgid "active"
msgstr ""
#: core/models.py:187
#: core/models.py:190
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: core/models.py:199
#: core/models.py:202
msgid "user"
msgstr ""
#: core/models.py:200
#: core/models.py:203
msgid "users"
msgstr ""
#: core/models.py:332 core/models.py:638
#: core/models.py:342 core/models.py:718
msgid "title"
msgstr ""
#: core/models.py:347
#: core/models.py:364
msgid "Document"
msgstr ""
#: core/models.py:348
#: core/models.py:365
msgid "Documents"
msgstr ""
#: core/models.py:351
#: core/models.py:368
msgid "Untitled Document"
msgstr ""
#: core/models.py:530
#, python-format
msgid "%(sender_name)s shared a document with you: %(document)s"
msgstr "%(sender_name)s a partagé un document avec vous: %(document)s"
#: core/models.py:593
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} a partagé un document avec vous!"
#: core/models.py:574
#: core/models.py:597
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} vous a invité avec le rôle \"{role}\" sur le document suivant:"
#: core/models.py:600
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} a partagé un document avec vous: {title}"
#: core/models.py:623
msgid "Document/user link trace"
msgstr ""
#: core/models.py:575
#: core/models.py:624
msgid "Document/user link traces"
msgstr ""
#: core/models.py:581
#: core/models.py:630
msgid "A link trace already exists for this document/user."
msgstr ""
#: core/models.py:602
#: core/models.py:653
msgid "Document favorite"
msgstr ""
#: core/models.py:654
msgid "Document favorites"
msgstr ""
#: core/models.py:660
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: core/models.py:682
msgid "Document/user relation"
msgstr ""
#: core/models.py:603
#: core/models.py:683
msgid "Document/user relations"
msgstr ""
#: core/models.py:609
#: core/models.py:689
msgid "This user is already in this document."
msgstr ""
#: core/models.py:615
#: core/models.py:695
msgid "This team is already in this document."
msgstr ""
#: core/models.py:621 core/models.py:810
#: core/models.py:701 core/models.py:890
msgid "Either user or team must be set, not both."
msgstr ""
#: core/models.py:639
#: core/models.py:719
msgid "description"
msgstr ""
#: core/models.py:640
#: core/models.py:720
msgid "code"
msgstr ""
#: core/models.py:641
#: core/models.py:721
msgid "css"
msgstr ""
#: core/models.py:643
#: core/models.py:723
msgid "public"
msgstr ""
#: core/models.py:645
#: core/models.py:725
msgid "Whether this template is public for anyone to use."
msgstr ""
#: core/models.py:651
#: core/models.py:731
msgid "Template"
msgstr ""
#: core/models.py:652
#: core/models.py:732
msgid "Templates"
msgstr ""
#: core/models.py:791
#: core/models.py:871
msgid "Template/user relation"
msgstr ""
#: core/models.py:792
#: core/models.py:872
msgid "Template/user relations"
msgstr ""
#: core/models.py:798
#: core/models.py:878
msgid "This user is already in this template."
msgstr ""
#: core/models.py:804
#: core/models.py:884
msgid "This team is already in this template."
msgstr ""
#: core/models.py:827
#: core/models.py:907
msgid "email address"
msgstr ""
#: core/models.py:844
#: core/models.py:926
msgid "Document invitation"
msgstr ""
#: core/models.py:845
#: core/models.py:927
msgid "Document invitations"
msgstr ""
#: core/models.py:862
#: core/models.py:944
msgid "This email is already associated to a registered user."
msgstr ""
@@ -308,51 +354,41 @@ msgstr ""
msgid "This mail has been sent to %(email)s by <a href=\"%(href)s\">%(name)s</a>"
msgstr ""
#: core/templates/mail/html/invitation.html:159
#: core/templates/mail/html/invitation.html:162
#: core/templates/mail/text/invitation.txt:3
msgid "La Suite Numérique"
msgid "Logo email"
msgstr ""
#: core/templates/mail/html/invitation.html:189
#: core/templates/mail/text/invitation.txt:6
#, python-format
msgid " %(sender_name)s shared a document with you ! "
msgstr " %(sender_name)s a partagé un document avec vous ! "
#: core/templates/mail/html/invitation.html:196
#: core/templates/mail/text/invitation.txt:8
#, python-format
msgid " %(sender_name_email)s invited you with the role \"%(role)s\" on the following document : "
msgstr " %(sender_name_email)s vous a invité avec le rôle \"%(role)s\" sur le document suivant : "
#: core/templates/mail/html/invitation.html:205
#: core/templates/mail/html/invitation.html:209
#: core/templates/mail/text/invitation.txt:10
msgid "Open"
msgstr "Ouvrir"
#: core/templates/mail/html/invitation.html:222
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr " Docs, votre nouvel outil incontournable pour organiser, partager et collaborer sur vos documents en équipe. "
#: core/templates/mail/html/invitation.html:229
#: core/templates/mail/html/invitation.html:233
#: core/templates/mail/text/invitation.txt:16
msgid "Brought to you by La Suite Numérique"
msgstr "Proposé par La Suite Numérique"
#, python-format
msgid " Brought to you by %(brandname)s "
msgstr " Proposé par %(brandname)s "
#: core/templates/mail/text/hello.txt:8
#, python-format
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
msgstr ""
#: impress/settings.py:177
#: impress/settings.py:236
msgid "English"
msgstr ""
#: impress/settings.py:178
#: impress/settings.py:237
msgid "French"
msgstr ""
#: impress/settings.py:176
#: impress/settings.py:238
msgid "German"
msgstr ""

View File

@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "impress"
version = "1.8.2"
version = "1.10.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -25,21 +25,21 @@ license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"boto3==1.35.44",
"boto3==1.35.81",
"Brotli==1.1.0",
"celery[redis]==5.4.0",
"django-configurations==2.5.1",
"django-cors-headers==4.5.0",
"django-cors-headers==4.6.0",
"django-countries==7.6.1",
"django-filter==24.3",
"django-parler==2.3",
"redis==5.1.1",
"redis==5.2.1",
"django-redis==5.4.0",
"django-storages[s3]==1.14.4",
"django-timezone-field>=5.1",
"django==5.1.2",
"django==5.1.4",
"djangorestframework==3.15.2",
"drf_spectacular==0.27.2",
"drf_spectacular==0.28.0",
"dockerflow==2024.4.2",
"easy_thumbnails==2.10",
"factory_boy==3.3.1",
@@ -47,17 +47,17 @@ dependencies = [
"jsonschema==4.23.0",
"markdown==3.7",
"nested-multipart-parser==1.5.0",
"openai==1.55.3",
"openai==1.57.4",
"psycopg[binary]==3.2.3",
"PyJWT==2.9.0",
"PyJWT==2.10.1",
"pypandoc==1.14",
"python-frontmatter==1.1.0",
"python-magic==0.4.27",
"requests==2.32.3",
"sentry-sdk==2.17.0",
"sentry-sdk==2.19.2",
"url-normalize==1.4.3",
"WeasyPrint>=60.2",
"whitenoise==6.7.0",
"whitenoise==6.8.2",
"mozilla-django-oidc==4.0.1",
]
@@ -70,20 +70,20 @@ dependencies = [
[project.optional-dependencies]
dev = [
"django-extensions==3.2.3",
"drf-spectacular-sidecar==2024.7.1",
"drf-spectacular-sidecar==2024.12.1",
"freezegun==1.5.1",
"ipdb==0.13.13",
"ipython==8.28.0",
"pyfakefs==5.7.1",
"ipython==8.30.0",
"pyfakefs==5.7.3",
"pylint-django==2.6.1",
"pylint==3.3.1",
"pytest-cov==5.0.0",
"pylint==3.3.2",
"pytest-cov==6.0.0",
"pytest-django==4.9.0",
"pytest==8.3.3",
"pytest==8.3.4",
"pytest-icdiff==0.9",
"pytest-xdist==3.6.1",
"responses==0.25.3",
"ruff==0.7.0",
"ruff==0.8.3",
"types-requests==2.32.0.20241016",
]

View File

@@ -1,33 +1,3 @@
FROM node:20-alpine AS frontend-deps-y-provider
WORKDIR /home/frontend/
COPY ./src/frontend/package.json ./package.json
COPY ./src/frontend/yarn.lock ./yarn.lock
COPY ./src/frontend/servers/y-provider/package.json ./servers/y-provider/package.json
COPY ./src/frontend/packages/eslint-config-impress/package.json ./packages/eslint-config-impress/package.json
RUN yarn install
COPY ./src/frontend/ .
# Copy entrypoint
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
# ---- y-provider ----
FROM frontend-deps-y-provider AS y-provider
WORKDIR /home/frontend/servers/y-provider
RUN yarn build
# Un-privileged user running the application
ARG DOCKER_USER
USER ${DOCKER_USER}
ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
CMD ["yarn", "start"]
FROM node:20-alpine AS frontend-deps
WORKDIR /home/frontend/
@@ -40,7 +10,9 @@ COPY ./src/frontend/packages/eslint-config-impress/package.json ./packages/eslin
RUN yarn install --frozen-lockfile
COPY .dockerignore ./.dockerignore
COPY ./src/frontend/ .
COPY ./src/frontend/.prettierrc.js ./.prettierrc.js
COPY ./src/frontend/packages/eslint-config-impress ./packages/eslint-config-impress
COPY ./src/frontend/apps/impress ./apps/impress
### ---- Front-end builder image ----
FROM frontend-deps AS impress

View File

@@ -36,25 +36,18 @@ export const createDoc = async (
await page
.getByRole('button', {
name: 'New doc',
name: 'Create a new document',
})
.click();
const input = page.getByRole('textbox', { name: 'doc title input' });
await input.click();
await input.fill(randomDocs[i]);
await input.blur();
await page.getByRole('heading', { name: 'Untitled document' }).click();
await page.keyboard.type(randomDocs[i]);
await page.getByText('Created at ').click();
}
return randomDocs;
};
export const verifyDocName = async (page: Page, docName: string) => {
const input = page.getByRole('textbox', { name: 'doc title input' });
await expect(input).toBeVisible();
await expect(input).toHaveText(docName);
};
export const addNewMember = async (
page: Page,
index: number,
@@ -104,22 +97,24 @@ export const goToGridDoc = async (
const header = page.locator('header').first();
await header.locator('h2').getByText('Docs').click();
const docsGrid = page.getByTestId('docs-grid');
await expect(docsGrid).toBeVisible();
await expect(page.getByTestId('docs-grid-loader')).toBeHidden();
const datagrid = page.getByLabel('Datagrid of the documents page 1');
const datagridTable = datagrid.getByRole('table');
const rows = docsGrid.getByRole('row');
expect(await rows.count()).toEqual(20);
await expect(datagrid.getByLabel('Loading data')).toBeHidden({
timeout: 10000,
});
const rows = datagridTable.getByRole('row');
const row = title
? rows.filter({
hasText: title,
})
: rows.nth(nthRow);
await expect(row).toBeVisible();
const docTitleCell = row.getByRole('cell').nth(1);
const docTitle = await docTitleCell.textContent();
const docTitleContent = row.locator('[aria-describedby="doc-title"]').first();
const docTitle = await docTitleContent.textContent();
expect(docTitle).toBeDefined();
await row.getByRole('link').first().click();

View File

@@ -2,11 +2,11 @@ import path from 'path';
import { expect, test } from '@playwright/test';
import { createDoc, verifyDocName } from './common';
import { createDoc } from './common';
const config = {
CRISP_WEBSITE_ID: null,
COLLABORATION_SERVER_URL: 'ws://localhost:4444',
COLLABORATION_WS_URL: 'ws://localhost:8083/collaboration/ws/',
ENVIRONMENT: 'development',
FRONTEND_THEME: 'dsfr',
MEDIA_BASE_URL: 'http://localhost:8083',
@@ -93,6 +93,7 @@ test.describe('Config', () => {
const fileChooserPromise = page.waitForEvent('filechooser');
await page.locator('.bn-block-outer').last().fill('Anything');
await page.locator('.bn-block-outer').last().fill('/');
await page.getByText('Resizable image with caption').click();
await page.getByText('Upload image').click();
@@ -117,7 +118,7 @@ test.describe('Config', () => {
browserName,
}) => {
const webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
return webSocket.url().includes('ws://localhost:4444/');
return webSocket.url().includes('ws://localhost:8083/collaboration/ws/');
});
await page.goto('/');
@@ -128,11 +129,10 @@ test.describe('Config', () => {
browserName,
1,
);
await verifyDocName(page, randomDoc[0]);
await expect(page.locator('h2').getByText(randomDoc[0])).toBeVisible();
const webSocket = await webSocketPromise;
expect(webSocket.url()).toContain('ws://localhost:4444/');
expect(webSocket.url()).toContain('ws://localhost:8083/collaboration/ws/');
});
test('it checks that Crisp is trying to init from config endpoint', async ({

View File

@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
import { createDoc } from './common';
import { createDoc, goToGridDoc, keyCloakSignIn, randomName } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -18,11 +18,58 @@ test.describe('Doc Create', () => {
const header = page.locator('header').first();
await header.locator('h2').getByText('Docs').click();
await expect(page.getByTestId('docs-grid-loader')).toBeVisible();
const datagrid = page.getByLabel('Datagrid of the documents page 1');
const datagridTable = datagrid.getByRole('table');
const docsGrid = page.getByTestId('docs-grid');
await expect(docsGrid).toBeVisible();
await expect(page.getByTestId('docs-grid-loader')).toBeHidden();
await expect(docsGrid.getByText(docTitle)).toBeVisible();
await expect(datagrid.getByLabel('Loading data')).toBeHidden({
timeout: 10000,
});
await expect(datagridTable.getByText(docTitle)).toBeVisible({
timeout: 5000,
});
});
});
test.describe('Doc Create: Not loggued', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('it creates a doc server way', async ({
page,
browserName,
request,
}) => {
const markdown = `This is a normal text\n\n# And this is a large heading`;
const [title] = randomName('My server way doc create', browserName, 1);
const data = {
title,
content: markdown,
sub: `user@${browserName}.e2e`,
email: `user@${browserName}.e2e`,
};
const newDoc = await request.post(
`http://localhost:8071/api/v1.0/documents/create-for-owner/`,
{
data,
headers: {
Authorization: 'Bearer test-e2e',
format: 'json',
},
},
);
expect(newDoc.ok()).toBeTruthy();
await keyCloakSignIn(page, browserName);
await goToGridDoc(page, { title });
await expect(page.getByRole('heading', { name: title })).toBeVisible();
const editor = page.locator('.ProseMirror');
await expect(editor.getByText('This is a normal text')).toBeVisible();
await expect(
editor.locator('h1').getByText('And this is a large heading'),
).toBeVisible();
});
});

View File

@@ -2,12 +2,7 @@ import path from 'path';
import { expect, test } from '@playwright/test';
import {
createDoc,
goToGridDoc,
mockedDocument,
verifyDocName,
} from './common';
import { createDoc, goToGridDoc, mockedDocument } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -86,26 +81,69 @@ test.describe('Doc Editor', () => {
).toBeVisible();
});
test('checks the Doc is connected to the provider server', async ({
/**
* We check:
* - connection to the collaborative server
* - signal of the backend to the collaborative server (connection should close)
* - reconnection to the collaborative server
*/
test('checks the connection with collaborative server', async ({
page,
browserName,
}) => {
const webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
return webSocket.url().includes('ws://localhost:4444/');
let webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
return webSocket
.url()
.includes('ws://localhost:8083/collaboration/ws/?room=');
});
const randomDoc = await createDoc(page, 'doc-editor', browserName, 1);
await verifyDocName(page, randomDoc[0]);
await expect(page.locator('h2').getByText(randomDoc[0])).toBeVisible();
const webSocket = await webSocketPromise;
expect(webSocket.url()).toContain('ws://localhost:4444/');
let webSocket = await webSocketPromise;
expect(webSocket.url()).toContain(
'ws://localhost:8083/collaboration/ws/?room=',
);
const framesentPromise = webSocket.waitForEvent('framesent');
// Is connected
let framesentPromise = webSocket.waitForEvent('framesent');
await page.locator('.ProseMirror.bn-editor').click();
await page.locator('.ProseMirror.bn-editor').fill('Hello World');
const framesent = await framesentPromise;
let framesent = await framesentPromise;
expect(framesent.payload).not.toBeNull();
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByRole('combobox', {
name: 'Visibility',
});
// When the visibility is changed, the ws should closed the connection (backend signal)
const wsClosePromise = webSocket.waitForEvent('close');
await selectVisibility.click();
await page
.getByRole('option', {
name: 'Authenticated',
})
.click();
// Assert that the doc reconnects to the ws
const wsClose = await wsClosePromise;
expect(wsClose.isClosed()).toBeTruthy();
// Checkt the ws is connected again
webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
return webSocket
.url()
.includes('ws://localhost:8083/collaboration/ws/?room=');
});
webSocket = await webSocketPromise;
framesentPromise = webSocket.waitForEvent('framesent');
framesent = await framesentPromise;
expect(framesent.payload).not.toBeNull();
});
@@ -115,7 +153,7 @@ test.describe('Doc Editor', () => {
}) => {
const randomDoc = await createDoc(page, 'doc-markdown', browserName, 1);
await verifyDocName(page, randomDoc[0]);
await expect(page.locator('h2').getByText(randomDoc[0])).toBeVisible();
const editor = page.locator('.ProseMirror');
await editor.click();
@@ -140,7 +178,7 @@ test.describe('Doc Editor', () => {
}) => {
// Check the first doc
const [firstDoc] = await createDoc(page, 'doc-switch-1', browserName, 1);
await verifyDocName(page, firstDoc);
await expect(page.locator('h2').getByText(firstDoc)).toBeVisible();
const editor = page.locator('.ProseMirror');
await editor.click();
@@ -149,8 +187,7 @@ test.describe('Doc Editor', () => {
// Check the second doc
const [secondDoc] = await createDoc(page, 'doc-switch-2', browserName, 1);
await verifyDocName(page, secondDoc);
await expect(page.locator('h2').getByText(secondDoc)).toBeVisible();
await expect(editor.getByText('Hello World Doc 1')).toBeHidden();
await editor.click();
await editor.fill('Hello World Doc 2');
@@ -160,7 +197,7 @@ test.describe('Doc Editor', () => {
await goToGridDoc(page, {
title: firstDoc,
});
await verifyDocName(page, firstDoc);
await expect(page.locator('h2').getByText(firstDoc)).toBeVisible();
await expect(editor.getByText('Hello World Doc 2')).toBeHidden();
await expect(editor.getByText('Hello World Doc 1')).toBeVisible();
});
@@ -171,7 +208,7 @@ test.describe('Doc Editor', () => {
}) => {
// Check the first doc
const [doc] = await createDoc(page, 'doc-saves-change', browserName, 1);
await verifyDocName(page, doc);
await expect(page.locator('h2').getByText(doc)).toBeVisible();
const editor = page.locator('.ProseMirror');
await editor.click();
@@ -182,7 +219,7 @@ test.describe('Doc Editor', () => {
nthRow: 2,
});
await verifyDocName(page, secondDoc);
await expect(page.locator('h2').getByText(secondDoc)).toBeVisible();
await goToGridDoc(page, {
title: doc,
@@ -196,9 +233,8 @@ test.describe('Doc Editor', () => {
test.skip(browserName === 'webkit', 'This test is very flaky with webkit');
// Check the first doc
const doc = await goToGridDoc(page);
await verifyDocName(page, doc);
const [doc] = await createDoc(page, 'doc-quit-1', browserName, 1);
await expect(page.locator('h2').getByText(doc)).toBeVisible();
const editor = page.locator('.ProseMirror');
await editor.click();
@@ -236,8 +272,8 @@ test.describe('Doc Editor', () => {
).toBeVisible();
});
test('it adds an image to the doc editor', async ({ page }) => {
await goToGridDoc(page);
test('it adds an image to the doc editor', async ({ page, browserName }) => {
await createDoc(page, 'doc-image', browserName, 1);
const fileChooserPromise = page.waitForEvent('filechooser');

View File

@@ -3,44 +3,13 @@ import cs from 'convert-stream';
import jsdom from 'jsdom';
import pdf from 'pdf-parse';
import { createDoc, verifyDocName } from './common';
import { createDoc } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Doc Export', () => {
test('it check if all elements are visible', async ({
page,
browserName,
}) => {
await createDoc(page, 'doc-editor', browserName, 1);
await page
.getByRole('button', {
name: 'download',
})
.click();
await expect(
page
.locator('div')
.filter({ hasText: /^Download$/ })
.first(),
).toBeVisible();
await expect(
page.getByText(
'Upload your docs to a Microsoft Word, Open Office or PDF document',
),
).toBeVisible();
await expect(
page.getByRole('combobox', { name: 'Template' }),
).toBeVisible();
await expect(page.getByRole('combobox', { name: 'Format' })).toBeVisible();
await expect(
page.getByRole('button', { name: 'Close the modal' }),
).toBeVisible();
await expect(page.getByRole('button', { name: 'Download' })).toBeVisible();
});
test('it converts the doc to pdf with a template integrated', async ({
page,
browserName,
@@ -51,14 +20,15 @@ test.describe('Doc Export', () => {
return download.suggestedFilename().includes(`${randomDoc}.pdf`);
});
await verifyDocName(page, randomDoc);
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: 'download',
name: 'Export',
})
.click();
@@ -87,19 +57,19 @@ test.describe('Doc Export', () => {
return download.suggestedFilename().includes(`${randomDoc}.docx`);
});
await verifyDocName(page, randomDoc);
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: 'download',
name: 'Export',
})
.click();
await page.getByRole('combobox', { name: 'Format' }).click();
await page.getByRole('option', { name: 'Word / Open Office' }).click();
await page.getByText('Docx').click();
await page
.getByRole('button', {
@@ -127,7 +97,7 @@ test.describe('Doc Export', () => {
await route.continue();
});
await verifyDocName(page, randomDoc);
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();
@@ -220,9 +190,10 @@ test.describe('Doc Export', () => {
.click();
// Download
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
name: 'download',
name: 'Export',
})
.click();

View File

@@ -1,14 +1,264 @@
import { expect, test } from '@playwright/test';
type SmallDoc = {
id: string;
title: string;
};
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',
defaultColumn: false,
},
{
nameColumn: 'Created at',
ordering: 'created_at',
cellNumber: 2,
orderDefault: '',
orderDesc: '&ordering=-created_at',
orderAsc: '&ordering=created_at',
defaultColumn: false,
},
{
nameColumn: 'Updated at',
ordering: 'updated_at',
cellNumber: 3,
orderDefault: '&ordering=-updated_at',
orderDesc: '&ordering=updated_at',
orderAsc: '',
defaultColumn: true,
},
].forEach(
({
nameColumn,
ordering,
cellNumber,
orderDefault,
orderDesc,
orderAsc,
defaultColumn,
}) => {
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');
const datagridTable = datagrid.getByRole('table');
const thead = datagridTable.locator('thead');
const response = await responsePromise;
expect(response.ok()).toBeTruthy();
const docNameRow1 = datagridTable
.getByRole('row')
.nth(1)
.getByRole('cell')
.nth(cellNumber);
const docNameRow2 = datagridTable
.getByRole('row')
.nth(2)
.getByRole('cell')
.nth(cellNumber);
await expect(datagrid.getByLabel('Loading data')).toBeHidden({
timeout: 10000,
});
// 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({
timeout: 10000,
});
await expect(docNameRow1).toHaveText(/.*/);
await expect(docNameRow2).toHaveText(/.*/);
const textDocNameRow1Asc = await docNameRow1.textContent();
const textDocNameRow2Asc = await docNameRow2.textContent();
const compare = (comp1: string, comp2: string) => {
const comparisonResult = comp1.localeCompare(comp2, 'en', {
caseFirst: 'false',
ignorePunctuation: true,
});
// eslint-disable-next-line playwright/no-conditional-in-test
return defaultColumn ? comparisonResult >= 0 : comparisonResult <= 0;
};
expect(
textDocNameRow1Asc &&
textDocNameRow2Asc &&
compare(textDocNameRow1Asc, textDocNameRow2Asc),
).toBeTruthy();
// Ordering Desc
await thead.getByText(nameColumn).click();
const responseOrderingDesc = await responsePromiseOrderingDesc;
expect(responseOrderingDesc.ok()).toBeTruthy();
await expect(datagrid.getByLabel('Loading data')).toBeHidden({
timeout: 10000,
});
await expect(docNameRow1).toHaveText(/.*/);
await expect(docNameRow2).toHaveText(/.*/);
const textDocNameRow1Desc = await docNameRow1.textContent();
const textDocNameRow2Desc = await docNameRow2.textContent();
expect(
textDocNameRow1Desc &&
textDocNameRow2Desc &&
compare(textDocNameRow2Desc, textDocNameRow1Desc),
).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 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
.getByRole('button', {
name: 'Delete the 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();
});
});
test.describe('Documents Grid mobile', () => {
test.use({ viewport: { width: 500, height: 1200 } });
@@ -76,257 +326,19 @@ test.describe('Documents Grid mobile', () => {
await page.goto('/');
const docsGrid = page.getByTestId('docs-grid');
await expect(docsGrid).toBeVisible();
await expect(page.getByTestId('docs-grid-loader')).toBeHidden();
const datagrid = page.getByLabel('Datagrid of the documents page 1');
const tableDatagrid = datagrid.getByRole('table');
const rows = docsGrid.getByRole('row');
await expect(datagrid.getByLabel('Loading data')).toBeHidden({
timeout: 10000,
});
const rows = tableDatagrid.getByRole('row');
const row = rows.filter({
hasText: 'My mocked document',
});
await expect(
row.locator('[aria-describedby="doc-title"]').nth(0),
).toHaveText('My mocked document');
});
});
test.describe('Document grid item options', () => {
test('it deletes the document', async ({ page }) => {
let docs: SmallDoc[] = [];
const response = await page.waitForResponse(
(response) =>
response.url().includes('documents/?page=1') &&
response.status() === 200,
);
const result = await response.json();
docs = result.results as SmallDoc[];
const button = page.getByTestId(`docs-grid-actions-button-${docs[0].id}`);
await expect(button).toBeVisible();
await button.click();
const removeButton = page.getByTestId(
`docs-grid-actions-remove-${docs[0].id}`,
);
await expect(removeButton).toBeVisible();
await removeButton.click();
await expect(
page.locator('h2').getByText(`Deleting the document "${docs[0].title}"`),
).toBeVisible();
await page
.getByRole('button', {
name: 'Confirm deletion',
})
.click();
const refetchResponse = await page.waitForResponse(
(response) =>
response.url().includes('documents/?page=1') &&
response.status() === 200,
);
const resultRefetch = await refetchResponse.json();
expect(resultRefetch.count).toBe(result.count - 1);
await expect(page.getByTestId('main-layout-loader')).toBeHidden();
await expect(
page.getByText('The document has been deleted.'),
).toBeVisible();
await expect(button).toBeHidden();
});
test("it checks if the delete option is disabled if we don't have the destroy capability", async ({
page,
}) => {
await page.route('*/**/api/v1.0/documents/?page=1', async (route) => {
await route.fulfill({
json: {
results: [
{
id: 'mocked-document-id',
content: '',
title: 'Mocked document',
accesses: [],
abilities: {
destroy: false, // Means not owner
link_configuration: false,
versions_destroy: false,
versions_list: true,
versions_retrieve: true,
accesses_manage: false, // Means not admin
update: false,
partial_update: false, // Means not editor
retrieve: true,
},
link_reach: 'restricted',
created_at: '2021-09-01T09:00:00Z',
},
],
},
});
});
await page.goto('/');
const button = page.getByTestId(
`docs-grid-actions-button-mocked-document-id`,
);
await expect(button).toBeVisible();
await button.click();
const removeButton = page.getByTestId(
`docs-grid-actions-remove-mocked-document-id`,
);
await expect(removeButton).toBeVisible();
await removeButton.isDisabled();
});
});
test.describe('Documents filters', () => {
test('it checks the prebuild left panel filters', async ({ page }) => {
// All Docs
await expect(page.getByTestId('docs-grid-loader')).toBeVisible();
const response = await page.waitForResponse(
(response) =>
response.url().includes('documents/?page=1') &&
response.status() === 200,
);
const result = await response.json();
const allCount = result.count as number;
await expect(page.getByTestId('docs-grid-loader')).toBeHidden();
const allDocs = page.getByLabel('All docs');
const myDocs = page.getByLabel('My docs');
const sharedWithMe = page.getByLabel('Shared with me');
// Initial state
await expect(allDocs).toBeVisible();
await expect(allDocs).toHaveCSS('background-color', 'rgb(238, 238, 238)');
await expect(allDocs).toHaveAttribute('aria-selected', 'true');
await expect(myDocs).toBeVisible();
await expect(myDocs).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)');
await expect(myDocs).toHaveAttribute('aria-selected', 'false');
await expect(sharedWithMe).toBeVisible();
await expect(sharedWithMe).toHaveCSS(
'background-color',
'rgba(0, 0, 0, 0)',
);
await expect(sharedWithMe).toHaveAttribute('aria-selected', 'false');
await allDocs.click();
let url = new URL(page.url());
let target = url.searchParams.get('target');
expect(target).toBe('all_docs');
// My docs
await myDocs.click();
url = new URL(page.url());
target = url.searchParams.get('target');
expect(target).toBe('my_docs');
await expect(page.getByTestId('docs-grid-loader')).toBeVisible();
const responseMyDocs = await page.waitForResponse(
(response) =>
response.url().includes('documents/?page=1&is_creator_me=true') &&
response.status() === 200,
);
const resultMyDocs = await responseMyDocs.json();
const countMyDocs = resultMyDocs.count as number;
await expect(page.getByTestId('docs-grid-loader')).toBeHidden();
expect(countMyDocs).toBeLessThanOrEqual(allCount);
// Shared with me
await sharedWithMe.click();
url = new URL(page.url());
target = url.searchParams.get('target');
expect(target).toBe('shared_with_me');
await expect(page.getByTestId('docs-grid-loader')).toBeVisible();
const responseSharedWithMe = await page.waitForResponse(
(response) =>
response.url().includes('documents/?page=1&is_creator_me=false') &&
response.status() === 200,
);
const resultSharedWithMe = await responseSharedWithMe.json();
const countSharedWithMe = resultSharedWithMe.count as number;
await expect(page.getByTestId('docs-grid-loader')).toBeHidden();
expect(countSharedWithMe).toBeLessThanOrEqual(allCount);
expect(countSharedWithMe + countMyDocs).toEqual(allCount);
});
});
test.describe('Documents Grid', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('checks all the elements are visible', async ({ page }) => {
let docs: SmallDoc[] = [];
await expect(page.getByTestId('docs-grid-loader')).toBeVisible();
const response = await page.waitForResponse(
(response) =>
response.url().includes('documents/?page=1') &&
response.status() === 200,
);
const result = await response.json();
docs = result.results as SmallDoc[];
await expect(page.getByTestId('docs-grid-loader')).toBeHidden();
await expect(page.locator('h4').getByText('All docs')).toBeVisible();
const thead = page.getByTestId('docs-grid-header');
await expect(thead.getByText(/Name/i)).toBeVisible();
await expect(thead.getByText(/Updated at/i)).toBeVisible();
await Promise.all(
docs.map(async (doc) => {
await expect(
page.getByTestId(`docs-grid-name-${doc.id}`),
).toBeVisible();
}),
);
});
test('checks the infinite scroll', async ({ page }) => {
let docs: SmallDoc[] = [];
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 responsePage1 = await responsePromisePage1;
expect(responsePage1.ok()).toBeTruthy();
let result = await responsePage1.json();
docs = result.results as SmallDoc[];
await Promise.all(
docs.map(async (doc) => {
await expect(
page.getByTestId(`docs-grid-name-${doc.id}`),
).toBeVisible();
}),
);
await page.getByTestId('infinite-scroll-trigger').scrollIntoViewIfNeeded();
const responsePage2 = await responsePromisePage2;
result = await responsePage2.json();
docs = result.results as SmallDoc[];
await Promise.all(
docs.map(async (doc) => {
await expect(
page.getByTestId(`docs-grid-name-${doc.id}`),
).toBeVisible();
}),
);
await expect(row.getByRole('cell').nth(0)).toHaveText('My mocked document');
await expect(row.getByRole('cell').nth(1)).toHaveText('Public');
});
});

View File

@@ -6,7 +6,6 @@ import {
mockedAccesses,
mockedDocument,
mockedInvitations,
verifyDocName,
} from './common';
test.beforeEach(async ({ page }) => {
@@ -60,31 +59,84 @@ test.describe('Doc Header', () => {
const card = page.getByLabel(
'It is the card information about the document.',
);
const docTitle = card.getByRole('textbox', { name: 'doc title input' });
await expect(docTitle).toBeVisible();
await expect(card.getByText('Public document')).toBeVisible();
await expect(card.getByText('Owner ·')).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
await expect(page.getByRole('button', { name: 'download' })).toBeVisible();
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(
page.getByRole('button', { name: 'Open the document options' }),
card.getByText('Created at 09/01/2021, 11:00 AM'),
).toBeVisible();
await expect(card.getByText('Your role: Owner')).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
});
test('it updates the title doc', async ({ page, browserName }) => {
await createDoc(page, 'doc-update', browserName, 1);
const docTitle = page.getByRole('textbox', { name: 'doc title input' });
await expect(docTitle).toBeVisible();
await docTitle.fill('Hello World');
await docTitle.blur();
await verifyDocName(page, 'Hello World');
const [randomDoc] = await createDoc(page, 'doc-update', browserName, 1);
await page.getByRole('heading', { name: randomDoc }).fill(' ');
await page.getByText('Created at').click();
await expect(
page.getByRole('heading', { name: 'Untitled document' }),
).toBeVisible();
});
test('it updates the title doc from editor heading', async ({ page }) => {
await page
.getByRole('button', {
name: 'Create a new document',
})
.click();
const docHeader = page.getByLabel(
'It is the card information about the document.',
);
await expect(
docHeader.getByRole('heading', { name: 'Untitled document', level: 2 }),
).toBeVisible();
const editor = page.locator('.ProseMirror');
await editor.locator('h1').click();
await page.keyboard.type('Hello World', { delay: 100 });
await expect(
docHeader.getByRole('heading', { name: 'Hello World', level: 2 }),
).toBeVisible();
await expect(
page.getByText('Document title updated successfully'),
).toBeVisible();
await docHeader
.getByRole('heading', { name: 'Hello World', level: 2 })
.fill('Top World');
await editor.locator('h1').fill('Super World');
await expect(
docHeader.getByRole('heading', { name: 'Top World', level: 2 }),
).toBeVisible();
await editor.locator('h1').fill('');
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(500);
await docHeader
.getByRole('heading', { name: 'Top World', level: 2 })
.fill(' ');
await page.getByText('Created at').click();
await expect(
docHeader.getByRole('heading', { name: 'Untitled document', level: 2 }),
).toBeVisible();
});
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
@@ -107,7 +159,9 @@ test.describe('Doc Header', () => {
page.getByText('The document has been deleted.'),
).toBeVisible();
await expect(page.getByRole('button', { name: 'New do' })).toBeVisible();
await expect(
page.getByRole('button', { name: 'Create a new document' }),
).toBeVisible();
const row = page
.getByLabel('Datagrid of the documents page 1')
@@ -141,13 +195,16 @@ test.describe('Doc Header', () => {
await goToGridDoc(page);
await expect(page.getByRole('button', { name: 'download' })).toBeVisible();
await expect(
page.locator('h2').getByText('Mocked document'),
).toHaveAttribute('contenteditable');
await page.getByLabel('Open the document options').click();
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
await expect(
page.getByRole('button', { name: 'Delete document' }),
).toBeDisabled();
).toBeHidden();
// Click somewhere else to close the options
await page.click('body', { position: { x: 0, y: 0 } });
@@ -216,12 +273,16 @@ test.describe('Doc Header', () => {
await goToGridDoc(page);
await expect(page.getByRole('button', { name: 'download' })).toBeVisible();
await expect(
page.locator('h2').getByText('Mocked document'),
).toHaveAttribute('contenteditable');
await page.getByLabel('Open the document options').click();
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
await expect(
page.getByRole('button', { name: 'Delete document' }),
).toBeDisabled();
).toBeHidden();
// Click somewhere else to close the options
await page.click('body', { position: { x: 0, y: 0 } });
@@ -290,12 +351,16 @@ test.describe('Doc Header', () => {
await goToGridDoc(page);
await expect(page.getByRole('button', { name: 'download' })).toBeVisible();
await expect(
page.locator('h2').getByText('Mocked document'),
).not.toHaveAttribute('contenteditable');
await page.getByLabel('Open the document options').click();
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
await expect(
page.getByRole('button', { name: 'Delete document' }),
).toBeDisabled();
).toBeHidden();
// Click somewhere else to close the options
await page.click('body', { position: { x: 0, y: 0 } });
@@ -349,7 +414,7 @@ test.describe('Doc Header', () => {
// create page and navigate to it
await page
.getByRole('button', {
name: 'New doc',
name: 'Create a new document',
})
.click();
@@ -384,7 +449,7 @@ test.describe('Doc Header', () => {
// create page and navigate to it
await page
.getByRole('button', {
name: 'New doc',
name: 'Create a new document',
})
.click();
@@ -436,7 +501,6 @@ test.describe('Documents Header mobile', () => {
await goToGridDoc(page);
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Share' }).click();
await expect(page.getByLabel('Share modal')).toBeVisible();

View File

@@ -2,7 +2,7 @@ import { expect, test } from '@playwright/test';
import { waitForElementCount } from '../helpers';
import { addNewMember, createDoc, goToGridDoc, verifyDocName } from './common';
import { addNewMember, createDoc, goToGridDoc } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -54,10 +54,8 @@ test.describe('Document list members', () => {
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();
const loadMoreButton = page
.getByLabel('List members card')
.getByRole('button', { name: 'arrow_downward Load more' });
await loadMoreButton.scrollIntoViewIfNeeded();
await page.mouse.wheel(0, 10);
await waitForElementCount(list.locator('li'), 21, 10000);
expect(await list.locator('li').count()).toBeGreaterThan(20);
@@ -111,13 +109,9 @@ test.describe('Document list members', () => {
await page.getByRole('button', { name: 'Share' }).click();
const list = page.getByLabel('List invitation card').locator('ul');
await expect(list.locator('li')).toHaveCount(20);
await list.getByText(`impress@impress.world-page-${1}-18`).hover();
const loadMoreButton = page
.getByLabel('List invitation card')
.getByRole('button', { name: 'arrow_downward Load more' });
await loadMoreButton.scrollIntoViewIfNeeded();
await page.mouse.wheel(0, 10);
await waitForElementCount(list.locator('li'), 21, 10000);
@@ -133,7 +127,7 @@ test.describe('Document list members', () => {
test('it checks the role rules', async ({ page, browserName }) => {
const [docTitle] = await createDoc(page, 'Doc role rules', browserName, 1);
await verifyDocName(page, docTitle);
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
@@ -194,7 +188,7 @@ test.describe('Document list members', () => {
test('it checks the delete members', async ({ page, browserName }) => {
const [docTitle] = await createDoc(page, 'Doc role rules', browserName, 1);
await verifyDocName(page, docTitle);
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();

View File

@@ -9,7 +9,7 @@ test.describe('Doc Routing', () => {
test('Check the presence of the meta tag noindex', async ({ page }) => {
const buttonCreateHomepage = page.getByRole('button', {
name: 'New doc',
name: 'Create a new document',
});
await expect(buttonCreateHomepage).toBeVisible();
@@ -27,7 +27,7 @@ test.describe('Doc Routing', () => {
await expect(page).toHaveURL('/');
const buttonCreateHomepage = page.getByRole('button', {
name: 'New doc',
name: 'Create a new document',
});
await expect(buttonCreateHomepage).toBeVisible();

View File

@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
import { createDoc, verifyDocName } from './common';
import { createDoc, goToGridDoc } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -17,60 +17,123 @@ test.describe('Doc Table Content', () => {
1,
);
await verifyDocName(page, randomDoc);
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
name: 'Table of contents',
})
.click();
const panel = page.getByLabel('Document panel');
const editor = page.locator('.ProseMirror');
await editor.locator('.bn-block-outer').last().fill('/');
await page.getByText('Heading 1').click();
await page.keyboard.type('Level 1');
await editor.getByText('Level 1').dblclick();
await page.keyboard.type('Hello World');
await editor.getByText('Hello').dblclick();
await page.getByRole('button', { name: 'Strike' }).click();
await page.locator('.bn-block-outer').first().click();
await editor.click();
await page.locator('.bn-block-outer').last().click();
// Create space to fill the viewport
for (let i = 0; i < 2; i++) {
for (let i = 0; i < 10; i++) {
await page.keyboard.press('Enter');
}
await editor.locator('.bn-block-outer').last().fill('/');
await page.getByText('Heading 2').click();
await page.keyboard.type('Level 2');
await page.keyboard.type('Super World', { delay: 100 });
await page.locator('.bn-block-outer').last().click();
// Create space to fill the viewport
for (let i = 0; i < 2; i++) {
for (let i = 0; i < 10; i++) {
await page.keyboard.press('Enter');
}
await editor.locator('.bn-block-outer').last().fill('/');
await page.getByText('Heading 3').click();
await page.keyboard.type('Level 3');
await page.keyboard.type('Another World');
expect(true).toBe(true);
const hello = panel.getByText('Hello World');
const superW = panel.getByText('Super World');
const another = panel.getByText('Another World');
const summaryContainer = page.locator('#summaryContainer');
await summaryContainer.hover();
await expect(hello).toBeVisible();
await expect(hello).toHaveCSS('font-size', /17/);
await expect(hello).toHaveAttribute('aria-selected', 'true');
const level1 = summaryContainer.getByText('Level 1');
const level2 = summaryContainer.getByText('Level 2');
const level3 = summaryContainer.getByText('Level 3');
await expect(superW).toBeVisible();
await expect(superW).toHaveCSS('font-size', /14/);
await expect(superW).toHaveAttribute('aria-selected', 'false');
await expect(level1).toBeVisible();
await expect(level1).toHaveCSS('padding', /4px 0px/);
await expect(level1).toHaveAttribute('aria-selected', 'true');
await expect(another).toBeVisible();
await expect(another).toHaveCSS('font-size', /12/);
await expect(another).toHaveAttribute('aria-selected', 'false');
await expect(level2).toBeVisible();
await expect(level2).toHaveCSS('padding-left', /14.4px/);
await expect(level2).toHaveAttribute('aria-selected', 'false');
await hello.click();
await expect(level3).toBeVisible();
await expect(level3).toHaveCSS('padding-left', /24px/);
await expect(level3).toHaveAttribute('aria-selected', 'false');
await expect(editor.getByText('Hello World')).toBeInViewport();
await expect(hello).toHaveAttribute('aria-selected', 'true');
await expect(superW).toHaveAttribute('aria-selected', 'false');
await another.click();
await expect(editor.getByText('Hello World')).not.toBeInViewport();
await expect(hello).toHaveAttribute('aria-selected', 'false');
await expect(superW).toHaveAttribute('aria-selected', 'true');
await panel.getByText('Back to top').click();
await expect(editor.getByText('Hello World')).toBeInViewport();
await expect(hello).toHaveAttribute('aria-selected', 'true');
await expect(superW).toHaveAttribute('aria-selected', 'false');
await panel.getByText('Go to bottom').click();
await expect(editor.getByText('Hello World')).not.toBeInViewport();
await expect(superW).toHaveAttribute('aria-selected', 'true');
});
test('it checks that table contents panel is opened automaticaly if more that 2 headings', async ({
page,
browserName,
}) => {
const [randomDoc] = await createDoc(
page,
'doc-table-content',
browserName,
1,
);
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await expect(page.getByLabel('Open the panel')).toBeHidden();
const editor = page.locator('.ProseMirror');
await editor.locator('.bn-block-outer').last().fill('/');
await page.getByText('Heading 1').click();
await page.keyboard.type('Hello World', { delay: 100 });
await page.keyboard.press('Enter');
await editor.locator('.bn-block-outer').last().fill('/');
await page.getByText('Heading 2').click();
await page.keyboard.type('Super World', { delay: 100 });
await goToGridDoc(page, {
title: randomDoc,
});
await expect(page.getByLabel('Close the panel')).toBeVisible();
const panel = page.getByLabel('Document panel');
await expect(panel.getByText('Hello World')).toBeVisible();
await expect(panel.getByText('Super World')).toBeVisible();
await page.getByLabel('Close the panel').click();
await expect(panel).toHaveAttribute('aria-hidden', 'true');
});
});

View File

@@ -1,11 +1,6 @@
import { expect, test } from '@playwright/test';
import {
createDoc,
goToGridDoc,
mockedDocument,
verifyDocName,
} from './common';
import { createDoc, goToGridDoc, mockedDocument } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -15,7 +10,7 @@ test.describe('Doc Version', () => {
test('it displays the doc versions', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-version', browserName, 1);
await verifyDocName(page, randomDoc);
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await page.getByLabel('Open the document options').click();
await page
@@ -23,27 +18,24 @@ test.describe('Doc Version', () => {
name: 'Version history',
})
.click();
await expect(page.getByText('History', { exact: true })).toBeVisible();
const modal = page.getByLabel('version history modal');
const panel = modal.getByLabel('version list');
await expect(panel).toBeVisible();
await expect(modal.getByText('No versions')).toBeVisible();
const panel = page.getByLabel('Document panel');
await expect(panel.getByText('Current version')).toBeVisible();
expect(await panel.locator('li').count()).toBe(1);
await modal.getByRole('button', { name: 'close' }).click();
await page.locator('.ProseMirror.bn-editor').click();
await page.locator('.ProseMirror.bn-editor').last().fill('Hello World');
await goToGridDoc(page, {
title: randomDoc,
});
await expect(
page.getByRole('heading', { name: 'Hello World' }),
).toBeVisible();
await expect(page.getByText('Hello World')).toBeVisible();
await page
.locator('.ProseMirror .bn-block')
.getByRole('heading', { name: 'Hello World' })
.getByText('Hello World')
.fill('It will create a version');
await goToGridDoc(page, {
@@ -51,9 +43,7 @@ test.describe('Doc Version', () => {
});
await expect(page.getByText('Hello World')).toBeHidden();
await expect(
page.getByRole('heading', { name: 'It will create a version' }),
).toBeVisible();
await expect(page.getByText('It will create a version')).toBeVisible();
await page.getByLabel('Open the document options').click();
await page
@@ -62,16 +52,19 @@ test.describe('Doc Version', () => {
})
.click();
await expect(panel).toBeVisible();
await expect(page.getByText('History', { exact: true })).toBeVisible();
await expect(page.getByRole('status')).toBeVisible();
await expect(page.getByRole('status')).toBeHidden();
const items = await panel.locator('.version-item').all();
expect(items.length).toBe(1);
await items[0].click();
await expect(panel.getByText('Current version')).toBeVisible();
expect(await panel.locator('li').count()).toBe(2);
await expect(modal.getByText('Hello World')).toBeVisible();
await expect(modal.getByText('It will create a version')).toBeHidden();
await panel.locator('li').nth(1).click();
await expect(
page.getByText('Read only, you cannot edit document versions.'),
).toBeVisible();
await expect(page.getByText('Hello World')).toBeVisible();
await expect(page.getByText('It will create a version')).toBeHidden();
await panel.getByText('Current version').click();
await expect(page.getByText('Hello World')).toBeHidden();
await expect(page.getByText('It will create a version')).toBeVisible();
});
test('it does not display the doc versions if not allowed', async ({
@@ -86,17 +79,24 @@ test.describe('Doc Version', () => {
await goToGridDoc(page);
await verifyDocName(page, 'Mocked document');
await expect(page.locator('h2').getByText('Mocked document')).toBeVisible();
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('button', { name: 'Version history' }),
).toBeDisabled();
).toBeHidden();
await page.getByRole('button', { name: 'Table of content' }).click();
await expect(
page.getByLabel('Document panel').getByText('Versions'),
).toBeHidden();
});
test('it restores the doc version', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-version', browserName, 1);
await verifyDocName(page, randomDoc);
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await page.locator('.bn-block-outer').last().click();
await page.locator('.bn-block-outer').last().fill('Hello');
@@ -124,26 +124,84 @@ test.describe('Doc Version', () => {
})
.click();
const modal = page.getByLabel('version history modal');
const panel = modal.getByLabel('version list');
await expect(panel).toBeVisible();
const panel = page.getByLabel('Document panel');
await panel.locator('li').nth(1).click();
await expect(page.getByText('World')).toBeHidden();
await expect(page.getByText('History', { exact: true })).toBeVisible();
await expect(page.getByRole('status')).toBeVisible();
await expect(page.getByRole('status')).toBeHidden();
const items = await panel.locator('.version-item').all();
expect(items.length).toBe(1);
await items[0].click();
await panel.getByLabel('Open the version options').click();
await page.getByText('Restore the version').click();
await expect(modal.getByText('World')).toBeHidden();
await expect(page.getByText('Restore this version?')).toBeVisible();
await page.getByRole('button', { name: 'Restore' }).click();
await expect(page.getByText('Your current document will')).toBeVisible();
await page.getByText('If a member is editing, his').click();
await page
.getByRole('button', {
name: 'Restore',
})
.click();
await page.getByLabel('Restore', { exact: true }).click();
await expect(panel.locator('li')).toHaveCount(3);
await panel.getByText('Current version').click();
await expect(page.getByText('Hello')).toBeVisible();
await expect(page.getByText('World')).toBeHidden();
});
test('it restores the doc version from button title', async ({
page,
browserName,
}) => {
const [randomDoc] = await createDoc(page, 'doc-version', browserName, 1);
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
const editor = page.locator('.ProseMirror');
await editor.locator('.bn-block-outer').last().click();
await editor.locator('.bn-block-outer').last().fill('Hello');
await goToGridDoc(page, {
title: randomDoc,
});
await expect(editor.getByText('Hello')).toBeVisible();
await editor.locator('.bn-block-outer').last().click();
await page.keyboard.press('Enter');
await editor.locator('.bn-block-outer').last().fill('World');
await goToGridDoc(page, {
title: randomDoc,
});
await expect(editor.getByText('World')).toBeVisible();
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
name: 'Version history',
})
.click();
const panel = page.getByLabel('Document panel');
await panel.locator('li').nth(1).click();
await expect(editor.getByText('World')).toBeHidden();
await page
.getByRole('button', {
name: 'Restore this version',
})
.click();
await expect(page.getByText('Restore this version?')).toBeVisible();
await page
.getByRole('button', {
name: 'Restore',
})
.click();
await expect(panel.locator('li')).toHaveCount(3);
await panel.getByText('Current version').click();
await expect(editor.getByText('Hello')).toBeVisible();
await expect(editor.getByText('World')).toBeHidden();
});
});

View File

@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
import { createDoc, keyCloakSignIn, verifyDocName } from './common';
import { createDoc, keyCloakSignIn } from './common';
const browsersName = ['chromium', 'webkit', 'firefox'];
@@ -85,7 +85,7 @@ test.describe('Doc Visibility: Restricted', () => {
1,
);
await verifyDocName(page, docTitle);
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
const urlDoc = page.url();
@@ -111,7 +111,7 @@ test.describe('Doc Visibility: Restricted', () => {
const [docTitle] = await createDoc(page, 'Restricted auth', browserName, 1);
await verifyDocName(page, docTitle);
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
const urlDoc = page.url();
@@ -139,7 +139,7 @@ test.describe('Doc Visibility: Restricted', () => {
const [docTitle] = await createDoc(page, 'Restricted auth', browserName, 1);
await verifyDocName(page, docTitle);
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
@@ -176,7 +176,7 @@ test.describe('Doc Visibility: Restricted', () => {
await page.goto(urlDoc);
await verifyDocName(page, docTitle);
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
});
});
@@ -198,7 +198,7 @@ test.describe('Doc Visibility: Public', () => {
1,
);
await verifyDocName(page, docTitle);
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
await page
@@ -227,14 +227,10 @@ test.describe('Doc Visibility: Public', () => {
position: { x: 0, y: 0 },
});
const cardContainer = page.getByLabel(
'It is the card information about the document.',
);
await expect(cardContainer.getByTestId('public-icon')).toBeVisible();
await expect(
cardContainer.getByText('Public document', { exact: true }),
page
.getByLabel('It is the card information about the document.')
.getByText('Public', { exact: true }),
).toBeVisible();
const urlDoc = page.url();
@@ -265,7 +261,7 @@ test.describe('Doc Visibility: Public', () => {
const [docTitle] = await createDoc(page, 'Public editable', browserName, 1);
await verifyDocName(page, docTitle);
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
await page
@@ -294,14 +290,10 @@ test.describe('Doc Visibility: Public', () => {
position: { x: 0, y: 0 },
});
const cardContainer = page.getByLabel(
'It is the card information about the document.',
);
await expect(cardContainer.getByTestId('public-icon')).toBeVisible();
await expect(
cardContainer.getByText('Public document', { exact: true }),
page
.getByLabel('It is the card information about the document.')
.getByText('Public', { exact: true }),
).toBeVisible();
const urlDoc = page.url();
@@ -316,7 +308,7 @@ test.describe('Doc Visibility: Public', () => {
await page.goto(urlDoc);
await verifyDocName(page, docTitle);
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeHidden();
await expect(
page.getByText('Read only, you cannot edit this document'),
@@ -341,7 +333,7 @@ test.describe('Doc Visibility: Authenticated', () => {
1,
);
await verifyDocName(page, docTitle);
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
await page
@@ -393,7 +385,7 @@ test.describe('Doc Visibility: Authenticated', () => {
1,
);
await verifyDocName(page, docTitle);
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
await page
@@ -459,7 +451,7 @@ test.describe('Doc Visibility: Authenticated', () => {
1,
);
await verifyDocName(page, docTitle);
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
await page
@@ -506,7 +498,7 @@ test.describe('Doc Visibility: Authenticated', () => {
await page.goto(urlDoc);
await verifyDocName(page, docTitle);
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
await expect(
page.getByText('Read only, you cannot edit this document'),

View File

@@ -0,0 +1,77 @@
import { expect, test } from '@playwright/test';
import { goToGridDoc } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Footer', () => {
test('checks all the elements are visible', async ({ page }) => {
const footer = page.locator('footer').first();
await expect(footer.getByAltText('Gouvernement Logo')).toBeVisible();
await expect(
footer.getByRole('link', { name: 'legifrance.gouv.fr' }),
).toBeVisible();
await expect(
footer.getByRole('link', { name: 'info.gouv.fr' }),
).toBeVisible();
await expect(
footer.getByRole('link', { name: 'service-public.fr' }),
).toBeVisible();
await expect(
footer.getByRole('link', { name: 'data.gouv.fr' }),
).toBeVisible();
await expect(
footer.getByRole('link', { name: 'Legal Notice' }),
).toBeVisible();
await expect(
footer.getByRole('link', { name: 'Personal data and cookies' }),
).toBeVisible();
await expect(
footer.getByRole('link', { name: 'Accessibility' }),
).toBeVisible();
await expect(
footer.getByText(
'Unless otherwise stated, all content on this site is under licence',
),
).toBeVisible();
});
test('checks footer is not visible on doc editor', async ({ page }) => {
await expect(page.locator('footer')).toBeVisible();
await goToGridDoc(page);
await expect(page.locator('footer')).toBeHidden();
});
const legalPages = [
{ name: 'Legal Notice', url: '/legal-notice/' },
{ name: 'Personal data and cookies', url: '/personal-data-cookies/' },
{ name: 'Accessibility', url: '/accessibility/' },
];
for (const { name, url } of legalPages) {
test(`checks ${name} page`, async ({ page }) => {
const footer = page.locator('footer').first();
await footer.getByRole('link', { name }).click();
await expect(
page
.getByRole('heading', {
name,
})
.first(),
).toBeVisible();
await expect(page).toHaveURL(url);
});
}
});

View File

@@ -75,15 +75,29 @@ test.describe('Header mobile', () => {
test('it checks the header when mobile', async ({ page }) => {
const header = page.locator('header').first();
await expect(header.getByLabel('Open the header menu')).toBeVisible();
await expect(
header.getByRole('link', { name: 'Docs Logo Docs' }),
).toBeVisible();
await expect(
header.getByRole('button', {
name: 'Les services de La Suite numérique',
}),
).toBeVisible();
await expect(
page.getByRole('button', {
name: 'Logout',
}),
).toBeHidden();
await expect(page.getByText('English')).toBeHidden();
await header.getByLabel('Open the header menu').click();
await expect(
page.getByRole('button', {
name: 'Logout',
}),
).toBeVisible();
await expect(page.getByText('English')).toBeVisible();
});
});

View File

@@ -6,7 +6,11 @@ test.beforeEach(async ({ page }) => {
test.describe('Language', () => {
test('checks the language picker', async ({ page }) => {
await expect(page.getByLabel('Logout')).toBeVisible();
await expect(
page.getByRole('button', {
name: 'Create a new document',
}),
).toBeVisible();
const header = page.locator('header').first();
await header.getByRole('combobox').getByText('English').click();
@@ -15,7 +19,11 @@ test.describe('Language', () => {
header.getByRole('combobox').getByText('Français'),
).toBeVisible();
await expect(page.getByLabel('Se déconnecter')).toBeVisible();
await expect(
page.getByRole('button', {
name: 'Créer un nouveau document',
}),
).toBeVisible();
await header.getByRole('combobox').getByText('Français').click();
await header.getByRole('option', { name: 'Deutsch' }).click();
@@ -23,7 +31,11 @@ test.describe('Language', () => {
header.getByRole('combobox').getByText('Deutsch'),
).toBeVisible();
await expect(page.getByLabel('Abmelden')).toBeVisible();
await expect(
page.getByRole('button', {
name: 'Neues Dokument erstellen',
}),
).toBeVisible();
});
test('checks that backend uses the same language as the frontend', async ({

View File

@@ -1,48 +0,0 @@
import { expect, test } from '@playwright/test';
test.describe('Left panel desktop', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('checks all the elements are visible', async ({ page }) => {
await expect(page.getByTestId('left-panel-desktop')).toBeVisible();
await expect(page.getByTestId('left-panel-mobile')).toBeHidden();
await expect(page.getByRole('button', { name: 'house' })).toBeVisible();
await expect(page.getByRole('button', { name: 'New doc' })).toBeVisible();
});
});
test.describe('Left panel mobile', () => {
test.use({ viewport: { width: 500, height: 1200 } });
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('checks all the desktop elements are hidden and all mobile elements are visible', async ({
page,
}) => {
await expect(page.getByTestId('left-panel-desktop')).toBeHidden();
await expect(page.getByTestId('left-panel-mobile')).not.toBeInViewport();
const header = page.locator('header').first();
const homeButton = page.getByRole('button', { name: 'house' });
const newDocButton = page.getByRole('button', { name: 'New doc' });
const languageButton = page.getByRole('combobox', { name: 'Language' });
const logoutButton = page.getByRole('button', { name: 'Logout' });
await expect(homeButton).not.toBeInViewport();
await expect(newDocButton).not.toBeInViewport();
await expect(languageButton).not.toBeInViewport();
await expect(logoutButton).not.toBeInViewport();
await header.getByLabel('Open the header menu').click();
await expect(page.getByTestId('left-panel-mobile')).toBeInViewport();
await expect(homeButton).toBeInViewport();
await expect(newDocButton).toBeInViewport();
await expect(languageButton).toBeInViewport();
await expect(logoutButton).toBeInViewport();
});
});

View File

@@ -1,6 +1,6 @@
{
"name": "app-e2e",
"version": "1.8.2",
"version": "1.10.0",
"private": true,
"scripts": {
"lint": "eslint . --ext .ts",
@@ -12,7 +12,7 @@
"test:ui::chromium": "yarn test:ui --project=chromium"
},
"devDependencies": {
"@playwright/test": "1.49.0",
"@playwright/test": "1.49.1",
"@types/node": "*",
"@types/pdf-parse": "1.1.4",
"eslint-config-impress": "*",

View File

@@ -19,6 +19,7 @@ export default defineConfig({
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
maxFailures: process.env.CI ? 3 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 3 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */

View File

@@ -9,6 +9,10 @@ server {
try_files $uri index.html $uri/ =404;
}
location ~ ^/docs/(.*)/versions/(.*)/$ {
error_page 404 /docs/[id]/versions/[versionId]/;
}
location /docs/ {
error_page 404 /docs/[id]/;
}

View File

@@ -5,30 +5,22 @@ const config = {
colors: {
'card-border': '#ededed',
'primary-bg': '#FAFAFA',
'primary-050': '#F5F5FE',
'primary-100': '#EDF5FA',
'primary-150': '#E5EEFA',
'primary-950': '#1B1B35',
'info-150': '#E5EEFA',
'greyscale-000': '#fff',
'greyscale-1000': '#161616',
},
font: {
sizes: {
xs: '0.75rem',
sm: '0.875rem',
md: '1rem',
lg: '1.125rem',
ml: '0.938rem',
xl: '1.25rem',
xl: '1.50rem',
t: '0.6875rem',
s: '0.75rem',
h1: '2rem',
h2: '1.75rem',
h3: '1.5rem',
h4: '1.375rem',
h5: '1.25rem',
h6: '1.125rem',
h1: '2.2rem',
h2: '1.7rem',
h3: '1.37rem',
h4: '1.15rem',
h5: '1rem',
h6: '0.87rem',
},
weights: {
thin: 100,
@@ -42,21 +34,6 @@ const config = {
auto: 'auto',
bx: '2.2rem',
full: '100%',
'4xs': '0.125rem',
'3xs': '0.25rem',
'2xs': '0.375rem',
xs: '0.5rem',
sm: '0.75rem',
base: '1rem',
md: '1.5rem',
lg: '2rem',
xl: '2.5rem',
xxl: '3rem',
xxxl: '3.5rem',
'4xl': '4rem',
'5xl': '4.5rem',
'6xl': '6rem',
'7xl': '7.5rem',
},
breakpoints: {
xxs: '320px',
@@ -127,7 +104,7 @@ const config = {
focus: 'var(--c--components--forms-select--border-radius)',
},
'font-size': 'var(--c--theme--font--sizes--ml)',
'menu-background-color': '#fff',
'menu-background-color': '#ffffff',
'item-background-color': {
hover: 'var(--c--theme--colors--primary-300)',
},
@@ -149,7 +126,7 @@ const config = {
},
},
modal: {
'background-color': '#fff',
'background-color': '#ffffff',
},
button: {
'border-radius': {
@@ -170,8 +147,8 @@ const config = {
danger: {
'color-hover': 'white',
background: {
color: 'var(--c--theme--colors--danger-600)',
'color-hover': '#FF2725',
color: 'var(--c--theme--colors--danger-400)',
'color-hover': 'var(--c--theme--colors--danger-500)',
'color-disabled': 'var(--c--theme--colors--danger-100)',
},
},
@@ -201,9 +178,7 @@ const config = {
color: 'var(--c--theme--colors--primary-text)',
'color-disabled': 'var(--c--theme--colors--greyscale-600)',
background: {
color: 'var(--c--theme--colors--primary-100)',
'color-hover': 'var(--c--theme--colors--primary-300)',
'color-active': 'var(--c--theme--colors--primary-100)',
'color-hover': 'var(--c--theme--colors--primary-100)',
'color-disabled': 'var(--c--theme--colors--greyscale-200)',
},
},
@@ -222,19 +197,19 @@ const config = {
dsfr: {
theme: {
colors: {
'card-border': '#E5E5E5',
'card-border': '#ededed',
'primary-text': '#000091',
'primary-100': '#ECECFE',
'primary-100': '#f5f5fe',
'primary-150': '#F4F4FD',
'primary-200': '#E3E3FD',
'primary-300': '#CACAFB',
'primary-400': '#8585F6',
'primary-500': '#6A6AF4',
'primary-600': '#313178',
'primary-200': '#ececfe',
'primary-300': '#e3e3fd',
'primary-400': '#cacafb',
'primary-500': '#6a6af4',
'primary-600': '#000091',
'primary-700': '#272747',
'primary-800': '#000091',
'primary-900': '#21213F',
'secondary-text': '#fff',
'primary-800': '#21213f',
'primary-900': '#1c1a36',
'secondary-text': '#FFFFFF',
'secondary-100': '#fee9ea',
'secondary-200': '#fedfdf',
'secondary-300': '#fdbfbf',
@@ -245,22 +220,16 @@ const config = {
'secondary-800': '#341f1f',
'secondary-900': '#2b1919',
'greyscale-text': '#303C4B',
'greyscale-000': '#fff',
'greyscale-050': '#F6F6F6',
'greyscale-100': '#eee',
'greyscale-200': '#E5E5E5',
'greyscale-250': '#ddd',
'greyscale-300': '#CECECE',
'greyscale-350': '#ddd',
'greyscale-400': '#929292',
'greyscale-500': '#7C7C7C',
'greyscale-600': '#666666',
'greyscale-700': '#3A3A3A',
'greyscale-750': '#353535',
'greyscale-800': '#2A2A2A',
'greyscale-900': '#242424',
'greyscale-950': '#1E1E1E',
'greyscale-1000': '#161616',
'greyscale-000': '#f6f6f6',
'greyscale-100': '#eeeeee',
'greyscale-200': '#e5e5e5',
'greyscale-300': '#e1e1e1',
'greyscale-400': '#dddddd',
'greyscale-500': '#cecece',
'greyscale-600': '#7b7b7b',
'greyscale-700': '#666666',
'greyscale-800': '#2a2a2a',
'greyscale-900': '#1e1e1e',
'success-text': '#1f8d49',
'success-100': '#dffee6',
'success-200': '#b8fec9',
@@ -272,15 +241,15 @@ const config = {
'success-800': '#1e2e22',
'success-900': '#19281d',
'info-text': '#0078f3',
'info-100': '#E8EDFF',
'info-200': '#DDE5FF',
'info-300': '#BCCDFF',
'info-400': '#518FFF',
'info-500': '#0078F3',
'info-600': '#0063CB',
'info-700': '#273961',
'info-800': '#222A3F',
'info-900': '#1D2437',
'info-100': '#f4f6ff',
'info-200': '#e8edff',
'info-300': '#dde5ff',
'info-400': '#bdcdff',
'info-500': '#0078f3',
'info-600': '#0063cb',
'info-700': '#f4f6ff',
'info-800': '#222a3f',
'info-900': '#1d2437',
'warning-text': '#d64d00',
'warning-100': '#fff4f3',
'warning-200': '#ffe9e6',
@@ -291,16 +260,16 @@ const config = {
'warning-700': '#5e2c21',
'warning-800': '#3e241e',
'warning-900': '#361e19',
'danger-text': '#FFF',
'danger-100': '#FFE9E9',
'danger-200': '#FFDDDD',
'danger-300': '#FFBDBD',
'danger-400': '#FF5655',
'danger-500': '#F60700',
'danger-600': '#CE0500',
'danger-700': '#642626',
'danger-text': '#e1000f',
'danger-100': '#fef4f4',
'danger-200': '#fee9e9',
'danger-300': '#fddede',
'danger-400': '#fcbfbf',
'danger-500': '#e1000f',
'danger-600': '#c9191e',
'danger-700': '#642727',
'danger-800': '#412121',
'danger-900': '#391C1C',
'danger-900': '#3a1c1c',
},
font: {
families: {
@@ -319,12 +288,8 @@ const config = {
alert: {
'border-radius': '0',
},
modal: {
'width-small': '342px',
},
button: {
'medium-height': '40px',
'medium-text-height': '40px',
'medium-height': '48px',
'border-radius': '4px',
primary: {
background: {
@@ -332,9 +297,9 @@ const config = {
'color-hover': '#1212ff',
'color-active': '#2323ff',
},
color: '#fff',
'color-hover': '#fff',
'color-active': '#fff',
color: '#ffffff',
'color-hover': '#ffffff',
'color-active': '#ffffff',
},
'primary-text': {
background: {
@@ -356,7 +321,7 @@ const config = {
},
'tertiary-text': {
background: {
'color-hover': 'var(--c--theme--colors--greyscale-100)',
'color-hover': 'var(--c--theme--colors--primary-100)',
},
'color-hover': 'var(--c--theme--colors--primary-text)',
color: 'var(--c--theme--colors--primary-600)',
@@ -398,7 +363,7 @@ const config = {
},
'forms-input': {
'border-radius': '4px',
'background-color': '#fff',
'background-color': '#ffffff',
'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)',
@@ -416,7 +381,7 @@ const config = {
'item-font-size': '14px',
'border-radius': '4px',
'border-radius-hover': '4px',
'background-color': '#fff',
'background-color': '#ffffff',
'border-color': 'var(--c--theme--colors--primary-text)',
'border-color-hover': 'var(--c--theme--colors--primary-text)',
'box-shadow-color': 'var(--c--theme--colors--primary-text)',

View File

@@ -2,4 +2,4 @@
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/pages/building-your-application/configuring/typescript for more information.
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.

View File

@@ -1,6 +1,6 @@
{
"name": "app-impress",
"version": "1.8.2",
"version": "1.10.0",
"private": true,
"scripts": {
"dev": "next dev",
@@ -19,34 +19,33 @@
"@blocknote/mantine": "*",
"@blocknote/react": "*",
"@gouvfr-lasuite/integration": "1.0.2",
"@hocuspocus/provider": "2.14.0",
"@hocuspocus/provider": "2.15.0",
"@openfun/cunningham-react": "2.9.4",
"@sentry/nextjs": "8.40.0",
"@tanstack/react-query": "5.61.3",
"@sentry/nextjs": "8.45.1",
"@tanstack/react-query": "5.62.7",
"crisp-sdk-web": "1.0.25",
"i18next": "24.0.0",
"i18next-browser-languagedetector": "8.0.0",
"idb": "8.0.0",
"i18next": "24.1.0",
"i18next-browser-languagedetector": "8.0.2",
"idb": "8.0.1",
"lodash": "4.17.21",
"luxon": "3.5.0",
"next": "15.0.3",
"next": "15.1.0",
"react": "*",
"react-aria-components": "1.5.0",
"react-dom": "*",
"react-i18next": "15.1.1",
"react-intersection-observer": "9.13.1",
"react-select": "5.8.3",
"react-i18next": "15.2.0",
"react-select": "5.9.0",
"styled-components": "6.1.13",
"y-protocols": "1.0.6",
"yjs": "*",
"zustand": "5.0.1"
"zustand": "5.0.2"
},
"devDependencies": {
"@svgr/webpack": "8.1.0",
"@tanstack/react-query-devtools": "5.61.3",
"@tanstack/react-query-devtools": "5.62.7",
"@testing-library/dom": "10.4.0",
"@testing-library/jest-dom": "6.6.3",
"@testing-library/react": "16.0.1",
"@testing-library/react": "16.1.0",
"@testing-library/user-event": "14.5.2",
"@types/jest": "29.5.14",
"@types/lodash": "4.17.13",
@@ -55,18 +54,18 @@
"@types/react": "18.3.12",
"@types/react-dom": "*",
"cross-env": "*",
"dotenv": "16.4.5",
"dotenv": "16.4.7",
"eslint-config-impress": "*",
"fetch-mock": "9.11.0",
"jest": "29.7.0",
"jest-environment-jsdom": "29.7.0",
"node-fetch": "2.7.0",
"prettier": "3.3.3",
"stylelint": "16.10.0",
"prettier": "3.4.2",
"stylelint": "16.12.0",
"stylelint-config-standard": "36.0.1",
"stylelint-prettier": "5.0.2",
"typescript": "*",
"webpack": "5.96.1",
"webpack": "5.97.1",
"workbox-webpack-plugin": "7.1.0"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -0,0 +1,32 @@
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import { AppWrapper } from '@/tests/utils';
import Page from '../pages';
jest.mock('next/router', () => ({
useRouter() {
return {
push: jest.fn(),
};
},
}));
jest.mock('@sentry/nextjs', () => ({
captureException: jest.fn(),
captureMessage: jest.fn(),
setUser: jest.fn(),
}));
describe('Page', () => {
it('checks Page rendering', () => {
render(<Page />, { wrapper: AppWrapper });
expect(
screen.getByRole('button', {
name: /Create a new document/i,
}),
).toBeInTheDocument();
});
});

View File

@@ -1,13 +1,9 @@
import { forwardRef } from 'react';
import { ComponentPropsWithRef, forwardRef } from 'react';
import { css } from 'styled-components';
import { Box, BoxType } from './Box';
export type BoxButtonType = BoxType & {
disabled?: boolean;
};
/**
export type BoxButtonType = ComponentPropsWithRef<typeof BoxButton>;
/**
* Styleless button that extends the Box component.
@@ -22,7 +18,7 @@ export type BoxButtonType = BoxType & {
* </BoxButton>
* ```
*/
const BoxButton = forwardRef<HTMLDivElement, BoxButtonType>(
const BoxButton = forwardRef<HTMLDivElement, BoxType>(
({ $css, ...props }, ref) => {
return (
<Box
@@ -32,24 +28,14 @@ const BoxButton = forwardRef<HTMLDivElement, BoxButtonType>(
$margin="none"
$padding="none"
$css={css`
cursor: ${props.disabled ? 'not-allowed' : 'pointer'};
cursor: pointer;
border: none;
outline: none;
transition: all 0.2s ease-in-out;
font-family: inherit;
color: ${props.disabled
? 'var(--c--theme--colors--greyscale-400) !important'
: 'inherit'};
${$css || ''}
`}
{...props}
onClick={(event: React.MouseEvent<HTMLDivElement>) => {
if (props.disabled) {
return;
}
props.onClick?.(event);
}}
/>
);
},

View File

@@ -17,7 +17,8 @@ export const Card = ({
$background="white"
$radius="4px"
$css={css`
border: 1px solid ${colorsTokens()['greyscale-200']};
box-shadow: 2px 2px 5px ${colorsTokens()['greyscale-300']};
border: 1px solid ${colorsTokens()['card-border']};
${$css}
`}
{...props}

View File

@@ -1,4 +1,9 @@
import { PropsWithChildren, ReactNode, useEffect, useState } from 'react';
import React, {
PropsWithChildren,
ReactNode,
useEffect,
useState,
} from 'react';
import { Button, DialogTrigger, Popover } from 'react-aria-components';
import styled from 'styled-components';
@@ -6,7 +11,7 @@ const StyledPopover = styled(Popover)`
background-color: white;
border-radius: 4px;
box-shadow: 1px 1px 5px rgba(0, 0, 0, 0.1);
padding: 0.5rem;
border: 1px solid #dddddd;
opacity: 0;
transition: opacity 0.2s ease-in-out;
@@ -24,7 +29,7 @@ const StyledButton = styled(Button)`
text-wrap: nowrap;
`;
export interface DropButtonProps {
interface DropButtonProps {
button: ReactNode;
isOpen?: boolean;
onOpenChange?: (isOpen: boolean) => void;

View File

@@ -1,117 +0,0 @@
import { PropsWithChildren, useState } from 'react';
import { css } from 'styled-components';
import { Box, BoxButton, BoxProps, DropButton, Icon } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
export type DropdownMenuOption = {
icon?: string;
label: string;
testId?: string;
callback?: () => void | Promise<unknown>;
danger?: boolean;
disabled?: boolean;
show?: boolean;
};
export type DropdownMenuProps = {
options: DropdownMenuOption[];
showArrow?: boolean;
arrowCss?: BoxProps['$css'];
};
export const DropdownMenu = ({
options,
children,
showArrow = false,
arrowCss,
}: PropsWithChildren<DropdownMenuProps>) => {
const theme = useCunninghamTheme();
const spacings = theme.spacingsTokens();
const colors = theme.colorsTokens();
const [isOpen, setIsOpen] = useState(false);
const onOpenChange = (isOpen: boolean) => {
setIsOpen(isOpen);
};
return (
<DropButton
isOpen={isOpen}
onOpenChange={onOpenChange}
button={
showArrow ? (
<Box>
<div>{children}</div>
<Icon
$css={
arrowCss ??
css`
color: var(--c--theme--colors--primary-600);
`
}
iconName={isOpen ? 'arrow_drop_up' : 'arrow_drop_down'}
/>
</Box>
) : (
children
)
}
>
<Box>
{options.map((option, index) => {
if (option.show !== undefined && !option.show) {
return;
}
const isDisabled = option.disabled !== undefined && option.disabled;
return (
<BoxButton
data-testid={option.testId}
$direction="row"
disabled={isDisabled}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onOpenChange?.(false);
void option.callback?.();
}}
key={option.label}
$align="center"
$background={colors['greyscale-000']}
$color={colors['primary-600']}
$padding={{ vertical: 'xs', horizontal: 'base' }}
$width="100%"
$gap={spacings['base']}
$css={css`
border: none;
font-size: var(--c--theme--font--sizes--sm);
color: var(--c--theme--colors--primary-600);
font-weight: 500;
cursor: ${isDisabled ? 'not-allowed' : 'pointer'};
user-select: none;
border-bottom: ${index !== options.length - 1
? `1px solid var(--c--theme--colors--greyscale-200)`
: 'none'};
&:hover {
background-color: var(--c--theme--colors--greyscale-050);
}
`}
>
{option.icon && (
<Icon
$size="20px"
$theme={!isDisabled ? 'primary' : 'greyscale'}
$variation={!isDisabled ? '600' : '400'}
iconName={option.icon}
/>
)}
{option.label}
</BoxButton>
);
})}
</Box>
</DropButton>
);
};

View File

@@ -1,19 +1,6 @@
import { css } from 'styled-components';
import { Text, TextType } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
type IconProps = TextType & {
iconName: string;
};
export const Icon = ({ iconName, ...textProps }: IconProps) => {
return (
<Text $isMaterialIcon {...textProps}>
{iconName}
</Text>
);
};
interface IconBGProps extends TextType {
iconName: string;
}
@@ -42,21 +29,23 @@ export const IconBG = ({ iconName, ...textProps }: IconBGProps) => {
);
};
type IconOptionsProps = TextType & {
isHorizontal?: boolean;
};
interface IconOptionsProps {
isOpen: boolean;
'aria-label': string;
}
export const IconOptions = ({ isHorizontal, ...props }: IconOptionsProps) => {
export const IconOptions = ({ isOpen, ...props }: IconOptionsProps) => {
return (
<Text
{...props}
aria-label={props['aria-label']}
$isMaterialIcon
$css={css`
$css={`
transition: all 0.3s ease-in-out;
transform: rotate(${isOpen ? '90' : '0'}deg);
user-select: none;
${props.$css}
`}
>
{isHorizontal ? 'more_horiz' : 'more_vert'}
more_vert
</Text>
);
};

View File

@@ -1,16 +1,12 @@
import { Button } from '@openfun/cunningham-react';
import { PropsWithChildren } from 'react';
import { useTranslation } from 'react-i18next';
import { InView } from 'react-intersection-observer';
import { PropsWithChildren, useEffect, useRef } from 'react';
import { Box, BoxType, Icon } from '@/components';
import { Box, BoxType } from '@/components';
interface InfiniteScrollProps extends BoxType {
hasMore: boolean;
isLoading: boolean;
next: () => void;
scrollContainer?: HTMLElement | null;
buttonLabel?: string;
scrollContainer: HTMLElement | null;
}
export const InfiniteScroll = ({
@@ -18,31 +14,42 @@ export const InfiniteScroll = ({
hasMore,
isLoading,
next,
buttonLabel,
scrollContainer,
...boxProps
}: PropsWithChildren<InfiniteScrollProps>) => {
const { t } = useTranslation();
const loadMore = (inView: boolean) => {
if (!inView || isLoading) {
const timeout = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
if (!scrollContainer) {
return;
}
void next();
};
return (
<Box {...boxProps}>
{children}
<InView onChange={loadMore}>
{!isLoading && hasMore && (
<Button
onClick={() => void next()}
color="primary-text"
icon={<Icon iconName="arrow_downward" />}
>
{buttonLabel ?? t('Load more')}
</Button>
)}
</InView>
</Box>
);
const nextHandle = () => {
if (!hasMore || isLoading) {
return;
}
// To not wait until the end of the scroll to load more data
const heightFromBottom = 150;
const { scrollTop, clientHeight, scrollHeight } = scrollContainer;
if (scrollTop + clientHeight >= scrollHeight - heightFromBottom) {
next();
}
};
const handleScroll = () => {
if (timeout.current) {
clearTimeout(timeout.current);
}
timeout.current = setTimeout(nextHandle, 50);
};
scrollContainer.addEventListener('scroll', handleScroll);
return () => {
scrollContainer.removeEventListener('scroll', handleScroll);
};
}, [hasMore, isLoading, next, scrollContainer]);
return <Box {...boxProps}>{children}</Box>;
};

View File

@@ -33,7 +33,6 @@ export interface TextProps extends BoxProps {
| 'greyscale';
$variation?:
| 'text'
| '000'
| '100'
| '200'
| '300'
@@ -42,8 +41,7 @@ export interface TextProps extends BoxProps {
| '600'
| '700'
| '800'
| '900'
| '1000';
| '900';
}
export type TextType = ComponentPropsWithRef<typeof Text>;

View File

@@ -17,8 +17,8 @@ describe('<Box />', () => {
);
expect(screen.getByText('My Box')).toHaveStyle(`
padding-left: 2.5rem;
padding-right: 2.5rem;
padding-left: 4rem;
padding-right: 4rem;
padding-top: 3rem;
padding-bottom: 0.5rem;`);
});

View File

@@ -2,11 +2,9 @@ export * from './Box';
export * from './BoxButton';
export * from './Card';
export * from './DropButton';
export * from './DropdownMenu';
export * from './Icon';
export * from './InfiniteScroll';
export * from './Link';
export * from './SideModal';
export * from './separators/SeparatedSection';
export * from './Text';
export * from './TextErrors';

View File

@@ -1,33 +0,0 @@
import { useCunninghamTheme } from '@/cunningham';
import { Box } from '../Box';
export enum SeparatorVariant {
LIGHT = 'light',
DARK = 'dark',
}
type Props = {
variant?: SeparatorVariant;
$withPadding?: boolean;
};
export const HorizontalSeparator = ({
variant = SeparatorVariant.LIGHT,
$withPadding = true,
}: Props) => {
const { colorsTokens } = useCunninghamTheme();
return (
<Box
$height="1px"
$width="100%"
$margin={{ vertical: $withPadding ? 'base' : 'none' }}
$background={
variant === SeparatorVariant.DARK
? '#e5e5e533'
: colorsTokens()['greyscale-100']
}
/>
);
};

View File

@@ -1,32 +0,0 @@
import { PropsWithChildren } from 'react';
import { css } from 'styled-components';
import { useCunninghamTheme } from '@/cunningham';
import { Box } from '../Box';
type Props = {
showSeparator?: boolean;
};
export const SeparatedSection = ({
showSeparator = true,
children,
}: PropsWithChildren<Props>) => {
const theme = useCunninghamTheme();
const colors = theme.colorsTokens();
const spacings = theme.spacingsTokens();
return (
<Box
$css={css`
padding: ${spacings['base']} 0;
${showSeparator &&
css`
border-bottom: 1px solid ${colors?.['greyscale-200']};
`}
`}
>
{children}
</Box>
);
};

View File

@@ -7,7 +7,7 @@ interface ConfigResponse {
LANGUAGES: [string, string][];
LANGUAGE_CODE: string;
ENVIRONMENT: string;
COLLABORATION_SERVER_URL?: string;
COLLABORATION_WS_URL?: string;
CRISP_WEBSITE_ID?: string;
FRONTEND_THEME?: Theme;
MEDIA_BASE_URL?: string;

View File

@@ -8,8 +8,10 @@ export const useCollaborationUrl = (room?: string) => {
}
const base =
conf?.COLLABORATION_SERVER_URL ||
(typeof window !== 'undefined' ? `wss://${window.location.host}/ws` : '');
conf?.COLLABORATION_WS_URL ||
(typeof window !== 'undefined'
? `wss://${window.location.host}/collaboration/ws/`
: '');
return `${base}/${room}`;
return `${base}?room=${room}`;
};

View File

@@ -351,24 +351,6 @@ input:-webkit-autofill:focus {
background-color: transparent;
}
.c__button--nano {
padding: 0 var(--c--theme--spacings--3xs);
gap: var(--c--theme--spacings--3xs);
}
.c__button--nano {
padding: 0 var(--c--theme--spacings--3xs);
gap: var(--c--theme--spacings--3xs);
}
.c__button--nano.c__button--icon-only {
width: auto;
}
.c__button--nano.c__button--icon-only.c__button--full-width {
width: 100%;
}
.c__button--medium {
padding: 0.9rem var(--c--theme--spacings--s);
}
@@ -460,7 +442,6 @@ input:-webkit-autofill:focus {
}
.c__button--tertiary {
background-color: var(--c--components--button--tertiary--background--color);
color: var(--c--components--button--tertiary--color);
border: none;
}
@@ -473,13 +454,6 @@ input:-webkit-autofill:focus {
color: var(--c--components--button--tertiary--color);
}
.c__button--tertiary:active {
background-color: var(
--c--components--button--tertiary--background--color-active
);
color: var(--c--components--button--tertiary--color-active);
}
.c__button--tertiary:disabled {
background-color: var(
--c--components--button--tertiary--background--color-disabled
@@ -537,32 +511,14 @@ input:-webkit-autofill:focus {
box-shadow: none;
}
.c__modal__footer {
margin-top: -1.5rem;
}
.c__modal__close button {
padding: 0px;
font-size: 88px;
width: 28px !important;
height: 28px;
}
.c__modal__close button .material-icons {
padding: 0px;
font-size: 24px;
padding: 1.5rem 1rem;
}
.c__modal--full .c__modal__content {
overflow-y: auto;
}
.c__modal__title {
padding: 0;
font-size: 1.125rem;
margin-bottom: var(--c--theme--spacings--sm);
}
@media screen and (width <= 420px) {
.c__modal__scroller {
padding: 0.7rem;

View File

@@ -13,7 +13,7 @@ export const tokens = {
'secondary-700': '#97A3AE',
'secondary-800': '#757E87',
'secondary-900': '#596067',
'info-text': '#fff',
'info-text': '#FFFFFF',
'info-100': '#EBF2FC',
'info-200': '#8CB5EA',
'info-300': '#5894E1',
@@ -32,7 +32,7 @@ export const tokens = {
'greyscale-700': '#555F6B',
'greyscale-800': '#303C4B',
'greyscale-900': '#0C1A2B',
'greyscale-000': '#fff',
'greyscale-000': '#FFFFFF',
'primary-100': '#EDF5FA',
'primary-200': '#8CB5EA',
'primary-300': '#5894E1',
@@ -69,35 +69,28 @@ export const tokens = {
'danger-700': '#9B0000',
'danger-800': '#780000',
'danger-900': '#5C0000',
'primary-text': '#fff',
'success-text': '#fff',
'warning-text': '#fff',
'danger-text': '#fff',
'primary-text': '#FFFFFF',
'success-text': '#FFFFFF',
'warning-text': '#FFFFFF',
'danger-text': '#FFFFFF',
'card-border': '#ededed',
'primary-bg': '#FAFAFA',
'primary-050': '#F5F5FE',
'primary-150': '#E5EEFA',
'primary-950': '#1B1B35',
'info-150': '#E5EEFA',
'greyscale-1000': '#161616',
},
font: {
sizes: {
h1: '2rem',
h2: '1.75rem',
h3: '1.5rem',
h4: '1.375rem',
h5: '1.25rem',
h6: '1.125rem',
h1: '2.2rem',
h2: '1.7rem',
h3: '1.37rem',
h4: '1.15rem',
h5: '1rem',
h6: '0.87rem',
l: '1rem',
m: '0.8125rem',
s: '0.75rem',
xs: '0.75rem',
sm: '0.875rem',
md: '1rem',
lg: '1.125rem',
ml: '0.938rem',
xl: '1.25rem',
xl: '1.50rem',
t: '0.6875rem',
},
weights: {
@@ -127,7 +120,7 @@ export const tokens = {
},
spacings: {
'0': '0',
xl: '2.5rem',
xl: '4rem',
l: '3rem',
b: '1.625rem',
s: '1rem',
@@ -137,20 +130,6 @@ export const tokens = {
auto: 'auto',
bx: '2.2rem',
full: '100%',
'4xs': '0.125rem',
'3xs': '0.25rem',
'2xs': '0.375rem',
xs: '0.5rem',
sm: '0.75rem',
base: '1rem',
md: '1.5rem',
lg: '2rem',
xxl: '3rem',
xxxl: '3.5rem',
'4xl': '4rem',
'5xl': '4.5rem',
'6xl': '6rem',
'7xl': '7.5rem',
},
transitions: {
'ease-in': 'cubic-bezier(0.32, 0, 0.67, 0)',
@@ -223,7 +202,7 @@ export const tokens = {
focus: 'var(--c--components--forms-select--border-radius)',
},
'font-size': 'var(--c--theme--font--sizes--ml)',
'menu-background-color': '#fff',
'menu-background-color': '#ffffff',
'item-background-color': {
hover: 'var(--c--theme--colors--primary-300)',
},
@@ -244,7 +223,7 @@ export const tokens = {
'border-color-hover': 'var(--c--theme--colors--greyscale-200)',
},
},
modal: { 'background-color': '#fff' },
modal: { 'background-color': '#ffffff' },
button: {
'border-radius': {
active: 'var(--c--components--button--border-radius)',
@@ -264,8 +243,8 @@ export const tokens = {
danger: {
'color-hover': 'white',
background: {
color: 'var(--c--theme--colors--danger-600)',
'color-hover': '#FF2725',
color: 'var(--c--theme--colors--danger-400)',
'color-hover': 'var(--c--theme--colors--danger-500)',
'color-disabled': 'var(--c--theme--colors--danger-100)',
},
},
@@ -291,9 +270,7 @@ export const tokens = {
color: 'var(--c--theme--colors--primary-text)',
'color-disabled': 'var(--c--theme--colors--greyscale-600)',
background: {
color: 'var(--c--theme--colors--primary-100)',
'color-hover': 'var(--c--theme--colors--primary-300)',
'color-active': 'var(--c--theme--colors--primary-100)',
'color-hover': 'var(--c--theme--colors--primary-100)',
'color-disabled': 'var(--c--theme--colors--greyscale-200)',
},
},
@@ -357,19 +334,19 @@ export const tokens = {
dsfr: {
theme: {
colors: {
'card-border': '#E5E5E5',
'card-border': '#ededed',
'primary-text': '#000091',
'primary-100': '#ECECFE',
'primary-100': '#f5f5fe',
'primary-150': '#F4F4FD',
'primary-200': '#E3E3FD',
'primary-300': '#CACAFB',
'primary-400': '#8585F6',
'primary-500': '#6A6AF4',
'primary-600': '#313178',
'primary-200': '#ececfe',
'primary-300': '#e3e3fd',
'primary-400': '#cacafb',
'primary-500': '#6a6af4',
'primary-600': '#000091',
'primary-700': '#272747',
'primary-800': '#000091',
'primary-900': '#21213F',
'secondary-text': '#fff',
'primary-800': '#21213f',
'primary-900': '#1c1a36',
'secondary-text': '#FFFFFF',
'secondary-100': '#fee9ea',
'secondary-200': '#fedfdf',
'secondary-300': '#fdbfbf',
@@ -380,22 +357,16 @@ export const tokens = {
'secondary-800': '#341f1f',
'secondary-900': '#2b1919',
'greyscale-text': '#303C4B',
'greyscale-000': '#fff',
'greyscale-050': '#F6F6F6',
'greyscale-100': '#eee',
'greyscale-200': '#E5E5E5',
'greyscale-250': '#ddd',
'greyscale-300': '#CECECE',
'greyscale-350': '#ddd',
'greyscale-400': '#929292',
'greyscale-500': '#7C7C7C',
'greyscale-600': '#666666',
'greyscale-700': '#3A3A3A',
'greyscale-750': '#353535',
'greyscale-800': '#2A2A2A',
'greyscale-900': '#242424',
'greyscale-950': '#1E1E1E',
'greyscale-1000': '#161616',
'greyscale-000': '#f6f6f6',
'greyscale-100': '#eeeeee',
'greyscale-200': '#e5e5e5',
'greyscale-300': '#e1e1e1',
'greyscale-400': '#dddddd',
'greyscale-500': '#cecece',
'greyscale-600': '#7b7b7b',
'greyscale-700': '#666666',
'greyscale-800': '#2a2a2a',
'greyscale-900': '#1e1e1e',
'success-text': '#1f8d49',
'success-100': '#dffee6',
'success-200': '#b8fec9',
@@ -407,15 +378,15 @@ export const tokens = {
'success-800': '#1e2e22',
'success-900': '#19281d',
'info-text': '#0078f3',
'info-100': '#E8EDFF',
'info-200': '#DDE5FF',
'info-300': '#BCCDFF',
'info-400': '#518FFF',
'info-500': '#0078F3',
'info-600': '#0063CB',
'info-700': '#273961',
'info-800': '#222A3F',
'info-900': '#1D2437',
'info-100': '#f4f6ff',
'info-200': '#e8edff',
'info-300': '#dde5ff',
'info-400': '#bdcdff',
'info-500': '#0078f3',
'info-600': '#0063cb',
'info-700': '#f4f6ff',
'info-800': '#222a3f',
'info-900': '#1d2437',
'warning-text': '#d64d00',
'warning-100': '#fff4f3',
'warning-200': '#ffe9e6',
@@ -426,16 +397,16 @@ export const tokens = {
'warning-700': '#5e2c21',
'warning-800': '#3e241e',
'warning-900': '#361e19',
'danger-text': '#FFF',
'danger-100': '#FFE9E9',
'danger-200': '#FFDDDD',
'danger-300': '#FFBDBD',
'danger-400': '#FF5655',
'danger-500': '#F60700',
'danger-600': '#CE0500',
'danger-700': '#642626',
'danger-text': '#e1000f',
'danger-100': '#fef4f4',
'danger-200': '#fee9e9',
'danger-300': '#fddede',
'danger-400': '#fcbfbf',
'danger-500': '#e1000f',
'danger-600': '#c9191e',
'danger-700': '#642727',
'danger-800': '#412121',
'danger-900': '#391C1C',
'danger-900': '#3a1c1c',
},
font: { families: { accent: 'Marianne', base: 'Marianne' } },
logo: {
@@ -447,10 +418,8 @@ export const tokens = {
},
components: {
alert: { 'border-radius': '0' },
modal: { 'width-small': '342px' },
button: {
'medium-height': '40px',
'medium-text-height': '40px',
'medium-height': '48px',
'border-radius': '4px',
primary: {
background: {
@@ -458,9 +427,9 @@ export const tokens = {
'color-hover': '#1212ff',
'color-active': '#2323ff',
},
color: '#fff',
'color-hover': '#fff',
'color-active': '#fff',
color: '#ffffff',
'color-hover': '#ffffff',
'color-active': '#ffffff',
},
'primary-text': {
background: {
@@ -479,7 +448,7 @@ export const tokens = {
},
'tertiary-text': {
background: {
'color-hover': 'var(--c--theme--colors--greyscale-100)',
'color-hover': 'var(--c--theme--colors--primary-100)',
},
'color-hover': 'var(--c--theme--colors--primary-text)',
color: 'var(--c--theme--colors--primary-600)',
@@ -517,7 +486,7 @@ export const tokens = {
},
'forms-input': {
'border-radius': '4px',
'background-color': '#fff',
'background-color': '#ffffff',
'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)',
@@ -533,7 +502,7 @@ export const tokens = {
'item-font-size': '14px',
'border-radius': '4px',
'border-radius-hover': '4px',
'background-color': '#fff',
'background-color': '#ffffff',
'border-color': 'var(--c--theme--colors--primary-text)',
'border-color-hover': 'var(--c--theme--colors--primary-text)',
'box-shadow-color': 'var(--c--theme--colors--primary-text)',

View File

@@ -5,8 +5,6 @@ import { tokens } from './cunningham-tokens';
type Tokens = typeof tokens.themes.default & Partial<typeof tokens.themes.dsfr>;
type ColorsTokens = Tokens['theme']['colors'];
type FontSizesTokens = Tokens['theme']['font']['sizes'];
type SpacingsTokens = Tokens['theme']['spacings'];
type ComponentTokens = Tokens['components'];
export type Theme = keyof typeof tokens.themes;
@@ -15,8 +13,6 @@ interface AuthStore {
setTheme: (theme: Theme) => void;
themeTokens: () => Partial<Tokens['theme']>;
colorsTokens: () => Partial<ColorsTokens>;
fontSizesTokens: () => Partial<FontSizesTokens>;
spacingsTokens: () => Partial<SpacingsTokens>;
componentTokens: () => ComponentTokens;
}
@@ -32,8 +28,6 @@ export const useCunninghamTheme = create<AuthStore>((set, get) => {
themeTokens: () => currentTheme().theme,
colorsTokens: () => currentTheme().theme.colors,
componentTokens: () => currentTheme().components,
spacingsTokens: () => currentTheme().theme.spacings,
fontSizesTokens: () => currentTheme().theme.font.sizes,
setTheme: (theme: Theme) => {
set({ theme });
},

View File

@@ -281,10 +281,14 @@ const AIMenuItem = ({
const handleAIError = useHandleAIError();
const handleAIAction = async () => {
const selectedBlocks = editor.getSelection()?.blocks;
let selectedBlocks = editor.getSelection()?.blocks;
if (!selectedBlocks || selectedBlocks.length === 0) {
return;
selectedBlocks = [editor.getTextCursorPosition().block];
if (!selectedBlocks || selectedBlocks.length === 0) {
return;
}
}
const markdown = await editor.blocksToMarkdownLossy(selectedBlocks);

View File

@@ -4,50 +4,30 @@ import { BlockNoteView } from '@blocknote/mantine';
import '@blocknote/mantine/style.css';
import { useCreateBlockNote } from '@blocknote/react';
import { HocuspocusProvider } from '@hocuspocus/provider';
import { useEffect } from 'react';
import React, { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import * as Y from 'yjs';
import { Box, TextErrors } from '@/components';
import { useAuthStore } from '@/core/auth';
import { Doc } from '@/features/docs/doc-management';
import { Doc, Role, currentDocRole } from '@/features/docs/doc-management';
import { useUploadFile } from '../hook';
import { useHeadings } from '../hook/useHeadings';
import useSaveDoc from '../hook/useSaveDoc';
import { useEditorStore, useHeadingStore } from '../stores';
import { useEditorStore } from '../stores';
import { randomColor } from '../utils';
import { BlockNoteToolbar } from './BlockNoteToolbar';
const cssEditor = (readonly: boolean) => css`
color: red;
&,
& > .bn-container,
& .ProseMirror {
height: 100%;
a {
color: var(--c--theme--colors--greyscale-500);
cursor: pointer;
}
.bn-block-group
.bn-block-group
.bn-block-outer:not([data-prev-depth-changed]):before {
border-left: none;
}
}
.bn-block-outer:not(:first-child) {
&:has(h1) {
padding-top: 32px;
}
&:has(h2) {
padding-top: 24px;
}
&:has(h3) {
padding-top: 16px;
}
}
const cssEditor = (readonly: boolean) => `
&, & > .bn-container, & .ProseMirror {
height:100%
};
& .bn-editor {
padding-right: 30px;
${readonly && `padding-left: 30px;`}
};
& .bn-inline-content code {
background-color: gainsboro;
padding: 2px;
@@ -58,14 +38,14 @@ const cssEditor = (readonly: boolean) => css`
padding-left: 40px;
padding-right: 10px;
${readonly && `padding-left: 10px;`}
};
.bn-side-menu[data-block-type=heading][data-level="1"] {
height: 46px;
}
.bn-side-menu[data-block-type='heading'][data-level='1'] {
height: 46px;
}
.bn-side-menu[data-block-type='heading'][data-level='2'] {
.bn-side-menu[data-block-type=heading][data-level="2"] {
height: 40px;
}
.bn-side-menu[data-block-type='heading'][data-level='3'] {
.bn-side-menu[data-block-type=heading][data-level="3"] {
height: 40px;
}
& .bn-editor h1 {
@@ -77,7 +57,7 @@ const cssEditor = (readonly: boolean) => css`
& .bn-editor h3 {
font-size: 1.2rem;
}
.bn-block-content[data-is-empty-and-focused][data-content-type='paragraph']
.bn-block-content[data-is-empty-and-focused][data-content-type="paragraph"]
.bn-inline-content:has(> .ProseMirror-trailingBreak:only-child)::before {
font-size: 14px;
}
@@ -87,30 +67,23 @@ const cssEditor = (readonly: boolean) => css`
interface BlockNoteEditorProps {
doc: Doc;
provider: HocuspocusProvider;
storeId: string;
}
export const BlockNoteEditor = ({
doc,
provider,
storeId,
}: BlockNoteEditorProps) => {
const isVersion = doc.id !== storeId;
export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
const { userData } = useAuthStore();
const { setEditor } = useEditorStore();
const { t } = useTranslation();
const readOnly = !doc.abilities.partial_update || isVersion;
const readOnly = !doc.abilities.partial_update;
useSaveDoc(doc.id, provider.document, !readOnly);
const { setHeadings, resetHeadings } = useHeadingStore();
const { i18n } = useTranslation();
const lang = i18n.language;
const { uploadFile, errorAttachment } = useUploadFile(doc.id);
const collabName =
userData?.full_name ||
userData?.email ||
(readOnly ? 'Reader' : t('Anonymous'));
const collabName = readOnly
? 'Reader'
: userData?.full_name || userData?.email || t('Anonymous');
const editor = useCreateBlockNote(
{
@@ -152,6 +125,24 @@ export const BlockNoteEditor = ({
},
[collabName, lang, provider, uploadFile],
);
useHeadings(editor);
/**
* With the collaboration it gets complicated to create the initial block
* better to let Blocknote manage, then we update the block with the content.
*/
useEffect(() => {
if (doc.content || currentDocRole(doc.abilities) !== Role.OWNER) {
return;
}
setTimeout(() => {
editor.updateBlock(editor.document[0], {
type: 'heading',
content: '',
});
}, 100);
}, [editor, doc.content, doc.abilities]);
useEffect(() => {
setEditor(editor);
@@ -161,18 +152,6 @@ export const BlockNoteEditor = ({
};
}, [setEditor, editor]);
useEffect(() => {
setHeadings(editor);
editor?.onEditorContentChange(() => {
setHeadings(editor);
});
return () => {
resetHeadings();
};
}, [editor, resetHeadings, setHeadings]);
return (
<Box $css={cssEditor(readOnly)}>
{errorAttachment && (
@@ -196,3 +175,42 @@ export const BlockNoteEditor = ({
</Box>
);
};
interface BlockNoteEditorVersionProps {
initialContent: Y.XmlFragment;
}
export const BlockNoteEditorVersion = ({
initialContent,
}: BlockNoteEditorVersionProps) => {
const readOnly = true;
const { setEditor } = useEditorStore();
const editor = useCreateBlockNote(
{
collaboration: {
fragment: initialContent,
user: {
name: '',
color: '',
},
provider: undefined,
},
},
[initialContent],
);
useHeadings(editor);
useEffect(() => {
setEditor(editor);
return () => {
setEditor(undefined);
};
}, [setEditor, editor]);
return (
<Box $css={cssEditor(readOnly)}>
<BlockNoteView editor={editor} editable={!readOnly} theme="light" />
</Box>
);
};

View File

@@ -2,27 +2,29 @@ import '@blocknote/mantine/style.css';
import {
FormattingToolbar,
FormattingToolbarController,
FormattingToolbarProps,
getFormattingToolbarItems,
} from '@blocknote/react';
import React from 'react';
import React, { useCallback } from 'react';
import { AIGroupButton } from './AIButton';
import { MarkdownButton } from './MarkdownButton';
export const BlockNoteToolbar = () => {
return (
<FormattingToolbarController
formattingToolbar={({ blockTypeSelectItems }) => (
<FormattingToolbar>
{getFormattingToolbarItems(blockTypeSelectItems)}
const formattingToolbar = useCallback(
({ blockTypeSelectItems }: FormattingToolbarProps) => (
<FormattingToolbar>
{getFormattingToolbarItems(blockTypeSelectItems)}
{/* Extra button to do some AI powered actions */}
<AIGroupButton key="AIButton" />
{/* Extra button to do some AI powered actions */}
<AIGroupButton key="AIButton" />
{/* Extra button to convert from markdown to json */}
<MarkdownButton key="customButton" />
</FormattingToolbar>
)}
/>
{/* Extra button to convert from markdown to json */}
<MarkdownButton key="customButton" />
</FormattingToolbar>
),
[],
);
return <FormattingToolbarController formattingToolbar={formattingToolbar} />;
};

View File

@@ -1,34 +1,35 @@
import { Alert, Loader, VariantType } from '@openfun/cunningham-react';
import { useRouter } from 'next/router';
import { useEffect } from 'react';
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import * as Y from 'yjs';
import { Box, Text, TextErrors } from '@/components';
import { useCollaborationUrl } from '@/core';
import { Box, Card, Text, TextErrors } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { DocHeader } from '@/features/docs/doc-header';
import { Doc, useDocStore } from '@/features/docs/doc-management';
import { TableContent } from '@/features/docs/doc-table-content/';
import {
Doc,
base64ToBlocknoteXmlFragment,
useDocStore,
} from '@/features/docs/doc-management';
import { Versions, useDocVersion } from '@/features/docs/doc-versioning/';
import { useResponsiveStore } from '@/stores';
import { DocVersionHeader } from '../../doc-header/components/DocVersionHeader';
import { useHeadingStore } from '../stores';
import { BlockNoteEditor } from './BlockNoteEditor';
import { BlockNoteEditor, BlockNoteEditorVersion } from './BlockNoteEditor';
import { IconOpenPanelEditor, PanelEditor } from './PanelEditor';
interface DocEditorProps {
doc: Doc;
versionId?: Versions['version_id'];
}
export const DocEditor = ({ doc, versionId }: DocEditorProps) => {
export const DocEditor = ({ doc }: DocEditorProps) => {
const {
query: { versionId },
} = useRouter();
const { t } = useTranslation();
const { headings } = useHeadingStore();
const { isDesktop } = useResponsiveStore();
const { isMobile } = useResponsiveStore();
const isVersion = !!versionId && typeof versionId === 'string';
const isVersion = versionId && typeof versionId === 'string';
const { colorsTokens } = useCunninghamTheme();
@@ -41,84 +42,77 @@ export const DocEditor = ({ doc, versionId }: DocEditorProps) => {
return (
<>
{isDesktop && !isVersion && (
<Box
$position="absolute"
$css={css`
top: 72px;
right: 20px;
`}
>
<TableContent headings={headings} />
<DocHeader doc={doc} />
{!doc.abilities.partial_update && (
<Box $margin={{ all: 'small', top: 'none' }}>
<Alert type={VariantType.WARNING}>
{t(`Read only, you cannot edit this document.`)}
</Alert>
</Box>
)}
<Box $maxWidth="868px" $width="100%" $height="100%">
<Box $padding={{ horizontal: '54px' }}>
{isVersion ? (
<DocVersionHeader title={doc.title} />
) : (
<DocHeader doc={doc} />
)}
{isVersion && (
<Box $margin={{ all: 'small', top: 'none' }}>
<Alert type={VariantType.WARNING}>
{t(`Read only, you cannot edit document versions.`)}
</Alert>
</Box>
{!doc.abilities.partial_update && (
<Box $width="100%" $margin={{ all: 'small', top: 'none' }}>
<Alert type={VariantType.WARNING}>
{t(`Read only, you cannot edit this document.`)}
</Alert>
</Box>
)}
<Box
$background={colorsTokens()['primary-bg']}
$direction="row"
$width="100%"
$css="overflow-x: clip; flex: 1;"
)}
<Box
$background={colorsTokens()['primary-bg']}
$height="100%"
$direction="row"
$margin={{ all: isMobile ? 'tiny' : 'small', top: 'none' }}
$css="overflow-x: clip;"
$position="relative"
>
<Card
$padding={isMobile ? 'small' : 'big'}
$css="flex:1;"
$overflow="auto"
$position="relative"
>
<Box $css="flex:1;" $overflow="auto" $position="relative">
{isVersion ? (
<DocVersionEditor doc={doc} versionId={versionId} />
) : (
<BlockNoteEditor doc={doc} storeId={doc.id} provider={provider} />
)}
</Box>
</Box>
{isVersion ? (
<DocVersionEditor docId={doc.id} versionId={versionId} />
) : (
<BlockNoteEditor doc={doc} provider={provider} />
)}
{!isMobile && <IconOpenPanelEditor />}
</Card>
<PanelEditor doc={doc} />
</Box>
</>
);
};
interface DocVersionEditorProps {
doc: Doc;
docId: Doc['id'];
versionId: Versions['version_id'];
}
export const DocVersionEditor = ({ doc, versionId }: DocVersionEditorProps) => {
export const DocVersionEditor = ({
docId,
versionId,
}: DocVersionEditorProps) => {
const {
data: version,
isLoading,
isError,
error,
} = useDocVersion({
docId: doc.id,
docId,
versionId,
});
const { createProvider, providers } = useDocStore();
const collaborationUrl = useCollaborationUrl(versionId);
const { replace } = useRouter();
const [initialContent, setInitialContent] = useState<Y.XmlFragment>();
useEffect(() => {
if (!version?.id || !collaborationUrl) {
if (!version?.content) {
return;
}
const provider = providers?.[version.id];
if (!provider || provider.document.guid !== version.id) {
createProvider(collaborationUrl, version.id, version.content);
}
}, [createProvider, providers, version, collaborationUrl]);
setInitialContent(base64ToBlocknoteXmlFragment(version.content));
}, [version?.content]);
if (isError && error) {
if (error.status === 404) {
@@ -142,7 +136,7 @@ export const DocVersionEditor = ({ doc, versionId }: DocVersionEditorProps) => {
);
}
if (isLoading || !version) {
if (isLoading || !version || !initialContent) {
return (
<Box $align="center" $justify="center" $height="100%">
<Loader />
@@ -150,11 +144,5 @@ export const DocVersionEditor = ({ doc, versionId }: DocVersionEditorProps) => {
);
}
const provider = providers?.[version.id];
if (!provider) {
return null;
}
return <BlockNoteEditor doc={doc} storeId={version.id} provider={provider} />;
return <BlockNoteEditorVersion initialContent={initialContent} />;
};

View File

@@ -46,7 +46,11 @@ export function MarkdownButton() {
const { t } = useTranslation();
const handleConvertMarkdown = () => {
const blocks = editor.getSelection()?.blocks;
let blocks = editor.getSelection()?.blocks;
if (!blocks || blocks.length === 0) {
blocks = [editor.getTextCursorPosition().block];
}
forEach(blocks, async (block) => {
if (!isBlock(block as unknown as Block)) {

View File

@@ -0,0 +1,197 @@
import React, { PropsWithChildren, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, BoxButton, Card, IconBG, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Doc } from '@/features/docs/doc-management';
import { TableContent } from '@/features/docs/doc-table-content';
import { VersionList } from '@/features/docs/doc-versioning';
import { useResponsiveStore } from '@/stores';
import { useHeadingStore, usePanelEditorStore } from '../stores';
interface PanelProps {
doc: Doc;
}
export const PanelEditor = ({ doc }: PropsWithChildren<PanelProps>) => {
const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
const { isMobile } = useResponsiveStore();
const { isPanelTableContentOpen, setIsPanelTableContentOpen, isPanelOpen } =
usePanelEditorStore();
return (
<Card
$width="100%"
$maxWidth="20rem"
$position={isMobile ? 'absolute' : 'sticky'}
$height="100%"
$hasTransition="slow"
$css={`
top: 0vh;
right: 0;
transform: translateX(0%);
flex: 1;
margin-left: 1rem;
${
!isPanelOpen &&
`
transform: translateX(200%);
opacity: 0;
flex: 0;
margin-left: 0rem;
max-width: 0rem;
`
}
`}
aria-label={t('Document panel')}
aria-hidden={!isPanelOpen}
>
<Box
$overflow="inherit"
$position="sticky"
$hasTransition="slow"
$css={`
top: 0;
opacity: ${isPanelOpen ? '1' : '0'};
`}
$maxHeight="99vh"
>
{isMobile && <IconOpenPanelEditor />}
<Box
$direction="row"
$justify="space-between"
$align="center"
$position="relative"
$background={colorsTokens()['primary-400']}
$margin={{ bottom: 'tiny' }}
$radius="4px 4px 0 0"
>
<Box
$background="white"
$position="absolute"
$height="100%"
$width={doc.abilities.versions_list ? '50%' : '100%'}
$hasTransition="slow"
$css={`
border-top: 2px solid ${colorsTokens()['primary-600']};
border-radius: 0 4px 0 0;
${
isPanelTableContentOpen
? `
transform: translateX(0);
border-radius: 4px 0 0 0;
`
: `transform: translateX(100%);`
}
`}
/>
<BoxButton
$minWidth={doc.abilities.versions_list ? '50%' : '100%'}
onClick={() => setIsPanelTableContentOpen(true)}
$zIndex={1}
>
<Text
$width="100%"
$weight="bold"
$size="m"
$theme="primary"
$variation="600"
$padding={{ vertical: 'small', horizontal: 'small' }}
>
{t('Table of content')}
</Text>
</BoxButton>
{doc.abilities.versions_list && (
<BoxButton
$minWidth="50%"
onClick={() => setIsPanelTableContentOpen(false)}
$zIndex={1}
>
<Text
$width="100%"
$weight="bold"
$size="m"
$theme="primary"
$variation="600"
$padding={{ vertical: 'small', horizontal: 'small' }}
>
{t('Versions')}
</Text>
</BoxButton>
)}
</Box>
{isPanelTableContentOpen && <TableContent />}
{!isPanelTableContentOpen && doc.abilities.versions_list && (
<VersionList doc={doc} />
)}
</Box>
</Card>
);
};
export const IconOpenPanelEditor = () => {
const { headings } = useHeadingStore();
const { t } = useTranslation();
const { setIsPanelOpen, isPanelOpen, setIsPanelTableContentOpen } =
usePanelEditorStore();
const [hasBeenOpen, setHasBeenOpen] = useState(isPanelOpen);
const { isMobile } = useResponsiveStore();
const setClosePanel = () => {
setHasBeenOpen(true);
setIsPanelOpen(!isPanelOpen);
};
// Open the panel if there are more than 1 heading
useEffect(() => {
if (headings?.length && headings.length > 1 && !hasBeenOpen && !isMobile) {
setIsPanelTableContentOpen(true);
setIsPanelOpen(true);
setHasBeenOpen(true);
}
}, [
headings,
setIsPanelTableContentOpen,
setIsPanelOpen,
hasBeenOpen,
isMobile,
]);
// If open from the doc header we set the state as well
useEffect(() => {
if (isPanelOpen && !hasBeenOpen) {
setHasBeenOpen(true);
}
}, [hasBeenOpen, isPanelOpen]);
// Close the panel unmount
useEffect(() => {
return () => {
setIsPanelOpen(false);
};
}, [setIsPanelOpen]);
return (
<IconBG
iconName="menu_open"
aria-label={isPanelOpen ? t('Close the panel') : t('Open the panel')}
$background="transparent"
$size="h2"
$zIndex={10}
$hasTransition="slow"
$css={`
cursor: pointer;
right: 0rem;
top: 0.1rem;
transform: rotate(${isPanelOpen ? '180deg' : '0deg'});
user-select: none;
${hasBeenOpen ? 'display:flex;' : 'display: none;'}
`}
$position="absolute"
onClick={setClosePanel}
$radius="2px"
/>
);
};

View File

@@ -0,0 +1,20 @@
import { BlockNoteEditor } from '@blocknote/core';
import { useEffect } from 'react';
import { useHeadingStore } from '../stores';
export const useHeadings = (editor: BlockNoteEditor) => {
const { setHeadings, resetHeadings } = useHeadingStore();
useEffect(() => {
setHeadings(editor);
editor?.onEditorContentChange(() => {
setHeadings(editor);
});
return () => {
resetHeadings();
};
}, [editor, resetHeadings, setHeadings]);
};

View File

@@ -1,18 +1,14 @@
import { DateTime } from 'luxon';
import { Fragment } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, Icon, Text } from '@/components';
import { HorizontalSeparator } from '@/components/separators/HorizontalSeparator';
import { Box, Card, StyledLink, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import {
Doc,
LinkReach,
currentDocRole,
useTrans,
} from '@/features/docs/doc-management';
import { Doc, currentDocRole, useTrans } from '@/features/docs/doc-management';
import { useDate } from '@/hook';
import { useResponsiveStore } from '@/stores';
import { DocTagPublic } from './DocTagPublic';
import { DocTitle } from './DocTitle';
import { DocToolBox } from './DocToolBox';
@@ -21,79 +17,89 @@ interface DocHeaderProps {
}
export const DocHeader = ({ doc }: DocHeaderProps) => {
const { colorsTokens, spacingsTokens } = useCunninghamTheme();
const { isDesktop } = useResponsiveStore();
const spacings = spacingsTokens();
const colors = colorsTokens();
const { colorsTokens } = useCunninghamTheme();
const { t } = useTranslation();
const docIsPublic = doc.link_reach === LinkReach.PUBLIC;
const { formatDate } = useDate();
const { transRole } = useTrans();
const { isMobile, isSmallMobile } = useResponsiveStore();
return (
<>
<Box
$width="100%"
$padding={{ top: 'base' }}
$gap={spacings['base']}
<Card
$margin={isMobile ? 'tiny' : 'small'}
aria-label={t('It is the card information about the document.')}
>
{docIsPublic && (
<Box
$padding={isMobile ? 'tiny' : 'small'}
$direction="row"
$align="center"
>
<StyledLink href="/">
<Text
$isMaterialIcon
$theme="primary"
$variation="600"
$size="2rem"
$css={css`
&:hover {
background-color: ${colorsTokens()['primary-100']};
}
`}
$hasTransition
$radius="5px"
$padding="tiny"
>
home
</Text>
</StyledLink>
<Box
aria-label={t('Public document')}
$color={colors['primary-600']}
$background={colors['primary-100']}
$radius={spacings['3xs']}
$direction="row"
$padding="xs"
$flex={1}
$align="center"
$gap={spacings['3xs']}
$css={css`
border: 1px solid var(--c--theme--colors--primary-300, #e3e3fd);
`}
>
<Icon data-testid="public-icon" iconName="public" />
<Text>{t('Public document')}</Text>
</Box>
)}
<Box $direction="row" $align="center" $width="100%">
$width="1px"
$height="70%"
$background={colorsTokens()['greyscale-100']}
$margin={{ horizontal: 'tiny' }}
/>
<Box
$direction="row"
$justify="space-between"
$css="flex:1;"
$gap="0.5rem 1rem"
$wrap="wrap"
$align="center"
>
<Box $gap={spacings['3xs']}>
<DocTitle doc={doc} />
<Box $direction="row">
{isDesktop && (
<>
<Text $variation="600" $size="s" $weight="bold">
{transRole(currentDocRole(doc.abilities))}&nbsp;·&nbsp;
</Text>
<Text $variation="600" $size="s">
{t('Last update: {{update}}', {
update: DateTime.fromISO(doc.updated_at).toRelative(),
})}
</Text>
</>
)}
{!isDesktop && (
<Text $variation="400" $size="s">
{DateTime.fromISO(doc.updated_at).toRelative()}
</Text>
)}
</Box>
</Box>
<DocTitle doc={doc} />
<DocToolBox doc={doc} />
</Box>
</Box>
</Box>
<HorizontalSeparator $withPadding={true} />
<Box
$direction={isSmallMobile ? 'column' : 'row'}
$align={isSmallMobile ? 'start' : 'center'}
$css="border-top:1px solid #eee"
$padding={{
horizontal: isMobile ? 'tiny' : 'big',
vertical: 'tiny',
}}
$gap="0.5rem 2rem"
$justify="space-between"
$wrap="wrap"
$position="relative"
>
<Box
$direction={isSmallMobile ? 'column' : 'row'}
$align={isSmallMobile ? 'start' : 'center'}
$gap="0.5rem 2rem"
$wrap="wrap"
>
<DocTagPublic doc={doc} />
<Text $size="s" $display="inline">
{t('Created at')} <strong>{formatDate(doc.created_at)}</strong>
</Text>
</Box>
<Text $size="s" $display="inline">
{t('Your role:')}{' '}
<strong>{transRole(currentDocRole(doc.abilities))}</strong>
</Text>
</Box>
</Card>
</>
);
};

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