mirror of
https://github.com/suitenumerique/docs.git
synced 2026-05-06 15:12:27 +02:00
Compare commits
121 Commits
preprod
...
docs-0.0.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62b5797223 | ||
|
|
ce83d8d72d | ||
|
|
33d1f3c151 | ||
|
|
fc4eba2497 | ||
|
|
3e5f27c1d5 | ||
|
|
f2f64f7dd6 | ||
|
|
d842800df3 | ||
|
|
1af2ad0ec4 | ||
|
|
67915151aa | ||
|
|
de25b36a01 | ||
|
|
59e74e6eeb | ||
|
|
4e7f095b0f | ||
|
|
cdea75b87f | ||
|
|
6a0d2e21b5 | ||
|
|
b79d5fccbc | ||
|
|
6d77cb1801 | ||
|
|
e4a45a556c | ||
|
|
3ca39ceb8a | ||
|
|
8a93122882 | ||
|
|
8eb986591a | ||
|
|
c10808b611 | ||
|
|
ba63358098 | ||
|
|
52534db3e1 | ||
|
|
dc9b375ff5 | ||
|
|
65fdf115be | ||
|
|
ecb2b35ec8 | ||
|
|
2d13e0985e | ||
|
|
5014443f80 | ||
|
|
3fef7596b3 | ||
|
|
19042907be | ||
|
|
5cdd06d432 | ||
|
|
47e23bff90 | ||
|
|
7dfc62b2c5 | ||
|
|
39c4af0a7c | ||
|
|
57c5c394f5 | ||
|
|
be6da38a08 | ||
|
|
fc36ed08f1 | ||
|
|
ed90769081 | ||
|
|
a8310fa0ff | ||
|
|
a902e31521 | ||
|
|
932ab13d97 | ||
|
|
94a1ba7989 | ||
|
|
bfecdbf83a | ||
|
|
ba1cfc3c27 | ||
|
|
2cba228a67 | ||
|
|
66553ee236 | ||
|
|
64674b6a73 | ||
|
|
a9def8cb18 | ||
|
|
69186e9a26 | ||
|
|
f606826098 | ||
|
|
aff036d9fb | ||
|
|
57ed08994b | ||
|
|
131eefa1ac | ||
|
|
b4e639cc24 | ||
|
|
ba962af914 | ||
|
|
76514a6e2b | ||
|
|
b69a5342d9 | ||
|
|
c25682f199 | ||
|
|
eec8b4d2c3 | ||
|
|
1af7b797bc | ||
|
|
b5c159bf63 | ||
|
|
bfbdfb2b5c | ||
|
|
08bb64ddc1 | ||
|
|
23f90156bf | ||
|
|
1899cff572 | ||
|
|
774c2ce248 | ||
|
|
89d9075850 | ||
|
|
2c915d53f4 | ||
|
|
797d9442ac | ||
|
|
573d054748 | ||
|
|
2035a256f5 | ||
|
|
c94f26c8b9 | ||
|
|
fc2f14b3f4 | ||
|
|
6dd1697915 | ||
|
|
79e899c301 | ||
|
|
2194301716 | ||
|
|
0348894ab8 | ||
|
|
9b17d8bea1 | ||
|
|
69d6b6f934 | ||
|
|
6c106374fa | ||
|
|
af039d045d | ||
|
|
4c9caf09ba | ||
|
|
3fd02adbec | ||
|
|
90dac3cd15 | ||
|
|
d0307ee6d9 | ||
|
|
09d02b7ced | ||
|
|
56a26d9663 | ||
|
|
42f809f6d4 | ||
|
|
7d64c82987 | ||
|
|
6252227bb6 | ||
|
|
e9ac393a8f | ||
|
|
5b1745f991 | ||
|
|
0e55bf5c43 | ||
|
|
9f66f73501 | ||
|
|
c3da28b07f | ||
|
|
b035b96dec | ||
|
|
9623ac4141 | ||
|
|
c8edbd285b | ||
|
|
016597d5a2 | ||
|
|
52dea8fa2f | ||
|
|
0a37a8ea6d | ||
|
|
c1404ef904 | ||
|
|
2c0fce61df | ||
|
|
bbe9b6b6cf | ||
|
|
23231563c9 | ||
|
|
d75c8668c5 | ||
|
|
f266232b5a | ||
|
|
a8362e8e88 | ||
|
|
e4dfae1905 | ||
|
|
a09e740648 | ||
|
|
5ee6a43f08 | ||
|
|
8bd83cbfcd | ||
|
|
bc14d1d0f8 | ||
|
|
526e649f06 | ||
|
|
ac40eb8f7c | ||
|
|
c750cf10a8 | ||
|
|
4f4951cdcd | ||
|
|
50891afd05 | ||
|
|
cbb6fc740a | ||
|
|
31c3dd6119 | ||
|
|
15700ddd8d |
10
.github/workflows/docker-hub.yml
vendored
10
.github/workflows/docker-hub.yml
vendored
@@ -8,6 +8,9 @@ on:
|
||||
- 'main'
|
||||
tags:
|
||||
- 'v*'
|
||||
pull_request:
|
||||
branches:
|
||||
- 'main'
|
||||
|
||||
env:
|
||||
DOCKER_USER: 1001:127
|
||||
@@ -52,6 +55,7 @@ jobs:
|
||||
with:
|
||||
docker-build-args: '--target backend-production -f Dockerfile'
|
||||
docker-image-name: 'docker.io/lasuite/impress-backend:${{ github.sha }}'
|
||||
continue-on-error: true
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
@@ -102,6 +106,7 @@ jobs:
|
||||
with:
|
||||
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
|
||||
docker-image-name: 'docker.io/lasuite/impress-frontend:${{ github.sha }}'
|
||||
continue-on-error: true
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
@@ -151,14 +156,15 @@ 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
|
||||
-
|
||||
name: Build and push
|
||||
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' }}
|
||||
|
||||
22
.github/workflows/helmfile-linter.yaml
vendored
Normal file
22
.github/workflows/helmfile-linter.yaml
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
name: Helmfile lint
|
||||
run-name: Helmfile lint
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- 'main'
|
||||
|
||||
jobs:
|
||||
helmfile-lint:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ghcr.io/helmfile/helmfile:latest
|
||||
steps:
|
||||
-
|
||||
uses: numerique-gouv/action-helmfile-lint@main
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
age-key: ${{ secrets.SOPS_PRIVATE }}
|
||||
private-key: ${{ secrets.PRIVATE_KEY }}
|
||||
helmfile-src: "src/helm"
|
||||
repositories: "impress,secrets"
|
||||
45
.github/workflows/impress-frontend.yml
vendored
45
.github/workflows/impress-frontend.yml
vendored
@@ -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,16 +95,38 @@ 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'
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-chromium-report
|
||||
@@ -124,16 +151,16 @@ 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
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-other-report
|
||||
|
||||
6
.github/workflows/impress.yml
vendored
6
.github/workflows/impress.yml
vendored
@@ -107,7 +107,9 @@ jobs:
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: "3.10"
|
||||
python-version: "3.12.6"
|
||||
- name: Upgrade pip and setuptools
|
||||
run: pip install --upgrade pip setuptools
|
||||
- name: Install development dependencies
|
||||
run: pip install --user .[dev]
|
||||
- name: Check code formatting with ruff
|
||||
@@ -199,7 +201,7 @@ jobs:
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: "3.10"
|
||||
python-version: "3.12.6"
|
||||
|
||||
- name: Install development dependencies
|
||||
run: pip install --user .[dev]
|
||||
|
||||
33
.github/workflows/release-helm-chart.yaml
vendored
Normal file
33
.github/workflows/release-helm-chart.yaml
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
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@v3
|
||||
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
|
||||
env:
|
||||
CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
||||
97
CHANGELOG.md
97
CHANGELOG.md
@@ -9,6 +9,93 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## Added
|
||||
|
||||
🔧(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
|
||||
|
||||
|
||||
## [1.8.1] - 2024-11-27
|
||||
|
||||
## Fixed
|
||||
|
||||
- 🐛(frontend) link not clickable and flickering firefox #457
|
||||
|
||||
|
||||
## [1.8.0] - 2024-11-25
|
||||
|
||||
## Added
|
||||
|
||||
- 🌐(backend) add German translation #259
|
||||
- 🌐(frontend) add German translation #255
|
||||
- ✨(frontend) add a broadcast store #387
|
||||
- ✨(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) improve users similarity search and sort results #391
|
||||
- ♻️(frontend) simplify stores #402
|
||||
- ✨(frontend) update $css Box props type to add styled components RuleSet #423
|
||||
- ✅(CI) trivy continue on error #453
|
||||
|
||||
## Fixed
|
||||
|
||||
- 🔧(backend) fix logging for docker and make it configurable by envar #427
|
||||
- 🦺(backend) add comma to sub regex #408
|
||||
- 🐛(editor) collaborative user tag hidden when read only #385
|
||||
- 🐛(frontend) users have view access when revoked #387
|
||||
- 🐛(frontend) fix placeholder editable when double clicks #454
|
||||
|
||||
|
||||
## [1.7.0] - 2024-10-24
|
||||
|
||||
## Added
|
||||
@@ -17,10 +104,13 @@ and this project adheres to
|
||||
- 🌐(frontend) add localization to editor #368
|
||||
- ✨Public and restricted doc editable #357
|
||||
- ✨(frontend) Add full name if available #380
|
||||
- ✨(backend) Add view accesses ability #376
|
||||
|
||||
## Changed
|
||||
|
||||
- ♻️(frontend) list accesses if user has abilities #376
|
||||
- ♻️(frontend) avoid documents indexing in search engine #372
|
||||
- 👔(backend) doc restricted by default #388
|
||||
|
||||
## Fixed
|
||||
|
||||
@@ -235,7 +325,12 @@ and this project adheres to
|
||||
- 🚀 Impress, project to manage your documents easily and collaboratively.
|
||||
|
||||
|
||||
[unreleased]: https://github.com/numerique-gouv/impress/compare/v1.7.0...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
|
||||
[v1.7.0]: https://github.com/numerique-gouv/impress/releases/v1.7.0
|
||||
[v1.6.0]: https://github.com/numerique-gouv/impress/releases/v1.6.0
|
||||
[1.5.1]: https://github.com/numerique-gouv/impress/releases/v1.5.1
|
||||
|
||||
2
Makefile
2
Makefile
@@ -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
|
||||
|
||||
@@ -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=[
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -4,13 +4,21 @@ DJANGO_SECRET_KEY=ThisIsAnExampleKeyForDevPurposeOnly
|
||||
DJANGO_SETTINGS_MODULE=impress.settings
|
||||
DJANGO_SUPERUSER_PASSWORD=admin
|
||||
|
||||
# Logging
|
||||
# Set to DEBUG level for dev only
|
||||
LOGGING_LEVEL_HANDLERS_CONSOLE=INFO
|
||||
LOGGING_LEVEL_LOGGERS_ROOT=INFO
|
||||
LOGGING_LEVEL_LOGGERS_APP=INFO
|
||||
|
||||
# Python
|
||||
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
|
||||
@@ -21,6 +29,7 @@ STORAGES_STATICFILES_BACKEND=django.contrib.staticfiles.storage.StaticFilesStora
|
||||
AWS_S3_ENDPOINT_URL=http://minio:9000
|
||||
AWS_S3_ACCESS_KEY_ID=impress
|
||||
AWS_S3_SECRET_ACCESS_KEY=password
|
||||
MEDIA_BASE_URL=http://localhost:8083
|
||||
|
||||
# OIDC
|
||||
OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/impress/protocol/openid-connect/certs
|
||||
@@ -44,3 +53,12 @@ OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
|
||||
AI_BASE_URL=https://openaiendpoint.com
|
||||
AI_API_KEY=password
|
||||
AI_MODEL=llama
|
||||
|
||||
# Collaboration
|
||||
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
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
69
src/backend/core/api/filters.py
Normal file
69
src/backend/core/api/filters.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""API filters for Impress' core application."""
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import django_filters
|
||||
|
||||
from core import models
|
||||
|
||||
|
||||
class DocumentFilter(django_filters.FilterSet):
|
||||
"""
|
||||
Custom filter for filtering documents.
|
||||
"""
|
||||
|
||||
is_creator_me = django_filters.BooleanFilter(
|
||||
method="filter_is_creator_me", label=_("Creator is me")
|
||||
)
|
||||
is_favorite = django_filters.BooleanFilter(
|
||||
method="filter_is_favorite", label=_("Favorite")
|
||||
)
|
||||
title = django_filters.CharFilter(
|
||||
field_name="title", lookup_expr="icontains", label=_("Title")
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = models.Document
|
||||
fields = ["is_creator_me", "is_favorite", "link_reach", "title"]
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def filter_is_creator_me(self, queryset, name, value):
|
||||
"""
|
||||
Filter documents based on the `creator` being the current user.
|
||||
|
||||
Example:
|
||||
- /api/v1.0/documents/?is_creator_me=true
|
||||
→ Filters documents created by the logged-in user
|
||||
- /api/v1.0/documents/?is_creator_me=false
|
||||
→ Filters documents created by other users
|
||||
"""
|
||||
user = self.request.user
|
||||
|
||||
if not user.is_authenticated:
|
||||
return queryset
|
||||
|
||||
if value:
|
||||
return queryset.filter(creator=user)
|
||||
|
||||
return queryset.exclude(creator=user)
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def filter_is_favorite(self, queryset, name, value):
|
||||
"""
|
||||
Filter documents based on whether they are marked as favorite by the current user.
|
||||
|
||||
Example:
|
||||
- /api/v1.0/documents/?is_favorite=true
|
||||
→ Filters documents marked as favorite by the logged-in user
|
||||
- /api/v1.0/documents/?is_favorite=false
|
||||
→ Filters documents not marked as favorite by the logged-in user
|
||||
"""
|
||||
user = self.request.user
|
||||
|
||||
if not user.is_authenticated:
|
||||
return queryset
|
||||
|
||||
if value:
|
||||
return queryset.filter(favorited_by_users__user=user)
|
||||
|
||||
return queryset.exclude(favorited_by_users__user=user)
|
||||
@@ -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):
|
||||
@@ -137,32 +142,69 @@ class BaseResourceSerializer(serializers.ModelSerializer):
|
||||
return {}
|
||||
|
||||
|
||||
class DocumentSerializer(BaseResourceSerializer):
|
||||
"""Serialize documents."""
|
||||
class ListDocumentSerializer(BaseResourceSerializer):
|
||||
"""Serialize documents with limited fields for display in lists."""
|
||||
|
||||
content = serializers.CharField(required=False)
|
||||
accesses = DocumentAccessSerializer(many=True, read_only=True)
|
||||
is_favorite = serializers.BooleanField(read_only=True)
|
||||
nb_accesses = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.Document
|
||||
fields = [
|
||||
"id",
|
||||
"content",
|
||||
"title",
|
||||
"accesses",
|
||||
"abilities",
|
||||
"content",
|
||||
"created_at",
|
||||
"creator",
|
||||
"is_favorite",
|
||||
"link_role",
|
||||
"link_reach",
|
||||
"created_at",
|
||||
"nb_accesses",
|
||||
"title",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"accesses",
|
||||
"abilities",
|
||||
"created_at",
|
||||
"creator",
|
||||
"is_favorite",
|
||||
"link_role",
|
||||
"link_reach",
|
||||
"nb_accesses",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
|
||||
class DocumentSerializer(ListDocumentSerializer):
|
||||
"""Serialize documents with all fields for display in detail views."""
|
||||
|
||||
content = serializers.CharField(required=False)
|
||||
|
||||
class Meta:
|
||||
model = models.Document
|
||||
fields = [
|
||||
"id",
|
||||
"abilities",
|
||||
"content",
|
||||
"created_at",
|
||||
"creator",
|
||||
"is_favorite",
|
||||
"link_role",
|
||||
"link_reach",
|
||||
"nb_accesses",
|
||||
"title",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"abilities",
|
||||
"created_at",
|
||||
"creator",
|
||||
"is_avorite",
|
||||
"link_role",
|
||||
"link_reach",
|
||||
"nb_accesses",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
@@ -190,6 +232,96 @@ class DocumentSerializer(BaseResourceSerializer):
|
||||
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.
|
||||
|
||||
@@ -1,57 +1,58 @@
|
||||
"""API endpoints"""
|
||||
# pylint: disable=too-many-lines
|
||||
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.contrib.postgres.search import TrigramSimilarity
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.files.storage import default_storage
|
||||
from django.db import models as db
|
||||
from django.db.models import (
|
||||
Min,
|
||||
Count,
|
||||
Exists,
|
||||
OuterRef,
|
||||
Q,
|
||||
Subquery,
|
||||
Value,
|
||||
)
|
||||
from django.http import Http404
|
||||
|
||||
import rest_framework as drf
|
||||
from botocore.exceptions import ClientError
|
||||
from rest_framework import (
|
||||
decorators,
|
||||
exceptions,
|
||||
filters,
|
||||
metadata,
|
||||
mixins,
|
||||
pagination,
|
||||
status,
|
||||
viewsets,
|
||||
)
|
||||
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>/`
|
||||
@@ -123,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"
|
||||
@@ -132,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"""
|
||||
|
||||
@@ -156,11 +157,24 @@ class UserViewSet(
|
||||
|
||||
# Filter users by email similarity
|
||||
if query := self.request.GET.get("q", ""):
|
||||
# For performance reasons we filter first by similarity, which relies on an index,
|
||||
# then only calculate precise similarity scores for sorting purposes
|
||||
queryset = queryset.filter(email__trigram_word_similar=query)
|
||||
|
||||
queryset = queryset.annotate(
|
||||
similarity=TrigramSimilarity("email", query)
|
||||
)
|
||||
# When the query only is on the name part, we should try to make many proposals
|
||||
# But when the query looks like an email we should only propose serious matches
|
||||
threshold = 0.6 if "@" in query else 0.1
|
||||
|
||||
queryset = queryset.filter(similarity__gt=threshold).order_by(
|
||||
"-similarity", "email"
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
@decorators.action(
|
||||
@drf.decorators.action(
|
||||
detail=False,
|
||||
methods=["get"],
|
||||
url_name="me",
|
||||
@@ -172,47 +186,11 @@ 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
|
||||
)
|
||||
|
||||
|
||||
class ResourceViewsetMixin:
|
||||
"""Mixin with methods common to all resource viewsets that are managed with accesses."""
|
||||
|
||||
filter_backends = [filters.OrderingFilter]
|
||||
ordering_fields = ["created_at", "updated_at", "title"]
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def get_queryset(self):
|
||||
"""Custom queryset to get user related resources."""
|
||||
queryset = super().get_queryset()
|
||||
user = self.request.user
|
||||
|
||||
if not user.is_authenticated:
|
||||
return queryset
|
||||
|
||||
user_roles_query = (
|
||||
self.access_model_class.objects.filter(
|
||||
Q(user=user) | Q(team__in=user.teams),
|
||||
**{self.resource_field_name: OuterRef("pk")},
|
||||
)
|
||||
.values(self.resource_field_name)
|
||||
.annotate(roles_array=ArrayAgg("role"))
|
||||
.values("roles_array")
|
||||
)
|
||||
return queryset.annotate(user_roles=Subquery(user_roles_query)).distinct()
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Set the current user as owner of the newly created object."""
|
||||
obj = serializer.save()
|
||||
self.access_model_class.objects.create(
|
||||
user=self.request.user,
|
||||
role=models.RoleChoices.OWNER,
|
||||
**{self.resource_field_name: obj},
|
||||
)
|
||||
|
||||
|
||||
class ResourceAccessViewsetMixin:
|
||||
"""Mixin with methods common to all access viewsets."""
|
||||
|
||||
@@ -243,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)
|
||||
@@ -257,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
|
||||
@@ -276,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)
|
||||
@@ -299,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):
|
||||
@@ -322,35 +302,90 @@ class DocumentMetadata(metadata.SimpleMetadata):
|
||||
|
||||
|
||||
class DocumentViewSet(
|
||||
ResourceViewsetMixin,
|
||||
mixins.CreateModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
drf.mixins.CreateModelMixin,
|
||||
drf.mixins.DestroyModelMixin,
|
||||
drf.mixins.UpdateModelMixin,
|
||||
drf.viewsets.GenericViewSet,
|
||||
):
|
||||
"""Document ViewSet"""
|
||||
"""
|
||||
Document ViewSet for managing documents.
|
||||
|
||||
Provides endpoints for creating, updating, and deleting documents,
|
||||
along with filtering options.
|
||||
|
||||
Filtering:
|
||||
- `is_creator_me=true`: Returns documents created by the current user.
|
||||
- `is_creator_me=false`: Returns documents created by other users.
|
||||
- `is_favorite=true`: Returns documents marked as favorite by the current user
|
||||
- `is_favorite=false`: Returns documents not marked as favorite by the current user
|
||||
- `title=hello`: Returns documents which title contains the "hello" string
|
||||
|
||||
Example Usage:
|
||||
- GET /api/v1.0/documents/?is_creator_me=true&is_favorite=true
|
||||
- GET /api/v1.0/documents/?is_creator_me=false&title=hello
|
||||
"""
|
||||
|
||||
filter_backends = [drf_filters.DjangoFilterBackend, filters.OrderingFilter]
|
||||
filterset_class = DocumentFilter
|
||||
metadata_class = DocumentMetadata
|
||||
ordering = ["-updated_at"]
|
||||
ordering_fields = ["created_at", "is_favorite", "updated_at", "title"]
|
||||
permission_classes = [
|
||||
permissions.AccessPermission,
|
||||
]
|
||||
serializer_class = serializers.DocumentSerializer
|
||||
access_model_class = models.DocumentAccess
|
||||
resource_field_name = "document"
|
||||
queryset = models.Document.objects.all()
|
||||
ordering = ["-updated_at"]
|
||||
metadata_class = DocumentMetadata
|
||||
serializer_class = serializers.DocumentSerializer
|
||||
|
||||
def get_serializer_class(self):
|
||||
"""
|
||||
Use ListDocumentSerializer for list actions, otherwise use DocumentSerializer.
|
||||
"""
|
||||
if self.action == "list":
|
||||
return serializers.ListDocumentSerializer
|
||||
return self.serializer_class
|
||||
|
||||
def get_queryset(self):
|
||||
"""Optimize queryset to include favorite status for the current user."""
|
||||
queryset = super().get_queryset()
|
||||
user = self.request.user
|
||||
|
||||
# Annotate the number of accesses associated with each document
|
||||
queryset = queryset.annotate(nb_accesses=Count("accesses", distinct=True))
|
||||
|
||||
if not user.is_authenticated:
|
||||
# If the user is not authenticated, annotate `is_favorite` as False
|
||||
return queryset.annotate(is_favorite=Value(False))
|
||||
|
||||
# Annotate the queryset to indicate if the document is favorited by the current user
|
||||
favorite_exists = models.DocumentFavorite.objects.filter(
|
||||
document_id=OuterRef("pk"), user=user
|
||||
)
|
||||
queryset = queryset.annotate(is_favorite=Exists(favorite_exists))
|
||||
|
||||
# Annotate the queryset with the logged-in user roles
|
||||
user_roles_query = (
|
||||
models.DocumentAccess.objects.filter(
|
||||
Q(user=user) | Q(team__in=user.teams),
|
||||
document_id=OuterRef("pk"),
|
||||
)
|
||||
.values("document")
|
||||
.annotate(roles_array=ArrayAgg("role"))
|
||||
.values("roles_array")
|
||||
)
|
||||
return queryset.annotate(user_roles=Subquery(user_roles_query)).distinct()
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
"""Restrict resources returned by the list endpoint"""
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
user = self.request.user
|
||||
|
||||
if user.is_authenticated:
|
||||
queryset = queryset.filter(
|
||||
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:
|
||||
@@ -362,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):
|
||||
"""
|
||||
@@ -385,9 +420,42 @@ 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)
|
||||
|
||||
@decorators.action(detail=True, methods=["get"], url_path="versions")
|
||||
def perform_create(self, serializer):
|
||||
"""Set the current user as creator and owner of the newly created object."""
|
||||
obj = serializer.save(creator=self.request.user)
|
||||
models.DocumentAccess.objects.create(
|
||||
document=obj,
|
||||
user=self.request.user,
|
||||
role=models.RoleChoices.OWNER,
|
||||
)
|
||||
|
||||
@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
|
||||
@@ -395,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)
|
||||
@@ -406,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"
|
||||
)
|
||||
|
||||
@@ -422,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})",
|
||||
@@ -445,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:
|
||||
@@ -453,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"],
|
||||
@@ -465,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
|
||||
@@ -478,9 +546,50 @@ 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"], url_path="attachment-upload")
|
||||
# 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.
|
||||
"""
|
||||
# Check permissions first
|
||||
document = self.get_object()
|
||||
user = request.user
|
||||
|
||||
if request.method == "POST":
|
||||
# Try to mark as favorite
|
||||
try:
|
||||
models.DocumentFavorite.objects.create(document=document, user=user)
|
||||
except ValidationError:
|
||||
return drf.response.Response(
|
||||
{"detail": "Document already marked as favorite"},
|
||||
status=drf.status.HTTP_200_OK,
|
||||
)
|
||||
return drf.response.Response(
|
||||
{"detail": "Document marked as favorite"},
|
||||
status=drf.status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
# Handle DELETE method to unmark as favorite
|
||||
deleted, _ = models.DocumentFavorite.objects.filter(
|
||||
document=document, user=user
|
||||
).delete()
|
||||
if deleted:
|
||||
return drf.response.Response(
|
||||
{"detail": "Document unmarked as favorite"},
|
||||
status=drf.status.HTTP_204_NO_CONTENT,
|
||||
)
|
||||
return drf.response.Response(
|
||||
{"detail": "Document was already not marked as favorite"},
|
||||
status=drf.status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@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
|
||||
@@ -505,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
|
||||
@@ -525,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",
|
||||
@@ -577,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",
|
||||
@@ -606,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.
|
||||
@@ -654,42 +838,83 @@ 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(
|
||||
ResourceViewsetMixin,
|
||||
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]
|
||||
permission_classes = [
|
||||
permissions.IsAuthenticatedOrSafe,
|
||||
permissions.AccessPermission,
|
||||
]
|
||||
ordering = ["-created_at"]
|
||||
ordering_fields = ["created_at", "updated_at", "title"]
|
||||
serializer_class = serializers.TemplateSerializer
|
||||
access_model_class = models.TemplateAccess
|
||||
resource_field_name = "template"
|
||||
queryset = models.Template.objects.all()
|
||||
|
||||
def get_queryset(self):
|
||||
"""Custom queryset to get user related templates."""
|
||||
queryset = super().get_queryset()
|
||||
user = self.request.user
|
||||
|
||||
if not user.is_authenticated:
|
||||
return queryset
|
||||
|
||||
user_roles_query = (
|
||||
models.TemplateAccess.objects.filter(
|
||||
Q(user=user) | Q(team__in=user.teams),
|
||||
template_id=OuterRef("pk"),
|
||||
)
|
||||
.values("template")
|
||||
.annotate(roles_array=ArrayAgg("role"))
|
||||
.values("roles_array")
|
||||
)
|
||||
return queryset.annotate(user_roles=Subquery(user_roles_query)).distinct()
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
"""Restrict templates returned by the list endpoint"""
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
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)
|
||||
@@ -700,9 +925,18 @@ 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)
|
||||
|
||||
@decorators.action(
|
||||
def perform_create(self, serializer):
|
||||
"""Set the current user as owner of the newly created object."""
|
||||
obj = serializer.save()
|
||||
models.TemplateAccess.objects.create(
|
||||
template=obj,
|
||||
user=self.request.user,
|
||||
role=models.RoleChoices.OWNER,
|
||||
)
|
||||
|
||||
@drf.decorators.action(
|
||||
detail=True,
|
||||
methods=["post"],
|
||||
url_path="generate-document",
|
||||
@@ -725,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"]
|
||||
@@ -739,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.
|
||||
@@ -779,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.
|
||||
|
||||
@@ -836,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")
|
||||
@@ -847,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
|
||||
@@ -869,6 +1103,34 @@ 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(drf.views.APIView):
|
||||
"""API ViewSet for sharing some public settings."""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
def get(self, request):
|
||||
"""
|
||||
GET /api/v1.0/config/
|
||||
Return a dictionary of public settings.
|
||||
"""
|
||||
array_settings = [
|
||||
"COLLABORATION_WS_URL",
|
||||
"CRISP_WEBSITE_ID",
|
||||
"ENVIRONMENT",
|
||||
"FRONTEND_THEME",
|
||||
"MEDIA_BASE_URL",
|
||||
"LANGUAGES",
|
||||
"LANGUAGE_CODE",
|
||||
"SENTRY_DSN",
|
||||
]
|
||||
dict_settings = {}
|
||||
for setting in array_settings:
|
||||
if hasattr(settings, setting):
|
||||
dict_settings[setting] = getattr(settings, setting)
|
||||
|
||||
return drf.response.Response(dict_settings)
|
||||
|
||||
@@ -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'"
|
||||
|
||||
@@ -56,6 +56,7 @@ class DocumentFactory(factory.django.DjangoModelFactory):
|
||||
|
||||
title = factory.Sequence(lambda n: f"document{n}")
|
||||
content = factory.Sequence(lambda n: f"content{n}")
|
||||
creator = factory.SubFactory(UserFactory)
|
||||
link_reach = factory.fuzzy.FuzzyChoice(
|
||||
[a[0] for a in models.LinkReachChoices.choices]
|
||||
)
|
||||
@@ -80,6 +81,13 @@ class DocumentFactory(factory.django.DjangoModelFactory):
|
||||
for item in extracted:
|
||||
models.LinkTrace.objects.create(document=self, user=item)
|
||||
|
||||
@factory.post_generation
|
||||
def favorited_by(self, create, extracted, **kwargs):
|
||||
"""Mark document as favorited by a list of users."""
|
||||
if create and extracted:
|
||||
for item in extracted:
|
||||
models.DocumentFavorite.objects.create(document=self, user=item)
|
||||
|
||||
|
||||
class UserDocumentAccessFactory(factory.django.DjangoModelFactory):
|
||||
"""Create fake document user accesses for testing."""
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.2 on 2024-10-25 11:41
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0007_fix_users_duplicate'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='document',
|
||||
name='link_reach',
|
||||
field=models.CharField(choices=[('restricted', 'Restricted'), ('authenticated', 'Authenticated'), ('public', 'Public')], default='restricted', max_length=20),
|
||||
),
|
||||
]
|
||||
37
src/backend/core/migrations/0009_add_document_favorite.py
Normal file
37
src/backend/core/migrations/0009_add_document_favorite.py
Normal file
@@ -0,0 +1,37 @@
|
||||
# Generated by Django 5.1.2 on 2024-11-08 07:59
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0008_alter_document_link_reach'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='language',
|
||||
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='DocumentFavorite',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
|
||||
('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorited_by_users', to='core.document')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorite_documents', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Document favorite',
|
||||
'verbose_name_plural': 'Document favorites',
|
||||
'db_table': 'impress_document_favorite',
|
||||
'constraints': [models.UniqueConstraint(fields=('user', 'document'), name='unique_document_favorite_user', violation_error_message='This document is already targeted by a favorite relation instance for the same user.')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,31 @@
|
||||
# Generated by Django 5.1.2 on 2024-11-09 11:36
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0009_add_document_favorite'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='document',
|
||||
name='creator',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='documents_created', 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'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='sub',
|
||||
field=models.CharField(blank=True, help_text='Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only.', max_length=255, null=True, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters.', regex='^[\\w.@+-:]+\\Z')], verbose_name='sub'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,52 @@
|
||||
# Generated by Django 5.1.2 on 2024-11-09 11:48
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations
|
||||
from django.db.models import F, ForeignKey, Subquery, OuterRef, Q
|
||||
|
||||
|
||||
def set_creator_from_document_access(apps, schema_editor):
|
||||
"""
|
||||
Populate the `creator` field for existing Document records.
|
||||
|
||||
This function assigns the `creator` field using the existing
|
||||
DocumentAccess entries. We can be sure that all documents have at
|
||||
least one user with "owner" role. If the document has several roles,
|
||||
it should take the entry with the oldest date of creation.
|
||||
|
||||
The update is performed using efficient bulk queries with Django's
|
||||
Subquery and OuterRef to minimize database hits and ensure performance.
|
||||
|
||||
Note: After running this migration, we quickly modify the schema to make
|
||||
the `creator` field required.
|
||||
"""
|
||||
Document = apps.get_model("core", "Document")
|
||||
DocumentAccess = apps.get_model("core", "DocumentAccess")
|
||||
|
||||
# Update `creator` using the "owner" role
|
||||
owner_subquery = DocumentAccess.objects.filter(
|
||||
document=OuterRef('pk'),
|
||||
user__isnull=False,
|
||||
role='owner',
|
||||
).order_by('created_at').values('user_id')[:1]
|
||||
|
||||
Document.objects.filter(
|
||||
creator__isnull=True
|
||||
).update(creator=Subquery(owner_subquery))
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0010_add_field_creator_to_document'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(set_creator_from_document_access, reverse_code=migrations.RunPython.noop),
|
||||
migrations.AlterField(
|
||||
model_name='document',
|
||||
name='creator',
|
||||
field=ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='documents_created', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
@@ -130,17 +130,17 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
"""User model to work with OIDC only authentication."""
|
||||
|
||||
sub_validator = validators.RegexValidator(
|
||||
regex=r"^[\w.@+-]+\Z",
|
||||
regex=r"^[\w.@+-:]+\Z",
|
||||
message=_(
|
||||
"Enter a valid sub. This value may contain only letters, "
|
||||
"numbers, and @/./+/-/_ characters."
|
||||
"numbers, and @/./+/-/_/: characters."
|
||||
),
|
||||
)
|
||||
|
||||
sub = models.CharField(
|
||||
_("sub"),
|
||||
help_text=_(
|
||||
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
|
||||
),
|
||||
max_length=255,
|
||||
unique=True,
|
||||
@@ -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):
|
||||
@@ -336,11 +343,18 @@ class Document(BaseModel):
|
||||
link_reach = models.CharField(
|
||||
max_length=20,
|
||||
choices=LinkReachChoices.choices,
|
||||
default=LinkReachChoices.AUTHENTICATED,
|
||||
default=LinkReachChoices.RESTRICTED,
|
||||
)
|
||||
link_role = models.CharField(
|
||||
max_length=20, choices=LinkRoleChoices.choices, default=LinkRoleChoices.READER
|
||||
)
|
||||
creator = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.RESTRICT,
|
||||
related_name="documents_created",
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
|
||||
_content = None
|
||||
|
||||
@@ -496,7 +510,8 @@ class Document(BaseModel):
|
||||
# Compute version roles before adding link roles because we don't
|
||||
# want anonymous users to access versions (we wouldn't know from
|
||||
# which date to allow them anyway)
|
||||
can_get_versions = bool(roles)
|
||||
# Anonymous users should also not see document accesses
|
||||
has_role = bool(roles)
|
||||
|
||||
# Add role provided by the document link
|
||||
if self.link_reach == LinkReachChoices.PUBLIC or (
|
||||
@@ -507,63 +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 {
|
||||
"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,
|
||||
"accesses_manage": is_owner_or_admin,
|
||||
"accesses_view": has_role,
|
||||
"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,
|
||||
"manage_accesses": 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": can_get_versions,
|
||||
"versions_retrieve": can_get_versions,
|
||||
"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):
|
||||
@@ -598,6 +636,37 @@ class LinkTrace(BaseModel):
|
||||
return f"{self.user!s} trace on document {self.document!s}"
|
||||
|
||||
|
||||
class DocumentFavorite(BaseModel):
|
||||
"""Relation model to store a user's favorite documents."""
|
||||
|
||||
document = models.ForeignKey(
|
||||
Document,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="favorited_by_users",
|
||||
)
|
||||
user = models.ForeignKey(
|
||||
User, on_delete=models.CASCADE, related_name="favorite_documents"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "impress_document_favorite"
|
||||
verbose_name = _("Document favorite")
|
||||
verbose_name_plural = _("Document favorites")
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["user", "document"],
|
||||
name="unique_document_favorite_user",
|
||||
violation_error_message=_(
|
||||
"This document is already targeted by a favorite relation instance "
|
||||
"for the same user."
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user!s} favorite on document {self.document!s}"
|
||||
|
||||
|
||||
class DocumentAccess(BaseAccess):
|
||||
"""Relation model to give access to a document for a user or a team with a role."""
|
||||
|
||||
@@ -673,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,
|
||||
"manage_accesses": is_owner_or_admin,
|
||||
"update": is_owner_or_admin or is_editor,
|
||||
"partial_update": is_owner_or_admin or is_editor,
|
||||
"accesses_manage": is_owner_or_admin,
|
||||
"update": can_update,
|
||||
"partial_update": can_update,
|
||||
"retrieve": can_get,
|
||||
}
|
||||
|
||||
@@ -848,6 +917,8 @@ class Invitation(BaseModel):
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="invitations",
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -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")
|
||||
|
||||
43
src/backend/core/services/collaboration_services.py
Normal file
43
src/backend/core/services/collaboration_services.py
Normal 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}"
|
||||
)
|
||||
78
src/backend/core/services/converter_services.py
Normal file
78
src/backend/core/services/converter_services.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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 "{role}" "
|
||||
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 "{role}" "
|
||||
f"on the following document: {document.title}"
|
||||
) in email_content
|
||||
assert "docs/" + str(document.id) + "/" in email_content
|
||||
|
||||
@@ -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 "{invited}" "
|
||||
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 "reader" on the "
|
||||
f"following document: {document.title}" in email_content
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ def test_api_documents_create_authenticated_success():
|
||||
assert response.status_code == 201
|
||||
document = Document.objects.get()
|
||||
assert document.title == "my document"
|
||||
assert document.link_reach == "restricted"
|
||||
assert document.accesses.filter(role="owner", user=user).exists()
|
||||
|
||||
|
||||
|
||||
@@ -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'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"}
|
||||
308
src/backend/core/tests/documents/test_api_documents_favorite.py
Normal file
308
src/backend/core/tests/documents/test_api_documents_favorite.py
Normal file
@@ -0,0 +1,308 @@
|
||||
"""Test favorite document API endpoint for users in impress's core app."""
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reach",
|
||||
[
|
||||
"restricted",
|
||||
"authenticated",
|
||||
"public",
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("method", ["post", "delete"])
|
||||
def test_api_document_favorite_anonymous_user(method, reach):
|
||||
"""Anonymous users should not be able to mark/unmark documents as favorites."""
|
||||
document = factories.DocumentFactory(link_reach=reach)
|
||||
|
||||
response = getattr(APIClient(), method)(
|
||||
f"/api/v1.0/documents/{document.id!s}/favorite/"
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
# Verify in database
|
||||
assert models.DocumentFavorite.objects.exists() is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reach, has_role",
|
||||
[
|
||||
["restricted", True],
|
||||
["authenticated", False],
|
||||
["authenticated", True],
|
||||
["public", False],
|
||||
["public", True],
|
||||
],
|
||||
)
|
||||
def test_api_document_favorite_authenticated_post_allowed(reach, has_role):
|
||||
"""Authenticated users should be able to mark a document as favorite using POST."""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(link_reach=reach)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
if has_role:
|
||||
models.DocumentAccess.objects.create(document=document, user=user)
|
||||
|
||||
# Mark as favorite
|
||||
response = client.post(f"/api/v1.0/documents/{document.id!s}/favorite/")
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response.json() == {"detail": "Document marked as favorite"}
|
||||
|
||||
# Verify in database
|
||||
assert models.DocumentFavorite.objects.filter(document=document, user=user).exists()
|
||||
|
||||
# Verify document format
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
|
||||
assert response.json()["is_favorite"] is True
|
||||
|
||||
|
||||
def test_api_document_favorite_authenticated_post_forbidden():
|
||||
"""Authenticated users should be able to mark a document as favorite using POST."""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Try marking as favorite
|
||||
response = client.post(f"/api/v1.0/documents/{document.id!s}/favorite/")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
# Verify in database
|
||||
assert (
|
||||
models.DocumentFavorite.objects.filter(document=document, user=user).exists()
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reach, has_role",
|
||||
[
|
||||
["restricted", True],
|
||||
["authenticated", False],
|
||||
["authenticated", True],
|
||||
["public", False],
|
||||
["public", True],
|
||||
],
|
||||
)
|
||||
def test_api_document_favorite_authenticated_post_already_favorited_allowed(
|
||||
reach, has_role
|
||||
):
|
||||
"""POST should not create duplicate favorites if already marked."""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(link_reach=reach, favorited_by=[user])
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
if has_role:
|
||||
models.DocumentAccess.objects.create(document=document, user=user)
|
||||
|
||||
# Try to mark as favorite again
|
||||
response = client.post(f"/api/v1.0/documents/{document.id!s}/favorite/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"detail": "Document already marked as favorite"}
|
||||
|
||||
# Verify in database
|
||||
assert models.DocumentFavorite.objects.filter(document=document, user=user).exists()
|
||||
|
||||
# Verify document format
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
|
||||
assert response.json()["is_favorite"] is True
|
||||
|
||||
|
||||
def test_api_document_favorite_authenticated_post_already_favorited_forbidden():
|
||||
"""POST should not create duplicate favorites if already marked."""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(link_reach="restricted", favorited_by=[user])
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Try to mark as favorite again
|
||||
response = client.post(f"/api/v1.0/documents/{document.id!s}/favorite/")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
# Verify in database
|
||||
assert models.DocumentFavorite.objects.filter(document=document, user=user).exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reach, has_role",
|
||||
[
|
||||
["restricted", True],
|
||||
["authenticated", False],
|
||||
["authenticated", True],
|
||||
["public", False],
|
||||
["public", True],
|
||||
],
|
||||
)
|
||||
def test_api_document_favorite_authenticated_delete_allowed(reach, has_role):
|
||||
"""Authenticated users should be able to unmark a document as favorite using DELETE."""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(link_reach=reach, favorited_by=[user])
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
if has_role:
|
||||
models.DocumentAccess.objects.create(document=document, user=user)
|
||||
|
||||
# Unmark as favorite
|
||||
response = client.delete(f"/api/v1.0/documents/{document.id!s}/favorite/")
|
||||
assert response.status_code == 204
|
||||
|
||||
# Verify in database
|
||||
assert (
|
||||
models.DocumentFavorite.objects.filter(document=document, user=user).exists()
|
||||
is False
|
||||
)
|
||||
|
||||
# Verify document format
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
|
||||
assert response.json()["is_favorite"] is False
|
||||
|
||||
|
||||
def test_api_document_favorite_authenticated_delete_forbidden():
|
||||
"""Authenticated users should be able to unmark a document as favorite using DELETE."""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(link_reach="restricted", favorited_by=[user])
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Unmark as favorite
|
||||
response = client.delete(f"/api/v1.0/documents/{document.id!s}/favorite/")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
# Verify in database
|
||||
assert (
|
||||
models.DocumentFavorite.objects.filter(document=document, user=user).exists()
|
||||
is True
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reach, has_role",
|
||||
[
|
||||
["restricted", True],
|
||||
["authenticated", False],
|
||||
["authenticated", True],
|
||||
["public", False],
|
||||
["public", True],
|
||||
],
|
||||
)
|
||||
def test_api_document_favorite_authenticated_delete_not_favorited_allowed(
|
||||
reach, has_role
|
||||
):
|
||||
"""DELETE should be idempotent if the document is not marked as favorite."""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(link_reach=reach)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
if has_role:
|
||||
models.DocumentAccess.objects.create(document=document, user=user)
|
||||
|
||||
# Try to unmark as favorite when no favorite entry exists
|
||||
response = client.delete(f"/api/v1.0/documents/{document.id!s}/favorite/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"detail": "Document was already not marked as favorite"}
|
||||
|
||||
# Verify in database
|
||||
assert (
|
||||
models.DocumentFavorite.objects.filter(document=document, user=user).exists()
|
||||
is False
|
||||
)
|
||||
|
||||
# Verify document format
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
|
||||
assert response.json()["is_favorite"] is False
|
||||
|
||||
|
||||
def test_api_document_favorite_authenticated_delete_not_favorited_forbidden():
|
||||
"""DELETE should be idempotent if the document is not marked as favorite."""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Try to unmark as favorite when no favorite entry exists
|
||||
response = client.delete(f"/api/v1.0/documents/{document.id!s}/favorite/")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
# Verify in database
|
||||
assert (
|
||||
models.DocumentFavorite.objects.filter(document=document, user=user).exists()
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reach, has_role",
|
||||
[
|
||||
["restricted", True],
|
||||
["authenticated", False],
|
||||
["authenticated", True],
|
||||
["public", False],
|
||||
["public", True],
|
||||
],
|
||||
)
|
||||
def test_api_document_favorite_authenticated_post_unmark_then_mark_again_allowed(
|
||||
reach, has_role
|
||||
):
|
||||
"""A user should be able to mark, unmark, and mark a document again as favorite."""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(link_reach=reach)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
if has_role:
|
||||
models.DocumentAccess.objects.create(document=document, user=user)
|
||||
|
||||
url = f"/api/v1.0/documents/{document.id!s}/favorite/"
|
||||
|
||||
# Mark as favorite
|
||||
response = client.post(url)
|
||||
assert response.status_code == 201
|
||||
|
||||
# Unmark as favorite
|
||||
response = client.delete(url)
|
||||
assert response.status_code == 204
|
||||
|
||||
# Mark as favorite again
|
||||
response = client.post(url)
|
||||
assert response.status_code == 201
|
||||
assert response.json() == {"detail": "Document marked as favorite"}
|
||||
|
||||
# Verify in database
|
||||
assert models.DocumentFavorite.objects.filter(document=document, user=user).exists()
|
||||
|
||||
# Verify document format
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
|
||||
assert response.json()["is_favorite"] is True
|
||||
@@ -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]
|
||||
|
||||
@@ -3,7 +3,9 @@ Tests for Documents API endpoint in impress's core app: list
|
||||
"""
|
||||
|
||||
import operator
|
||||
import random
|
||||
from unittest import mock
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import pytest
|
||||
from faker import Faker
|
||||
@@ -32,7 +34,47 @@ def test_api_documents_list_anonymous(reach, role):
|
||||
assert len(results) == 0
|
||||
|
||||
|
||||
def test_api_documents_list_authenticated_direct():
|
||||
def test_api_documents_list_format():
|
||||
"""Validate the format of documents as returned by the list view."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
other_users = factories.UserFactory.create_batch(3)
|
||||
document = factories.DocumentFactory(
|
||||
users=[user, *factories.UserFactory.create_batch(2)],
|
||||
favorited_by=[user, *other_users],
|
||||
link_traces=other_users,
|
||||
)
|
||||
|
||||
response = client.get("/api/v1.0/documents/")
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
results = content.pop("results")
|
||||
assert content == {
|
||||
"count": 1,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
}
|
||||
assert len(results) == 1
|
||||
assert results[0] == {
|
||||
"id": str(document.id),
|
||||
"abilities": document.get_abilities(user),
|
||||
"content": document.content,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(document.creator.id),
|
||||
"is_favorite": True,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses": 3,
|
||||
"title": document.title,
|
||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
}
|
||||
|
||||
|
||||
def test_api_documents_list_authenticated_direct(django_assert_num_queries):
|
||||
"""
|
||||
Authenticated users should be able to list documents they are a direct
|
||||
owner/administrator/member of or documents that have a link reach other
|
||||
@@ -55,9 +97,8 @@ def test_api_documents_list_authenticated_direct():
|
||||
|
||||
expected_ids = {str(document.id) for document in documents}
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/",
|
||||
)
|
||||
with django_assert_num_queries(3):
|
||||
response = client.get("/api/v1.0/documents/")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
@@ -66,7 +107,9 @@ def test_api_documents_list_authenticated_direct():
|
||||
assert expected_ids == results_id
|
||||
|
||||
|
||||
def test_api_documents_list_authenticated_via_team(mock_user_teams):
|
||||
def test_api_documents_list_authenticated_via_team(
|
||||
django_assert_num_queries, mock_user_teams
|
||||
):
|
||||
"""
|
||||
Authenticated users should be able to list documents they are a
|
||||
owner/administrator/member of via a team.
|
||||
@@ -89,7 +132,8 @@ def test_api_documents_list_authenticated_via_team(mock_user_teams):
|
||||
|
||||
expected_ids = {str(document.id) for document in documents_team1 + documents_team2}
|
||||
|
||||
response = client.get("/api/v1.0/documents/")
|
||||
with django_assert_num_queries(3):
|
||||
response = client.get("/api/v1.0/documents/")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
@@ -98,7 +142,9 @@ def test_api_documents_list_authenticated_via_team(mock_user_teams):
|
||||
assert expected_ids == results_id
|
||||
|
||||
|
||||
def test_api_documents_list_authenticated_link_reach_restricted():
|
||||
def test_api_documents_list_authenticated_link_reach_restricted(
|
||||
django_assert_num_queries,
|
||||
):
|
||||
"""
|
||||
An authenticated user who has link traces to a document that is restricted should not
|
||||
see it on the list view
|
||||
@@ -115,9 +161,10 @@ def test_api_documents_list_authenticated_link_reach_restricted():
|
||||
other_document = factories.DocumentFactory(link_reach="public")
|
||||
models.LinkTrace.objects.create(document=other_document, user=user)
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/",
|
||||
)
|
||||
with django_assert_num_queries(3):
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
@@ -127,7 +174,9 @@ def test_api_documents_list_authenticated_link_reach_restricted():
|
||||
assert results[0]["id"] == str(other_document.id)
|
||||
|
||||
|
||||
def test_api_documents_list_authenticated_link_reach_public_or_authenticated():
|
||||
def test_api_documents_list_authenticated_link_reach_public_or_authenticated(
|
||||
django_assert_num_queries,
|
||||
):
|
||||
"""
|
||||
An authenticated user who has link traces to a document with public or authenticated
|
||||
link reach should see it on the list view.
|
||||
@@ -144,9 +193,10 @@ def test_api_documents_list_authenticated_link_reach_public_or_authenticated():
|
||||
]
|
||||
expected_ids = {str(document.id) for document in documents}
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/",
|
||||
)
|
||||
with django_assert_num_queries(3):
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
@@ -224,6 +274,143 @@ def test_api_documents_list_authenticated_distinct():
|
||||
assert content["results"][0]["id"] == str(document.id)
|
||||
|
||||
|
||||
def test_api_documents_list_favorites_no_extra_queries(django_assert_num_queries):
|
||||
"""
|
||||
Ensure that marking documents as favorite does not generate additional queries
|
||||
when fetching the document list.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
special_documents = factories.DocumentFactory.create_batch(3, users=[user])
|
||||
factories.DocumentFactory.create_batch(2, users=[user])
|
||||
|
||||
url = "/api/v1.0/documents/"
|
||||
with django_assert_num_queries(3):
|
||||
response = client.get(url)
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 5
|
||||
|
||||
assert all(result["is_favorite"] is False for result in results)
|
||||
|
||||
# Mark documents as favorite and check results again
|
||||
for document in special_documents:
|
||||
models.DocumentFavorite.objects.create(document=document, user=user)
|
||||
|
||||
with django_assert_num_queries(3):
|
||||
response = client.get(url)
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 5
|
||||
|
||||
# Check if the "is_favorite" annotation is correctly set for the favorited documents
|
||||
favorited_ids = {str(doc.id) for doc in special_documents}
|
||||
for result in results:
|
||||
if result["id"] in favorited_ids:
|
||||
assert result["is_favorite"] is True
|
||||
else:
|
||||
assert result["is_favorite"] is False
|
||||
|
||||
|
||||
def test_api_documents_list_filter_and_access_rights():
|
||||
"""Filtering on querystring parameters should respect access rights."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
def random_favorited_by():
|
||||
return random.choice([[], [user], [other_user]])
|
||||
|
||||
# Documents that should be listed to this user
|
||||
listed_documents = [
|
||||
factories.DocumentFactory(
|
||||
link_reach="public",
|
||||
link_traces=[user],
|
||||
favorited_by=random_favorited_by(),
|
||||
creator=random.choice([user, other_user]),
|
||||
),
|
||||
factories.DocumentFactory(
|
||||
link_reach="authenticated",
|
||||
link_traces=[user],
|
||||
favorited_by=random_favorited_by(),
|
||||
creator=random.choice([user, other_user]),
|
||||
),
|
||||
factories.DocumentFactory(
|
||||
link_reach="restricted",
|
||||
users=[user],
|
||||
favorited_by=random_favorited_by(),
|
||||
creator=random.choice([user, other_user]),
|
||||
),
|
||||
]
|
||||
listed_ids = [str(doc.id) for doc in listed_documents]
|
||||
word_list = [word for doc in listed_documents for word in doc.title.split(" ")]
|
||||
|
||||
# Documents that should not be listed to this user
|
||||
factories.DocumentFactory(
|
||||
link_reach="public",
|
||||
favorited_by=random_favorited_by(),
|
||||
creator=random.choice([user, other_user]),
|
||||
)
|
||||
factories.DocumentFactory(
|
||||
link_reach="authenticated",
|
||||
favorited_by=random_favorited_by(),
|
||||
creator=random.choice([user, other_user]),
|
||||
)
|
||||
factories.DocumentFactory(
|
||||
link_reach="restricted",
|
||||
favorited_by=random_favorited_by(),
|
||||
creator=random.choice([user, other_user]),
|
||||
)
|
||||
factories.DocumentFactory(
|
||||
link_reach="restricted",
|
||||
link_traces=[user],
|
||||
favorited_by=random_favorited_by(),
|
||||
creator=random.choice([user, other_user]),
|
||||
)
|
||||
|
||||
filters = {
|
||||
"link_reach": random.choice([None, *models.LinkReachChoices.values]),
|
||||
"title": random.choice([None, *word_list]),
|
||||
"favorite": random.choice([None, True, False]),
|
||||
"creator": random.choice([None, user, other_user]),
|
||||
"ordering": random.choice(
|
||||
[
|
||||
None,
|
||||
"created_at",
|
||||
"-created_at",
|
||||
"is_favorite",
|
||||
"-is_favorite",
|
||||
"nb_accesses",
|
||||
"-nb_accesses",
|
||||
"title",
|
||||
"-title",
|
||||
"updated_at",
|
||||
"-updated_at",
|
||||
]
|
||||
),
|
||||
}
|
||||
query_params = {key: value for key, value in filters.items() if value is not None}
|
||||
querystring = urlencode(query_params)
|
||||
|
||||
response = client.get(f"/api/v1.0/documents/?{querystring:s}")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
|
||||
# Ensure all documents in results respect expected access rights
|
||||
for result in results:
|
||||
assert result["id"] in listed_ids
|
||||
|
||||
|
||||
# Filters: ordering
|
||||
|
||||
|
||||
def test_api_documents_list_ordering_default():
|
||||
"""Documents should be ordered by descending "updated_at" by default"""
|
||||
user = factories.UserFactory()
|
||||
@@ -254,10 +441,14 @@ def test_api_documents_list_ordering_by_fields():
|
||||
for parameter in [
|
||||
"created_at",
|
||||
"-created_at",
|
||||
"updated_at",
|
||||
"-updated_at",
|
||||
"is_favorite",
|
||||
"-is_favorite",
|
||||
"nb_accesses",
|
||||
"-nb_accesses",
|
||||
"title",
|
||||
"-title",
|
||||
"updated_at",
|
||||
"-updated_at",
|
||||
]:
|
||||
is_descending = parameter.startswith("-")
|
||||
field = parameter.lstrip("-")
|
||||
@@ -272,3 +463,212 @@ def test_api_documents_list_ordering_by_fields():
|
||||
compare = operator.ge if is_descending else operator.le
|
||||
for i in range(4):
|
||||
assert compare(results[i][field], results[i + 1][field])
|
||||
|
||||
|
||||
# Filters: is_creator_me
|
||||
|
||||
|
||||
def test_api_documents_list_filter_is_creator_me_true():
|
||||
"""
|
||||
Authenticated users should be able to filter documents they created.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.DocumentFactory.create_batch(3, users=[user], creator=user)
|
||||
factories.DocumentFactory.create_batch(2, users=[user])
|
||||
|
||||
response = client.get("/api/v1.0/documents/?is_creator_me=true")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 3
|
||||
|
||||
# Ensure all results are created by the current user
|
||||
for result in results:
|
||||
assert result["creator"] == str(user.id)
|
||||
|
||||
|
||||
def test_api_documents_list_filter_is_creator_me_false():
|
||||
"""
|
||||
Authenticated users should be able to filter documents created by others.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.DocumentFactory.create_batch(3, users=[user], creator=user)
|
||||
factories.DocumentFactory.create_batch(2, users=[user])
|
||||
|
||||
response = client.get("/api/v1.0/documents/?is_creator_me=false")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 2
|
||||
|
||||
# Ensure all results are created by other users
|
||||
for result in results:
|
||||
assert result["creator"] != str(user.id)
|
||||
|
||||
|
||||
def test_api_documents_list_filter_is_creator_me_invalid():
|
||||
"""Filtering with an invalid `is_creator_me` value should do nothing."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.DocumentFactory.create_batch(3, users=[user], creator=user)
|
||||
factories.DocumentFactory.create_batch(2, users=[user])
|
||||
|
||||
response = client.get("/api/v1.0/documents/?is_creator_me=invalid")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 5
|
||||
|
||||
|
||||
# Filters: is_favorite
|
||||
|
||||
|
||||
def test_api_documents_list_filter_is_favorite_true():
|
||||
"""
|
||||
Authenticated users should be able to filter documents they marked as favorite.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.DocumentFactory.create_batch(3, users=[user], favorited_by=[user])
|
||||
factories.DocumentFactory.create_batch(2, users=[user])
|
||||
|
||||
response = client.get("/api/v1.0/documents/?is_favorite=true")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 3
|
||||
|
||||
# Ensure all results are marked as favorite by the current user
|
||||
for result in results:
|
||||
assert result["is_favorite"] is True
|
||||
|
||||
|
||||
def test_api_documents_list_filter_is_favorite_false():
|
||||
"""
|
||||
Authenticated users should be able to filter documents they didn't mark as favorite.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.DocumentFactory.create_batch(3, users=[user], favorited_by=[user])
|
||||
factories.DocumentFactory.create_batch(2, users=[user])
|
||||
|
||||
response = client.get("/api/v1.0/documents/?is_favorite=false")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 2
|
||||
|
||||
# Ensure all results are not marked as favorite by the current user
|
||||
for result in results:
|
||||
assert result["is_favorite"] is False
|
||||
|
||||
|
||||
def test_api_documents_list_filter_is_favorite_invalid():
|
||||
"""Filtering with an invalid `is_favorite` value should do nothing."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.DocumentFactory.create_batch(3, users=[user], favorited_by=[user])
|
||||
factories.DocumentFactory.create_batch(2, users=[user])
|
||||
|
||||
response = client.get("/api/v1.0/documents/?is_favorite=invalid")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 5
|
||||
|
||||
|
||||
# Filters: link_reach
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
|
||||
def test_api_documents_list_filter_link_reach(reach):
|
||||
"""Authenticated users should be able to filter documents by link reach."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.DocumentFactory.create_batch(5, users=[user])
|
||||
|
||||
response = client.get(f"/api/v1.0/documents/?link_reach={reach:s}")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
|
||||
# Ensure all results have the chosen link reach
|
||||
for result in results:
|
||||
assert result["link_reach"] == reach
|
||||
|
||||
|
||||
def test_api_documents_list_filter_link_reach_invalid():
|
||||
"""Filtering with an invalid `link_reach` value should raise an error."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.DocumentFactory.create_batch(3, users=[user])
|
||||
|
||||
response = client.get("/api/v1.0/documents/?link_reach=invalid")
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {
|
||||
"link_reach": [
|
||||
"Select a valid choice. invalid is not one of the available choices."
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
# Filters: title
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"query,nb_results",
|
||||
[
|
||||
("Project Alpha", 1), # Exact match
|
||||
("project", 2), # Partial match (case-insensitive)
|
||||
("Guide", 1), # Word match within a title
|
||||
("Special", 0), # No match (nonexistent keyword)
|
||||
("2024", 2), # Match by numeric keyword
|
||||
("", 5), # Empty string
|
||||
],
|
||||
)
|
||||
def test_api_documents_list_filter_title(query, nb_results):
|
||||
"""Authenticated users should be able to search documents by their title."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Create documents with predefined titles
|
||||
titles = [
|
||||
"Project Alpha Documentation",
|
||||
"Project Beta Overview",
|
||||
"User Guide",
|
||||
"Financial Report 2024",
|
||||
"Annual Review 2024",
|
||||
]
|
||||
for title in titles:
|
||||
factories.DocumentFactory(title=title, users=[user])
|
||||
|
||||
# Perform the search query
|
||||
response = client.get(f"/api/v1.0/documents/?title={query:s}")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == nb_results
|
||||
|
||||
# Ensure all results contain the query in their title
|
||||
for result in results:
|
||||
assert query.lower().strip() in result["title"].lower()
|
||||
|
||||
@@ -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
|
||||
@@ -21,13 +21,18 @@ def test_api_documents_retrieve_anonymous_public():
|
||||
assert response.json() == {
|
||||
"id": str(document.id),
|
||||
"abilities": {
|
||||
"accesses_manage": False,
|
||||
"accesses_view": False,
|
||||
"ai_transform": document.link_role == "editor",
|
||||
"ai_translate": document.link_role == "editor",
|
||||
"attachment_upload": document.link_role == "editor",
|
||||
"collaboration_auth": True,
|
||||
"destroy": False,
|
||||
# Anonymous user can't favorite a document even with read access
|
||||
"favorite": False,
|
||||
"invite_owner": False,
|
||||
"link_configuration": False,
|
||||
"manage_accesses": False,
|
||||
"media_auth": True,
|
||||
"partial_update": document.link_role == "editor",
|
||||
"retrieve": True,
|
||||
"update": document.link_role == "editor",
|
||||
@@ -35,12 +40,14 @@ def test_api_documents_retrieve_anonymous_public():
|
||||
"versions_list": False,
|
||||
"versions_retrieve": False,
|
||||
},
|
||||
"accesses": [],
|
||||
"link_reach": "public",
|
||||
"link_role": document.link_role,
|
||||
"title": document.title,
|
||||
"content": document.content,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(document.creator.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": "public",
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses": 0,
|
||||
"title": document.title,
|
||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
}
|
||||
|
||||
@@ -78,13 +85,17 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
|
||||
assert response.json() == {
|
||||
"id": str(document.id),
|
||||
"abilities": {
|
||||
"accesses_manage": False,
|
||||
"accesses_view": False,
|
||||
"ai_transform": document.link_role == "editor",
|
||||
"ai_translate": document.link_role == "editor",
|
||||
"attachment_upload": document.link_role == "editor",
|
||||
"link_configuration": False,
|
||||
"collaboration_auth": True,
|
||||
"destroy": False,
|
||||
"favorite": True,
|
||||
"invite_owner": False,
|
||||
"manage_accesses": False,
|
||||
"media_auth": True,
|
||||
"link_configuration": False,
|
||||
"partial_update": document.link_role == "editor",
|
||||
"retrieve": True,
|
||||
"update": document.link_role == "editor",
|
||||
@@ -92,12 +103,14 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
|
||||
"versions_list": False,
|
||||
"versions_retrieve": False,
|
||||
},
|
||||
"accesses": [],
|
||||
"link_reach": reach,
|
||||
"link_role": document.link_role,
|
||||
"title": document.title,
|
||||
"content": document.content,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(document.creator.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": reach,
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses": 0,
|
||||
"title": document.title,
|
||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
}
|
||||
assert (
|
||||
@@ -166,43 +179,26 @@ def test_api_documents_retrieve_authenticated_related_direct():
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
access1 = factories.UserDocumentAccessFactory(document=document, user=user)
|
||||
factories.UserDocumentAccessFactory(document=document, user=user)
|
||||
access2 = factories.UserDocumentAccessFactory(document=document)
|
||||
access1_user = serializers.UserSerializer(instance=user).data
|
||||
access2_user = serializers.UserSerializer(instance=access2.user).data
|
||||
serializers.UserSerializer(instance=user)
|
||||
serializers.UserSerializer(instance=access2.user)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert sorted(content.pop("accesses"), key=lambda x: x["id"]) == sorted(
|
||||
[
|
||||
{
|
||||
"id": str(access1.id),
|
||||
"user": access1_user,
|
||||
"team": "",
|
||||
"role": access1.role,
|
||||
"abilities": access1.get_abilities(user),
|
||||
},
|
||||
{
|
||||
"id": str(access2.id),
|
||||
"user": access2_user,
|
||||
"team": "",
|
||||
"role": access2.role,
|
||||
"abilities": access2.get_abilities(user),
|
||||
},
|
||||
],
|
||||
key=lambda x: x["id"],
|
||||
)
|
||||
assert response.json() == {
|
||||
"id": str(document.id),
|
||||
"title": document.title,
|
||||
"content": document.content,
|
||||
"abilities": document.get_abilities(user),
|
||||
"content": document.content,
|
||||
"creator": str(document.creator.id),
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"is_favorite": False,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"nb_accesses": 2,
|
||||
"title": document.title,
|
||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
}
|
||||
|
||||
@@ -255,7 +251,7 @@ def test_api_documents_retrieve_authenticated_related_team_members(
|
||||
):
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve a document to which they
|
||||
are related via a team whatever the role and see all its accesses.
|
||||
are related via a team whatever the role.
|
||||
"""
|
||||
mock_user_teams.return_value = teams
|
||||
|
||||
@@ -266,81 +262,34 @@ def test_api_documents_retrieve_authenticated_related_team_members(
|
||||
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
|
||||
access_reader = factories.TeamDocumentAccessFactory(
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=document, team="readers", role="reader"
|
||||
)
|
||||
access_editor = factories.TeamDocumentAccessFactory(
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=document, team="editors", role="editor"
|
||||
)
|
||||
access_administrator = factories.TeamDocumentAccessFactory(
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=document, team="administrators", role="administrator"
|
||||
)
|
||||
access_owner = factories.TeamDocumentAccessFactory(
|
||||
document=document, team="owners", role="owner"
|
||||
)
|
||||
other_access = factories.TeamDocumentAccessFactory(document=document)
|
||||
factories.TeamDocumentAccessFactory(document=document, team="owners", role="owner")
|
||||
factories.TeamDocumentAccessFactory(document=document)
|
||||
factories.TeamDocumentAccessFactory()
|
||||
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
|
||||
|
||||
# pylint: disable=R0801
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
expected_abilities = {
|
||||
"destroy": False,
|
||||
"retrieve": True,
|
||||
"set_role_to": [],
|
||||
"update": False,
|
||||
"partial_update": False,
|
||||
}
|
||||
assert sorted(content.pop("accesses"), key=lambda x: x["id"]) == sorted(
|
||||
[
|
||||
{
|
||||
"id": str(access_reader.id),
|
||||
"user": None,
|
||||
"team": "readers",
|
||||
"role": access_reader.role,
|
||||
"abilities": expected_abilities,
|
||||
},
|
||||
{
|
||||
"id": str(access_editor.id),
|
||||
"user": None,
|
||||
"team": "editors",
|
||||
"role": access_editor.role,
|
||||
"abilities": expected_abilities,
|
||||
},
|
||||
{
|
||||
"id": str(access_administrator.id),
|
||||
"user": None,
|
||||
"team": "administrators",
|
||||
"role": access_administrator.role,
|
||||
"abilities": expected_abilities,
|
||||
},
|
||||
{
|
||||
"id": str(access_owner.id),
|
||||
"user": None,
|
||||
"team": "owners",
|
||||
"role": access_owner.role,
|
||||
"abilities": expected_abilities,
|
||||
},
|
||||
{
|
||||
"id": str(other_access.id),
|
||||
"user": None,
|
||||
"team": other_access.team,
|
||||
"role": other_access.role,
|
||||
"abilities": expected_abilities,
|
||||
},
|
||||
],
|
||||
key=lambda x: x["id"],
|
||||
)
|
||||
assert response.json() == {
|
||||
"id": str(document.id),
|
||||
"title": document.title,
|
||||
"content": document.content,
|
||||
"abilities": document.get_abilities(user),
|
||||
"content": document.content,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(document.creator.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": "restricted",
|
||||
"link_role": document.link_role,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"nb_accesses": 5,
|
||||
"title": document.title,
|
||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
}
|
||||
|
||||
@@ -358,7 +307,7 @@ def test_api_documents_retrieve_authenticated_related_team_administrators(
|
||||
):
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve a document to which they
|
||||
are related via a team whatever the role and see all its accesses.
|
||||
are related via a team whatever the role.
|
||||
"""
|
||||
mock_user_teams.return_value = teams
|
||||
|
||||
@@ -369,98 +318,34 @@ def test_api_documents_retrieve_authenticated_related_team_administrators(
|
||||
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
|
||||
access_reader = factories.TeamDocumentAccessFactory(
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=document, team="readers", role="reader"
|
||||
)
|
||||
access_editor = factories.TeamDocumentAccessFactory(
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=document, team="editors", role="editor"
|
||||
)
|
||||
access_administrator = factories.TeamDocumentAccessFactory(
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=document, team="administrators", role="administrator"
|
||||
)
|
||||
access_owner = factories.TeamDocumentAccessFactory(
|
||||
document=document, team="owners", role="owner"
|
||||
)
|
||||
other_access = factories.TeamDocumentAccessFactory(document=document)
|
||||
factories.TeamDocumentAccessFactory(document=document, team="owners", role="owner")
|
||||
factories.TeamDocumentAccessFactory(document=document)
|
||||
factories.TeamDocumentAccessFactory()
|
||||
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
|
||||
|
||||
# pylint: disable=R0801
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert sorted(content.pop("accesses"), key=lambda x: x["id"]) == sorted(
|
||||
[
|
||||
{
|
||||
"id": str(access_reader.id),
|
||||
"user": None,
|
||||
"team": "readers",
|
||||
"role": "reader",
|
||||
"abilities": {
|
||||
"destroy": True,
|
||||
"retrieve": True,
|
||||
"set_role_to": ["administrator", "editor"],
|
||||
"update": True,
|
||||
"partial_update": True,
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": str(access_editor.id),
|
||||
"user": None,
|
||||
"team": "editors",
|
||||
"role": "editor",
|
||||
"abilities": {
|
||||
"destroy": True,
|
||||
"retrieve": True,
|
||||
"set_role_to": ["administrator", "reader"],
|
||||
"update": True,
|
||||
"partial_update": True,
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": str(access_administrator.id),
|
||||
"user": None,
|
||||
"team": "administrators",
|
||||
"role": "administrator",
|
||||
"abilities": {
|
||||
"destroy": True,
|
||||
"retrieve": True,
|
||||
"set_role_to": ["editor", "reader"],
|
||||
"update": True,
|
||||
"partial_update": True,
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": str(access_owner.id),
|
||||
"user": None,
|
||||
"team": "owners",
|
||||
"role": "owner",
|
||||
"abilities": {
|
||||
"destroy": False,
|
||||
"retrieve": True,
|
||||
"set_role_to": [],
|
||||
"update": False,
|
||||
"partial_update": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": str(other_access.id),
|
||||
"user": None,
|
||||
"team": other_access.team,
|
||||
"role": other_access.role,
|
||||
"abilities": other_access.get_abilities(user),
|
||||
},
|
||||
],
|
||||
key=lambda x: x["id"],
|
||||
)
|
||||
assert response.json() == {
|
||||
"id": str(document.id),
|
||||
"title": document.title,
|
||||
"content": document.content,
|
||||
"abilities": document.get_abilities(user),
|
||||
"content": document.content,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(document.creator.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": "restricted",
|
||||
"link_role": document.link_role,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"nb_accesses": 5,
|
||||
"title": document.title,
|
||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
}
|
||||
|
||||
@@ -479,7 +364,7 @@ def test_api_documents_retrieve_authenticated_related_team_owners(
|
||||
):
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve a restricted document to which
|
||||
they are related via a team whatever the role and see all its accesses.
|
||||
they are related via a team whatever the role.
|
||||
"""
|
||||
mock_user_teams.return_value = teams
|
||||
|
||||
@@ -490,100 +375,33 @@ def test_api_documents_retrieve_authenticated_related_team_owners(
|
||||
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
|
||||
access_reader = factories.TeamDocumentAccessFactory(
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=document, team="readers", role="reader"
|
||||
)
|
||||
access_editor = factories.TeamDocumentAccessFactory(
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=document, team="editors", role="editor"
|
||||
)
|
||||
access_administrator = factories.TeamDocumentAccessFactory(
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=document, team="administrators", role="administrator"
|
||||
)
|
||||
access_owner = factories.TeamDocumentAccessFactory(
|
||||
document=document, team="owners", role="owner"
|
||||
)
|
||||
other_access = factories.TeamDocumentAccessFactory(document=document)
|
||||
factories.TeamDocumentAccessFactory(document=document, team="owners", role="owner")
|
||||
factories.TeamDocumentAccessFactory(document=document)
|
||||
factories.TeamDocumentAccessFactory()
|
||||
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
|
||||
|
||||
# pylint: disable=R0801
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert sorted(content.pop("accesses"), key=lambda x: x["id"]) == sorted(
|
||||
[
|
||||
{
|
||||
"id": str(access_reader.id),
|
||||
"user": None,
|
||||
"team": "readers",
|
||||
"role": "reader",
|
||||
"abilities": {
|
||||
"destroy": True,
|
||||
"retrieve": True,
|
||||
"set_role_to": ["owner", "administrator", "editor"],
|
||||
"update": True,
|
||||
"partial_update": True,
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": str(access_editor.id),
|
||||
"user": None,
|
||||
"team": "editors",
|
||||
"role": "editor",
|
||||
"abilities": {
|
||||
"destroy": True,
|
||||
"retrieve": True,
|
||||
"set_role_to": ["owner", "administrator", "reader"],
|
||||
"update": True,
|
||||
"partial_update": True,
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": str(access_administrator.id),
|
||||
"user": None,
|
||||
"team": "administrators",
|
||||
"role": "administrator",
|
||||
"abilities": {
|
||||
"destroy": True,
|
||||
"retrieve": True,
|
||||
"set_role_to": ["owner", "editor", "reader"],
|
||||
"update": True,
|
||||
"partial_update": True,
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": str(access_owner.id),
|
||||
"user": None,
|
||||
"team": "owners",
|
||||
"role": "owner",
|
||||
"abilities": {
|
||||
# editable only if there is another owner role than the user's team...
|
||||
"destroy": other_access.role == "owner",
|
||||
"retrieve": True,
|
||||
"set_role_to": ["administrator", "editor", "reader"]
|
||||
if other_access.role == "owner"
|
||||
else [],
|
||||
"update": other_access.role == "owner",
|
||||
"partial_update": other_access.role == "owner",
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": str(other_access.id),
|
||||
"user": None,
|
||||
"team": other_access.team,
|
||||
"role": other_access.role,
|
||||
"abilities": other_access.get_abilities(user),
|
||||
},
|
||||
],
|
||||
key=lambda x: x["id"],
|
||||
)
|
||||
assert response.json() == {
|
||||
"id": str(document.id),
|
||||
"title": document.title,
|
||||
"content": document.content,
|
||||
"abilities": document.get_abilities(user),
|
||||
"content": document.content,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(document.creator.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": "restricted",
|
||||
"link_role": document.link_role,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"nb_accesses": 5,
|
||||
"title": document.title,
|
||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
}
|
||||
|
||||
@@ -132,7 +132,14 @@ def test_api_documents_update_anonymous_or_authenticated_unrelated(
|
||||
document = models.Document.objects.get(pk=document.pk)
|
||||
document_values = serializers.DocumentSerializer(instance=document).data
|
||||
for key, value in document_values.items():
|
||||
if key in ["id", "accesses", "created_at", "link_reach", "link_role"]:
|
||||
if key in [
|
||||
"id",
|
||||
"accesses",
|
||||
"created_at",
|
||||
"creator",
|
||||
"link_reach",
|
||||
"link_role",
|
||||
]:
|
||||
assert value == old_document_values[key]
|
||||
elif key == "updated_at":
|
||||
assert value > old_document_values[key]
|
||||
@@ -216,7 +223,14 @@ def test_api_documents_update_authenticated_editor_administrator_or_owner(
|
||||
document = models.Document.objects.get(pk=document.pk)
|
||||
document_values = serializers.DocumentSerializer(instance=document).data
|
||||
for key, value in document_values.items():
|
||||
if key in ["id", "accesses", "created_at", "link_reach", "link_role"]:
|
||||
if key in [
|
||||
"id",
|
||||
"created_at",
|
||||
"creator",
|
||||
"link_reach",
|
||||
"link_role",
|
||||
"nb_accesses",
|
||||
]:
|
||||
assert value == old_document_values[key]
|
||||
elif key == "updated_at":
|
||||
assert value > old_document_values[key]
|
||||
@@ -255,7 +269,14 @@ def test_api_documents_update_authenticated_owners(via, mock_user_teams):
|
||||
document = models.Document.objects.get(pk=document.pk)
|
||||
document_values = serializers.DocumentSerializer(instance=document).data
|
||||
for key, value in document_values.items():
|
||||
if key in ["id", "accesses", "created_at", "link_reach", "link_role"]:
|
||||
if key in [
|
||||
"id",
|
||||
"created_at",
|
||||
"creator",
|
||||
"link_reach",
|
||||
"link_role",
|
||||
"nb_accesses",
|
||||
]:
|
||||
assert value == old_document_values[key]
|
||||
elif key == "updated_at":
|
||||
assert value > old_document_values[key]
|
||||
|
||||
@@ -22,7 +22,7 @@ def test_api_templates_retrieve_anonymous_public():
|
||||
"abilities": {
|
||||
"destroy": False,
|
||||
"generate_document": True,
|
||||
"manage_accesses": False,
|
||||
"accesses_manage": False,
|
||||
"partial_update": False,
|
||||
"retrieve": True,
|
||||
"update": False,
|
||||
@@ -68,7 +68,7 @@ def test_api_templates_retrieve_authenticated_unrelated_public():
|
||||
"abilities": {
|
||||
"destroy": False,
|
||||
"generate_document": True,
|
||||
"manage_accesses": False,
|
||||
"accesses_manage": False,
|
||||
"partial_update": False,
|
||||
"retrieve": True,
|
||||
"update": False,
|
||||
|
||||
45
src/backend/core/tests/test_api_config.py
Normal file
45
src/backend/core/tests/test_api_config.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""
|
||||
Test config API endpoints in the Impress core app.
|
||||
"""
|
||||
|
||||
from django.test import override_settings
|
||||
|
||||
import pytest
|
||||
from rest_framework.status import (
|
||||
HTTP_200_OK,
|
||||
)
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@override_settings(
|
||||
COLLABORATION_WS_URL="http://testcollab/",
|
||||
CRISP_WEBSITE_ID="123",
|
||||
FRONTEND_THEME="test-theme",
|
||||
MEDIA_BASE_URL="http://testserver/",
|
||||
SENTRY_DSN="https://sentry.test/123",
|
||||
)
|
||||
@pytest.mark.parametrize("is_authenticated", [False, True])
|
||||
def test_api_config(is_authenticated):
|
||||
"""Anonymous users should be allowed to get the configuration."""
|
||||
client = APIClient()
|
||||
|
||||
if is_authenticated:
|
||||
user = factories.UserFactory()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get("/api/v1.0/config/")
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json() == {
|
||||
"COLLABORATION_WS_URL": "http://testcollab/",
|
||||
"CRISP_WEBSITE_ID": "123",
|
||||
"ENVIRONMENT": "test",
|
||||
"FRONTEND_THEME": "test-theme",
|
||||
"LANGUAGES": [["en-us", "English"], ["fr-fr", "French"], ["de-de", "German"]],
|
||||
"LANGUAGE_CODE": "en-us",
|
||||
"MEDIA_BASE_URL": "http://testserver/",
|
||||
"SENTRY_DSN": "https://sentry.test/123",
|
||||
}
|
||||
@@ -69,6 +69,48 @@ def test_api_users_list_query_email():
|
||||
assert user_ids == [str(nicole.id), str(frank.id)]
|
||||
|
||||
|
||||
def test_api_users_list_query_email_matching():
|
||||
"""While filtering by email, results should be filtered and sorted by similarity"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
alice = factories.UserFactory(email="alice.johnson@example.gouv.fr")
|
||||
factories.UserFactory(email="jane.smith@example.gouv.fr")
|
||||
michael_wilson = factories.UserFactory(email="michael.wilson@example.gouv.fr")
|
||||
factories.UserFactory(email="david.jones@example.gouv.fr")
|
||||
michael_brown = factories.UserFactory(email="michael.brown@example.gouv.fr")
|
||||
factories.UserFactory(email="sophia.taylor@example.gouv.fr")
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?q=michael.johnson@example.gouv.f",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
assert user_ids == [str(michael_wilson.id)]
|
||||
|
||||
response = client.get("/api/v1.0/users/?q=michael.johnson@example.gouv.fr")
|
||||
|
||||
assert response.status_code == 200
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
assert user_ids == [str(michael_wilson.id), str(alice.id), str(michael_brown.id)]
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?q=ajohnson@example.gouv.f",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
assert user_ids == [str(alice.id)]
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?q=michael.wilson@example.gouv.f",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
assert user_ids == [str(michael_wilson.id)]
|
||||
|
||||
|
||||
def test_api_users_list_query_email_exclude_doc_user():
|
||||
"""
|
||||
Authenticated users should be able to list users
|
||||
|
||||
@@ -32,15 +32,22 @@ def test_models_documents_id_unique():
|
||||
factories.DocumentFactory(id=document.id)
|
||||
|
||||
|
||||
def test_models_documents_creator_required():
|
||||
"""No field should be required on the Document model."""
|
||||
models.Document.objects.create()
|
||||
|
||||
|
||||
def test_models_documents_title_null():
|
||||
"""The "title" field can be null."""
|
||||
document = models.Document.objects.create(title=None)
|
||||
document = models.Document.objects.create(
|
||||
title=None, creator=factories.UserFactory()
|
||||
)
|
||||
assert document.title is None
|
||||
|
||||
|
||||
def test_models_documents_title_empty():
|
||||
"""The "title" field can be empty."""
|
||||
document = models.Document.objects.create(title="")
|
||||
document = models.Document.objects.create(title="", creator=factories.UserFactory())
|
||||
assert document.title == ""
|
||||
|
||||
|
||||
@@ -83,13 +90,17 @@ def test_models_documents_get_abilities_forbidden(is_authenticated, reach, role)
|
||||
user = factories.UserFactory() if is_authenticated else AnonymousUser()
|
||||
abilities = document.get_abilities(user)
|
||||
assert abilities == {
|
||||
"accesses_manage": False,
|
||||
"accesses_view": False,
|
||||
"ai_transform": False,
|
||||
"ai_translate": False,
|
||||
"attachment_upload": False,
|
||||
"link_configuration": False,
|
||||
"collaboration_auth": False,
|
||||
"destroy": False,
|
||||
"favorite": False,
|
||||
"invite_owner": False,
|
||||
"manage_accesses": False,
|
||||
"media_auth": False,
|
||||
"link_configuration": False,
|
||||
"partial_update": False,
|
||||
"retrieve": False,
|
||||
"update": False,
|
||||
@@ -116,13 +127,17 @@ def test_models_documents_get_abilities_reader(is_authenticated, reach):
|
||||
user = factories.UserFactory() if is_authenticated else AnonymousUser()
|
||||
abilities = document.get_abilities(user)
|
||||
assert abilities == {
|
||||
"accesses_manage": False,
|
||||
"accesses_view": False,
|
||||
"ai_transform": False,
|
||||
"ai_translate": False,
|
||||
"attachment_upload": False,
|
||||
"collaboration_auth": True,
|
||||
"destroy": False,
|
||||
"link_configuration": False,
|
||||
"favorite": is_authenticated,
|
||||
"invite_owner": False,
|
||||
"manage_accesses": False,
|
||||
"link_configuration": False,
|
||||
"media_auth": True,
|
||||
"partial_update": False,
|
||||
"retrieve": True,
|
||||
"update": False,
|
||||
@@ -149,13 +164,17 @@ def test_models_documents_get_abilities_editor(is_authenticated, reach):
|
||||
user = factories.UserFactory() if is_authenticated else AnonymousUser()
|
||||
abilities = document.get_abilities(user)
|
||||
assert abilities == {
|
||||
"accesses_manage": False,
|
||||
"accesses_view": False,
|
||||
"ai_transform": True,
|
||||
"ai_translate": True,
|
||||
"attachment_upload": True,
|
||||
"collaboration_auth": True,
|
||||
"destroy": False,
|
||||
"link_configuration": False,
|
||||
"favorite": is_authenticated,
|
||||
"invite_owner": False,
|
||||
"manage_accesses": False,
|
||||
"link_configuration": False,
|
||||
"media_auth": True,
|
||||
"partial_update": True,
|
||||
"retrieve": True,
|
||||
"update": True,
|
||||
@@ -171,13 +190,17 @@ def test_models_documents_get_abilities_owner():
|
||||
access = factories.UserDocumentAccessFactory(role="owner", user=user)
|
||||
abilities = access.document.get_abilities(access.user)
|
||||
assert abilities == {
|
||||
"accesses_manage": True,
|
||||
"accesses_view": True,
|
||||
"ai_transform": True,
|
||||
"ai_translate": True,
|
||||
"attachment_upload": True,
|
||||
"collaboration_auth": True,
|
||||
"destroy": True,
|
||||
"link_configuration": True,
|
||||
"favorite": True,
|
||||
"invite_owner": True,
|
||||
"manage_accesses": True,
|
||||
"link_configuration": True,
|
||||
"media_auth": True,
|
||||
"partial_update": True,
|
||||
"retrieve": True,
|
||||
"update": True,
|
||||
@@ -192,13 +215,17 @@ def test_models_documents_get_abilities_administrator():
|
||||
access = factories.UserDocumentAccessFactory(role="administrator")
|
||||
abilities = access.document.get_abilities(access.user)
|
||||
assert abilities == {
|
||||
"accesses_manage": True,
|
||||
"accesses_view": True,
|
||||
"ai_transform": True,
|
||||
"ai_translate": True,
|
||||
"attachment_upload": True,
|
||||
"collaboration_auth": True,
|
||||
"destroy": False,
|
||||
"link_configuration": True,
|
||||
"favorite": True,
|
||||
"invite_owner": False,
|
||||
"manage_accesses": True,
|
||||
"link_configuration": True,
|
||||
"media_auth": True,
|
||||
"partial_update": True,
|
||||
"retrieve": True,
|
||||
"update": True,
|
||||
@@ -216,13 +243,17 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
|
||||
abilities = access.document.get_abilities(access.user)
|
||||
|
||||
assert abilities == {
|
||||
"accesses_manage": False,
|
||||
"accesses_view": True,
|
||||
"ai_transform": True,
|
||||
"ai_translate": True,
|
||||
"attachment_upload": True,
|
||||
"collaboration_auth": True,
|
||||
"destroy": False,
|
||||
"link_configuration": False,
|
||||
"favorite": True,
|
||||
"invite_owner": False,
|
||||
"manage_accesses": False,
|
||||
"link_configuration": False,
|
||||
"media_auth": True,
|
||||
"partial_update": True,
|
||||
"retrieve": True,
|
||||
"update": True,
|
||||
@@ -242,13 +273,17 @@ def test_models_documents_get_abilities_reader_user(django_assert_num_queries):
|
||||
abilities = access.document.get_abilities(access.user)
|
||||
|
||||
assert abilities == {
|
||||
"accesses_manage": False,
|
||||
"accesses_view": True,
|
||||
"ai_transform": False,
|
||||
"ai_translate": False,
|
||||
"attachment_upload": False,
|
||||
"collaboration_auth": True,
|
||||
"destroy": False,
|
||||
"link_configuration": False,
|
||||
"favorite": True,
|
||||
"invite_owner": False,
|
||||
"manage_accesses": False,
|
||||
"link_configuration": False,
|
||||
"media_auth": True,
|
||||
"partial_update": False,
|
||||
"retrieve": True,
|
||||
"update": False,
|
||||
@@ -269,13 +304,17 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
|
||||
abilities = access.document.get_abilities(access.user)
|
||||
|
||||
assert abilities == {
|
||||
"accesses_manage": False,
|
||||
"accesses_view": True,
|
||||
"ai_transform": False,
|
||||
"ai_translate": False,
|
||||
"attachment_upload": False,
|
||||
"collaboration_auth": True,
|
||||
"destroy": False,
|
||||
"link_configuration": False,
|
||||
"favorite": True,
|
||||
"invite_owner": False,
|
||||
"manage_accesses": False,
|
||||
"link_configuration": False,
|
||||
"media_auth": True,
|
||||
"partial_update": False,
|
||||
"retrieve": True,
|
||||
"update": False,
|
||||
@@ -388,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
|
||||
@@ -402,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 "editor" "
|
||||
f"on the following document: {document.title}" in email_content
|
||||
)
|
||||
assert f"docs/{document.id}/" in email_content
|
||||
|
||||
@@ -420,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
|
||||
@@ -437,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 "propriétaire" "
|
||||
f"sur le document suivant: {document.title}" in email_content
|
||||
)
|
||||
assert f"docs/{document.id}/" in email_content
|
||||
|
||||
@@ -456,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
|
||||
@@ -472,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)
|
||||
|
||||
@@ -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
|
||||
):
|
||||
|
||||
@@ -62,7 +62,7 @@ def test_models_templates_get_abilities_anonymous_public():
|
||||
"destroy": False,
|
||||
"retrieve": True,
|
||||
"update": False,
|
||||
"manage_accesses": False,
|
||||
"accesses_manage": False,
|
||||
"partial_update": False,
|
||||
"generate_document": True,
|
||||
}
|
||||
@@ -76,7 +76,7 @@ def test_models_templates_get_abilities_anonymous_not_public():
|
||||
"destroy": False,
|
||||
"retrieve": False,
|
||||
"update": False,
|
||||
"manage_accesses": False,
|
||||
"accesses_manage": False,
|
||||
"partial_update": False,
|
||||
"generate_document": False,
|
||||
}
|
||||
@@ -90,7 +90,7 @@ def test_models_templates_get_abilities_authenticated_public():
|
||||
"destroy": False,
|
||||
"retrieve": True,
|
||||
"update": False,
|
||||
"manage_accesses": False,
|
||||
"accesses_manage": False,
|
||||
"partial_update": False,
|
||||
"generate_document": True,
|
||||
}
|
||||
@@ -104,7 +104,7 @@ def test_models_templates_get_abilities_authenticated_not_public():
|
||||
"destroy": False,
|
||||
"retrieve": False,
|
||||
"update": False,
|
||||
"manage_accesses": False,
|
||||
"accesses_manage": False,
|
||||
"partial_update": False,
|
||||
"generate_document": False,
|
||||
}
|
||||
@@ -119,7 +119,7 @@ def test_models_templates_get_abilities_owner():
|
||||
"destroy": True,
|
||||
"retrieve": True,
|
||||
"update": True,
|
||||
"manage_accesses": True,
|
||||
"accesses_manage": True,
|
||||
"partial_update": True,
|
||||
"generate_document": True,
|
||||
}
|
||||
@@ -133,7 +133,7 @@ def test_models_templates_get_abilities_administrator():
|
||||
"destroy": False,
|
||||
"retrieve": True,
|
||||
"update": True,
|
||||
"manage_accesses": True,
|
||||
"accesses_manage": True,
|
||||
"partial_update": True,
|
||||
"generate_document": True,
|
||||
}
|
||||
@@ -150,7 +150,7 @@ def test_models_templates_get_abilities_editor_user(django_assert_num_queries):
|
||||
"destroy": False,
|
||||
"retrieve": True,
|
||||
"update": True,
|
||||
"manage_accesses": False,
|
||||
"accesses_manage": False,
|
||||
"partial_update": True,
|
||||
"generate_document": True,
|
||||
}
|
||||
@@ -167,7 +167,7 @@ def test_models_templates_get_abilities_reader_user(django_assert_num_queries):
|
||||
"destroy": False,
|
||||
"retrieve": True,
|
||||
"update": False,
|
||||
"manage_accesses": False,
|
||||
"accesses_manage": False,
|
||||
"partial_update": False,
|
||||
"generate_document": True,
|
||||
}
|
||||
@@ -185,7 +185,7 @@ def test_models_templates_get_abilities_preset_role(django_assert_num_queries):
|
||||
"destroy": False,
|
||||
"retrieve": True,
|
||||
"update": False,
|
||||
"manage_accesses": False,
|
||||
"accesses_manage": False,
|
||||
"partial_update": False,
|
||||
"generate_document": True,
|
||||
}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
185
src/backend/core/tests/test_services_collaboration_services.py
Normal file
185
src/backend/core/tests/test_services_collaboration_services.py
Normal 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
|
||||
147
src/backend/core/tests/test_services_converter_services.py
Normal file
147
src/backend/core/tests/test_services_converter_services.py
Normal 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)
|
||||
@@ -55,4 +55,5 @@ urlpatterns = [
|
||||
]
|
||||
),
|
||||
),
|
||||
path(f"api/{settings.API_VERSION}/config/", viewsets.ConfigView.as_view()),
|
||||
]
|
||||
|
||||
@@ -132,10 +132,13 @@ def create_demo(stdout):
|
||||
)
|
||||
queue.flush()
|
||||
|
||||
users_ids = list(models.User.objects.values_list("id", flat=True))
|
||||
|
||||
with Timeit(stdout, "Creating documents"):
|
||||
for _ in range(defaults.NB_OBJECTS["docs"]):
|
||||
queue.push(
|
||||
models.Document(
|
||||
creator_id=random.choice(users_ids),
|
||||
title=fake.sentence(nb_words=4),
|
||||
link_reach=models.LinkReachChoices.AUTHENTICATED
|
||||
if random_true_with_probability(0.5)
|
||||
@@ -147,7 +150,6 @@ def create_demo(stdout):
|
||||
|
||||
with Timeit(stdout, "Creating docs accesses"):
|
||||
docs_ids = list(models.Document.objects.values_list("id", flat=True))
|
||||
users_ids = list(models.User.objects.values_list("id", flat=True))
|
||||
for doc_id in docs_ids:
|
||||
for user_id in random.sample(
|
||||
users_ids,
|
||||
|
||||
@@ -10,8 +10,9 @@ For the full list of settings and their values, see
|
||||
https://docs.djangoproject.com/en/3.1/ref/settings/
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import tomllib
|
||||
from socket import gethostbyname, gethostname
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
@@ -27,19 +28,12 @@ DATA_DIR = os.path.join("/", "data")
|
||||
def get_release():
|
||||
"""
|
||||
Get the current release of the application
|
||||
|
||||
By release, we mean the release from the version.json file à la Mozilla [1]
|
||||
(if any). If this file has not been found, it defaults to "NA".
|
||||
|
||||
[1]
|
||||
https://github.com/mozilla-services/Dockerflow/blob/master/docs/version_object.md
|
||||
"""
|
||||
# Try to get the current release from the version.json file generated by the
|
||||
# CI during the Docker image build
|
||||
try:
|
||||
with open(os.path.join(BASE_DIR, "version.json"), encoding="utf8") as version:
|
||||
return json.load(version)["version"]
|
||||
except FileNotFoundError:
|
||||
with open(os.path.join(BASE_DIR, "pyproject.toml"), "rb") as f:
|
||||
pyproject_data = tomllib.load(f)
|
||||
return pyproject_data["project"]["version"]
|
||||
except (FileNotFoundError, KeyError):
|
||||
return "NA" # Default: not available
|
||||
|
||||
|
||||
@@ -56,7 +50,7 @@ class Base(Configuration):
|
||||
You may also want to override default configuration by setting the following environment
|
||||
variables:
|
||||
|
||||
* DJANGO_SENTRY_DSN
|
||||
* SENTRY_DSN
|
||||
* DB_NAME
|
||||
* DB_HOST
|
||||
* DB_PASSWORD
|
||||
@@ -71,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"
|
||||
@@ -104,6 +99,9 @@ class Base(Configuration):
|
||||
STATIC_ROOT = os.path.join(DATA_DIR, "static")
|
||||
MEDIA_URL = "/media/"
|
||||
MEDIA_ROOT = os.path.join(DATA_DIR, "media")
|
||||
MEDIA_BASE_URL = values.Value(
|
||||
None, environ_name="MEDIA_BASE_URL", environ_prefix=None
|
||||
)
|
||||
|
||||
SITE_ID = 1
|
||||
|
||||
@@ -237,6 +235,7 @@ class Base(Configuration):
|
||||
(
|
||||
("en-us", _("English")),
|
||||
("fr-fr", _("French")),
|
||||
("de-de", _("German")),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -353,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)
|
||||
@@ -371,7 +372,28 @@ class Base(Configuration):
|
||||
CORS_ALLOWED_ORIGIN_REGEXES = values.ListValue([])
|
||||
|
||||
# Sentry
|
||||
SENTRY_DSN = values.Value(None, environ_name="SENTRY_DSN")
|
||||
SENTRY_DSN = values.Value(None, environ_name="SENTRY_DSN", environ_prefix=None)
|
||||
|
||||
# Collaboration
|
||||
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
|
||||
FRONTEND_THEME = values.Value(
|
||||
None, environ_name="FRONTEND_THEME", environ_prefix=None
|
||||
)
|
||||
|
||||
# Crisp
|
||||
CRISP_WEBSITE_ID = values.Value(
|
||||
None, environ_name="CRISP_WEBSITE_ID", environ_prefix=None
|
||||
)
|
||||
|
||||
# Easy thumbnails
|
||||
THUMBNAIL_EXTENSION = "webp"
|
||||
@@ -452,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)
|
||||
@@ -470,17 +505,74 @@ 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,
|
||||
)
|
||||
|
||||
# Logging
|
||||
# We want to make it easy to log to console but by default we log production
|
||||
# to Sentry and don't want to log to console.
|
||||
LOGGING = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"handlers": {
|
||||
"console": {
|
||||
"class": "logging.StreamHandler",
|
||||
"level": values.Value(
|
||||
"ERROR",
|
||||
environ_name="LOGGING_LEVEL_HANDLERS_CONSOLE",
|
||||
environ_prefix=None,
|
||||
),
|
||||
},
|
||||
},
|
||||
# Override root logger to send it to console
|
||||
"root": {
|
||||
"handlers": ["console"],
|
||||
"level": values.Value(
|
||||
"INFO", environ_name="LOGGING_LEVEL_LOGGERS_ROOT", environ_prefix=None
|
||||
),
|
||||
},
|
||||
"loggers": {
|
||||
"core": {
|
||||
"handlers": ["console"],
|
||||
"level": values.Value(
|
||||
"INFO",
|
||||
environ_name="LOGGING_LEVEL_LOGGERS_APP",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"propagate": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
@property
|
||||
def ENVIRONMENT(self):
|
||||
@@ -576,23 +668,6 @@ class Development(Base):
|
||||
class Test(Base):
|
||||
"""Test environment settings"""
|
||||
|
||||
LOGGING = values.DictValue(
|
||||
{
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"handlers": {
|
||||
"console": {
|
||||
"class": "logging.StreamHandler",
|
||||
},
|
||||
},
|
||||
"loggers": {
|
||||
"impress": {
|
||||
"handlers": ["console"],
|
||||
"level": "DEBUG",
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
PASSWORD_HASHERS = [
|
||||
"django.contrib.auth.hashers.MD5PasswordHasher",
|
||||
]
|
||||
@@ -623,7 +698,13 @@ class Production(Base):
|
||||
"""
|
||||
|
||||
# Security
|
||||
ALLOWED_HOSTS = values.ListValue(None)
|
||||
# Add allowed host from environment variables.
|
||||
# The machine hostname is added by default,
|
||||
# it makes the application pingable by a load balancer on the same machine by example
|
||||
ALLOWED_HOSTS = [
|
||||
*values.ListValue([], environ_name="ALLOWED_HOSTS"),
|
||||
gethostbyname(gethostname()),
|
||||
]
|
||||
CSRF_TRUSTED_ORIGINS = values.ListValue([])
|
||||
SECURE_BROWSER_XSS_FILTER = True
|
||||
SECURE_CONTENT_TYPE_NOSNIFF = True
|
||||
|
||||
BIN
src/backend/locale/de_DE/LC_MESSAGES/django.mo
Normal file
BIN
src/backend/locale/de_DE/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
394
src/backend/locale/de_DE/LC_MESSAGES/django.po
Normal file
394
src/backend/locale/de_DE/LC_MESSAGES/django.po
Normal file
@@ -0,0 +1,394 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-people\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2024-12-17 15:50+0000\n"
|
||||
"PO-Revision-Date: 2024-12-17 15:53\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: German\n"
|
||||
"Language: de_DE\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
"X-Crowdin-Project: lasuite-people\n"
|
||||
"X-Crowdin-Project-ID: 637934\n"
|
||||
"X-Crowdin-Language: de\n"
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 8\n"
|
||||
|
||||
#: core/admin.py:33
|
||||
msgid "Personal info"
|
||||
msgstr "Persönliche Daten"
|
||||
|
||||
#: core/admin.py:46
|
||||
msgid "Permissions"
|
||||
msgstr "Berechtigungen"
|
||||
|
||||
#: core/admin.py:58
|
||||
msgid "Important dates"
|
||||
msgstr "Wichtige Daten"
|
||||
|
||||
#: 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 "Inhalt"
|
||||
|
||||
#: core/api/serializers.py:417
|
||||
msgid "Body type"
|
||||
msgstr "Typ"
|
||||
|
||||
#: core/api/serializers.py:423
|
||||
msgid "Format"
|
||||
msgstr "Format"
|
||||
|
||||
#: core/authentication/backends.py:57
|
||||
msgid "Invalid response format or token verification failed"
|
||||
msgstr ""
|
||||
|
||||
#: core/authentication/backends.py:81
|
||||
msgid "User info contained no recognizable user identification"
|
||||
msgstr ""
|
||||
|
||||
#: core/authentication/backends.py:88
|
||||
msgid "User account is disabled"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:62 core/models.py:69
|
||||
msgid "Reader"
|
||||
msgstr "Lesen"
|
||||
|
||||
#: core/models.py:63 core/models.py:70
|
||||
msgid "Editor"
|
||||
msgstr "Bearbeiten"
|
||||
|
||||
#: core/models.py:71
|
||||
msgid "Administrator"
|
||||
msgstr "Administrator"
|
||||
|
||||
#: core/models.py:72
|
||||
msgid "Owner"
|
||||
msgstr "Besitzer"
|
||||
|
||||
#: core/models.py:83
|
||||
msgid "Restricted"
|
||||
msgstr "Beschränkt"
|
||||
|
||||
#: core/models.py:87
|
||||
msgid "Authenticated"
|
||||
msgstr "Authentifiziert"
|
||||
|
||||
#: core/models.py:89
|
||||
msgid "Public"
|
||||
msgstr "Öffentlich"
|
||||
|
||||
#: core/models.py:101
|
||||
msgid "id"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:102
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:108
|
||||
msgid "created on"
|
||||
msgstr "Erstellt"
|
||||
|
||||
#: core/models.py:109
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr "Datum und Uhrzeit, an dem ein Datensatz erstellt wurde"
|
||||
|
||||
#: core/models.py:114
|
||||
msgid "updated on"
|
||||
msgstr "Aktualisiert"
|
||||
|
||||
#: 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:141
|
||||
msgid "sub"
|
||||
msgstr ""
|
||||
|
||||
#: 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:160
|
||||
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:177
|
||||
msgid "device"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:179
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:182
|
||||
msgid "staff status"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:184
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:187
|
||||
msgid "active"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:190
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:202
|
||||
msgid "user"
|
||||
msgstr "Benutzer"
|
||||
|
||||
#: core/models.py:203
|
||||
msgid "users"
|
||||
msgstr "Benutzer"
|
||||
|
||||
#: core/models.py:342 core/models.py:718
|
||||
msgid "title"
|
||||
msgstr "Titel"
|
||||
|
||||
#: core/models.py:364
|
||||
msgid "Document"
|
||||
msgstr "Dokument"
|
||||
|
||||
#: core/models.py:365
|
||||
msgid "Documents"
|
||||
msgstr "Dokumente"
|
||||
|
||||
#: 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: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:624
|
||||
msgid "Document/user link traces"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:630
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr ""
|
||||
|
||||
#: 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:683
|
||||
msgid "Document/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:689
|
||||
msgid "This user is already in this document."
|
||||
msgstr "Dieser Benutzer befindet sich bereits in diesem Dokument."
|
||||
|
||||
#: core/models.py:695
|
||||
msgid "This team is already in this document."
|
||||
msgstr "Dieses Team befindet sich bereits in diesem Dokument."
|
||||
|
||||
#: core/models.py:701 core/models.py:890
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Benutzer oder Team müssen gesetzt werden, nicht beides."
|
||||
|
||||
#: core/models.py:719
|
||||
msgid "description"
|
||||
msgstr "Beschreibung"
|
||||
|
||||
#: core/models.py:720
|
||||
msgid "code"
|
||||
msgstr "Code"
|
||||
|
||||
#: core/models.py:721
|
||||
msgid "css"
|
||||
msgstr "CSS"
|
||||
|
||||
#: core/models.py:723
|
||||
msgid "public"
|
||||
msgstr "öffentlich"
|
||||
|
||||
#: core/models.py:725
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr "Ob diese Vorlage für jedermann öffentlich ist."
|
||||
|
||||
#: core/models.py:731
|
||||
msgid "Template"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:732
|
||||
msgid "Templates"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:871
|
||||
msgid "Template/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:872
|
||||
msgid "Template/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:878
|
||||
msgid "This user is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:884
|
||||
msgid "This team is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:907
|
||||
msgid "email address"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:926
|
||||
msgid "Document invitation"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:927
|
||||
msgid "Document invitations"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:944
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/hello.html:159 core/templates/mail/text/hello.txt:3
|
||||
msgid "Company logo"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
|
||||
#, python-format
|
||||
msgid "Hello %(name)s"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
|
||||
msgid "Hello"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/hello.html:189 core/templates/mail/text/hello.txt:6
|
||||
msgid "Thank you very much for your visit!"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/hello.html:221
|
||||
#, python-format
|
||||
msgid "This mail has been sent to %(email)s by <a href=\"%(href)s\">%(name)s</a>"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:162
|
||||
#: core/templates/mail/text/invitation.txt:3
|
||||
msgid "Logo email"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:209
|
||||
#: core/templates/mail/text/invitation.txt:10
|
||||
msgid "Open"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:226
|
||||
#: core/templates/mail/text/invitation.txt:14
|
||||
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:233
|
||||
#: core/templates/mail/text/invitation.txt:16
|
||||
#, python-format
|
||||
msgid " Brought to you by %(brandname)s "
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/text/hello.txt:8
|
||||
#, python-format
|
||||
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
|
||||
msgstr ""
|
||||
|
||||
#: impress/settings.py:236
|
||||
msgid "English"
|
||||
msgstr ""
|
||||
|
||||
#: impress/settings.py:237
|
||||
msgid "French"
|
||||
msgstr ""
|
||||
|
||||
#: impress/settings.py:238
|
||||
msgid "German"
|
||||
msgstr ""
|
||||
|
||||
Binary file not shown.
@@ -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,11 +380,15 @@ msgstr ""
|
||||
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
|
||||
msgstr ""
|
||||
|
||||
#: impress/settings.py:176
|
||||
#: impress/settings.py:236
|
||||
msgid "English"
|
||||
msgstr ""
|
||||
|
||||
#: impress/settings.py:177
|
||||
#: impress/settings.py:237
|
||||
msgid "French"
|
||||
msgstr ""
|
||||
|
||||
#: impress/settings.py:238
|
||||
msgid "German"
|
||||
msgstr ""
|
||||
|
||||
|
||||
Binary file not shown.
@@ -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,48 +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:176
|
||||
#: impress/settings.py:236
|
||||
msgid "English"
|
||||
msgstr ""
|
||||
|
||||
#: impress/settings.py:177
|
||||
#: impress/settings.py:237
|
||||
msgid "French"
|
||||
msgstr ""
|
||||
|
||||
#: impress/settings.py:238
|
||||
msgid "German"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "impress"
|
||||
version = "1.7.0"
|
||||
version = "1.10.0"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -17,28 +17,29 @@ classifiers = [
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Natural Language :: English",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
]
|
||||
description = "An application to print markdown to pdf from a set of managed templates."
|
||||
keywords = ["Django", "Contacts", "Templates", "RBAC"]
|
||||
license = { file = "LICENSE" }
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
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",
|
||||
@@ -46,17 +47,17 @@ dependencies = [
|
||||
"jsonschema==4.23.0",
|
||||
"markdown==3.7",
|
||||
"nested-multipart-parser==1.5.0",
|
||||
"openai==1.52.0",
|
||||
"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",
|
||||
]
|
||||
|
||||
@@ -69,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",
|
||||
]
|
||||
|
||||
@@ -127,6 +128,7 @@ select = [
|
||||
[tool.ruff.lint.isort]
|
||||
section-order = ["future","standard-library","django","third-party","impress","first-party","local-folder"]
|
||||
sections = { impress=["core"], django=["django"] }
|
||||
extra-standard-library = ["tomllib"]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"**/tests/*" = ["S", "SLF"]
|
||||
|
||||
@@ -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
|
||||
@@ -61,18 +33,9 @@ FROM impress AS impress-builder
|
||||
|
||||
WORKDIR /home/frontend/apps/impress
|
||||
|
||||
ARG FRONTEND_THEME
|
||||
ENV NEXT_PUBLIC_THEME=${FRONTEND_THEME}
|
||||
|
||||
ARG Y_PROVIDER_URL
|
||||
ENV NEXT_PUBLIC_Y_PROVIDER_URL=${Y_PROVIDER_URL}
|
||||
|
||||
ARG API_ORIGIN
|
||||
ENV NEXT_PUBLIC_API_ORIGIN=${API_ORIGIN}
|
||||
|
||||
ARG MEDIA_URL
|
||||
ENV NEXT_PUBLIC_MEDIA_URL=${MEDIA_URL}
|
||||
|
||||
ARG SW_DEACTIVATED
|
||||
ENV NEXT_PUBLIC_SW_DEACTIVATED=${SW_DEACTIVATED}
|
||||
|
||||
|
||||
@@ -144,7 +144,7 @@ export const mockedDocument = async (page: Page, json: object) => {
|
||||
versions_destroy: false,
|
||||
versions_list: true,
|
||||
versions_retrieve: true,
|
||||
manage_accesses: false, // Means not admin
|
||||
accesses_manage: false, // Means not admin
|
||||
update: false,
|
||||
partial_update: false, // Means not editor
|
||||
retrieve: true,
|
||||
|
||||
161
src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts
Normal file
161
src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import path from 'path';
|
||||
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { createDoc } from './common';
|
||||
|
||||
const config = {
|
||||
CRISP_WEBSITE_ID: null,
|
||||
COLLABORATION_WS_URL: 'ws://localhost:8083/collaboration/ws/',
|
||||
ENVIRONMENT: 'development',
|
||||
FRONTEND_THEME: 'dsfr',
|
||||
MEDIA_BASE_URL: 'http://localhost:8083',
|
||||
LANGUAGES: [
|
||||
['en-us', 'English'],
|
||||
['fr-fr', 'French'],
|
||||
['de-de', 'German'],
|
||||
],
|
||||
LANGUAGE_CODE: 'en-us',
|
||||
SENTRY_DSN: null,
|
||||
};
|
||||
|
||||
test.describe('Config', () => {
|
||||
test('it checks the config api is called', async ({ page }) => {
|
||||
const responsePromise = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/config/') && response.status() === 200,
|
||||
);
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
const response = await responsePromise;
|
||||
expect(response.ok()).toBeTruthy();
|
||||
|
||||
expect(await response.json()).toStrictEqual(config);
|
||||
});
|
||||
|
||||
test('it checks that sentry is trying to init from config endpoint', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.route('**/api/v1.0/config/', async (route) => {
|
||||
const request = route.request();
|
||||
if (request.method().includes('GET')) {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
...config,
|
||||
SENTRY_DSN: 'https://sentry.io/123',
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
const invalidMsg = 'Invalid Sentry Dsn: https://sentry.io/123';
|
||||
const consoleMessage = page.waitForEvent('console', {
|
||||
timeout: 5000,
|
||||
predicate: (msg) => msg.text().includes(invalidMsg),
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
expect((await consoleMessage).text()).toContain(invalidMsg);
|
||||
});
|
||||
|
||||
test('it checks that theme is configured from config endpoint', async ({
|
||||
page,
|
||||
}) => {
|
||||
const responsePromise = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/config/') && response.status() === 200,
|
||||
);
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
const response = await responsePromise;
|
||||
expect(response.ok()).toBeTruthy();
|
||||
|
||||
const jsonResponse = await response.json();
|
||||
expect(jsonResponse.FRONTEND_THEME).toStrictEqual('dsfr');
|
||||
|
||||
const footer = page.locator('footer').first();
|
||||
// alt 'Gouvernement Logo' comes from the theme
|
||||
await expect(footer.getByAltText('Gouvernement Logo')).toBeVisible();
|
||||
});
|
||||
|
||||
test('it checks that media server is configured from config endpoint', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
await page.goto('/');
|
||||
|
||||
await createDoc(page, 'doc-media', browserName, 1);
|
||||
|
||||
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();
|
||||
|
||||
const fileChooser = await fileChooserPromise;
|
||||
await fileChooser.setFiles(
|
||||
path.join(__dirname, 'assets/logo-suite-numerique.png'),
|
||||
);
|
||||
|
||||
const image = page.getByRole('img', { name: 'logo-suite-numerique.png' });
|
||||
|
||||
await expect(image).toBeVisible();
|
||||
|
||||
// Check src of image
|
||||
expect(await image.getAttribute('src')).toMatch(
|
||||
/http:\/\/localhost:8083\/media\/.*\/attachments\/.*.png/,
|
||||
);
|
||||
});
|
||||
|
||||
test('it checks that collaboration server is configured from config endpoint', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
const webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
|
||||
return webSocket.url().includes('ws://localhost:8083/collaboration/ws/');
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
const randomDoc = await createDoc(
|
||||
page,
|
||||
'doc-collaboration',
|
||||
browserName,
|
||||
1,
|
||||
);
|
||||
await expect(page.locator('h2').getByText(randomDoc[0])).toBeVisible();
|
||||
|
||||
const webSocket = await webSocketPromise;
|
||||
expect(webSocket.url()).toContain('ws://localhost:8083/collaboration/ws/');
|
||||
});
|
||||
|
||||
test('it checks that Crisp is trying to init from config endpoint', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.route('**/api/v1.0/config/', async (route) => {
|
||||
const request = route.request();
|
||||
if (request.method().includes('GET')) {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
...config,
|
||||
CRISP_WEBSITE_ID: '1234',
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
await expect(
|
||||
page.locator('#crisp-chatbox').getByText('Invalid website'),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -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('/');
|
||||
@@ -29,3 +29,47 @@ test.describe('Doc Create', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -81,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 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();
|
||||
});
|
||||
|
||||
@@ -190,7 +233,7 @@ test.describe('Doc Editor', () => {
|
||||
test.skip(browserName === 'webkit', 'This test is very flaky with webkit');
|
||||
|
||||
// Check the first doc
|
||||
const doc = await goToGridDoc(page);
|
||||
const [doc] = await createDoc(page, 'doc-quit-1', browserName, 1);
|
||||
await expect(page.locator('h2').getByText(doc)).toBeVisible();
|
||||
|
||||
const editor = page.locator('.ProseMirror');
|
||||
@@ -215,7 +258,7 @@ test.describe('Doc Editor', () => {
|
||||
versions_destroy: false,
|
||||
versions_list: true,
|
||||
versions_retrieve: true,
|
||||
manage_accesses: false, // Means not admin
|
||||
accesses_manage: false, // Means not admin
|
||||
update: false,
|
||||
partial_update: false, // Means not editor
|
||||
retrieve: true,
|
||||
@@ -229,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');
|
||||
|
||||
|
||||
@@ -303,7 +303,7 @@ test.describe('Documents Grid mobile', () => {
|
||||
attachment_upload: true,
|
||||
destroy: true,
|
||||
link_configuration: true,
|
||||
manage_accesses: true,
|
||||
accesses_manage: true,
|
||||
partial_update: true,
|
||||
retrieve: true,
|
||||
update: true,
|
||||
|
||||
@@ -45,7 +45,7 @@ test.describe('Doc Header', () => {
|
||||
versions_destroy: true,
|
||||
versions_list: true,
|
||||
versions_retrieve: true,
|
||||
manage_accesses: true,
|
||||
accesses_manage: true,
|
||||
update: true,
|
||||
partial_update: true,
|
||||
retrieve: true,
|
||||
@@ -65,9 +65,6 @@ test.describe('Doc Header', () => {
|
||||
await expect(
|
||||
card.getByText('Created at 09/01/2021, 11:00 AM'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
card.getByText('Owners: Super Owner / super2@owner.com'),
|
||||
).toBeVisible();
|
||||
await expect(card.getByText('Your role: Owner')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
|
||||
});
|
||||
@@ -123,6 +120,9 @@ test.describe('Doc Header', () => {
|
||||
|
||||
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(' ');
|
||||
@@ -177,12 +177,13 @@ test.describe('Doc Header', () => {
|
||||
test('it checks the options available if administrator', async ({ page }) => {
|
||||
await mockedDocument(page, {
|
||||
abilities: {
|
||||
accesses_manage: true, // Means admin
|
||||
accesses_view: true,
|
||||
destroy: false, // Means not owner
|
||||
link_configuration: true,
|
||||
versions_destroy: true,
|
||||
versions_list: true,
|
||||
versions_retrieve: true,
|
||||
manage_accesses: true, // Means admin
|
||||
update: true,
|
||||
partial_update: true,
|
||||
retrieve: true,
|
||||
@@ -247,12 +248,13 @@ test.describe('Doc Header', () => {
|
||||
test('it checks the options available if editor', async ({ page }) => {
|
||||
await mockedDocument(page, {
|
||||
abilities: {
|
||||
accesses_manage: false, // Means not admin
|
||||
accesses_view: true,
|
||||
destroy: false, // Means not owner
|
||||
link_configuration: false,
|
||||
versions_destroy: true,
|
||||
versions_list: true,
|
||||
versions_retrieve: true,
|
||||
manage_accesses: false, // Means not admin
|
||||
update: true,
|
||||
partial_update: true, // Means editor
|
||||
retrieve: true,
|
||||
@@ -324,12 +326,13 @@ test.describe('Doc Header', () => {
|
||||
test('it checks the options available if reader', async ({ page }) => {
|
||||
await mockedDocument(page, {
|
||||
abilities: {
|
||||
accesses_manage: false, // Means not admin
|
||||
accesses_view: true,
|
||||
destroy: false, // Means not owner
|
||||
link_configuration: false,
|
||||
versions_destroy: false,
|
||||
versions_list: true,
|
||||
versions_retrieve: true,
|
||||
manage_accesses: false, // Means not admin
|
||||
update: false,
|
||||
partial_update: false, // Means not editor
|
||||
retrieve: true,
|
||||
@@ -489,7 +492,7 @@ test.describe('Documents Header mobile', () => {
|
||||
versions_destroy: true,
|
||||
versions_list: true,
|
||||
versions_retrieve: true,
|
||||
manage_accesses: true,
|
||||
accesses_manage: true,
|
||||
update: true,
|
||||
partial_update: true,
|
||||
retrieve: true,
|
||||
|
||||
@@ -40,20 +40,20 @@ test.describe('Doc Visibility', () => {
|
||||
name: 'Visibility',
|
||||
});
|
||||
|
||||
await expect(selectVisibility.getByText('Authenticated')).toBeVisible();
|
||||
await expect(selectVisibility.getByText('Restricted')).toBeVisible();
|
||||
|
||||
await expect(page.getByLabel('Read only')).toBeVisible();
|
||||
await expect(page.getByLabel('Can read and edit')).toBeVisible();
|
||||
await expect(page.getByLabel('Read only')).toBeHidden();
|
||||
await expect(page.getByLabel('Can read and edit')).toBeHidden();
|
||||
|
||||
await selectVisibility.click();
|
||||
await page
|
||||
.getByRole('option', {
|
||||
name: 'Restricted',
|
||||
name: 'Authenticated',
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect(page.getByLabel('Read only')).toBeHidden();
|
||||
await expect(page.getByLabel('Can read and edit')).toBeHidden();
|
||||
await expect(page.getByLabel('Read only')).toBeVisible();
|
||||
await expect(page.getByLabel('Can read and edit')).toBeVisible();
|
||||
|
||||
await selectVisibility.click();
|
||||
|
||||
@@ -87,26 +87,6 @@ test.describe('Doc Visibility: Restricted', () => {
|
||||
|
||||
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
await page
|
||||
.getByRole('combobox', {
|
||||
name: 'Visibility',
|
||||
})
|
||||
.click();
|
||||
await page
|
||||
.getByRole('option', {
|
||||
name: 'Restricted',
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByText('The document visibility has been updated.'),
|
||||
).toBeVisible();
|
||||
|
||||
await page.locator('.c__modal__backdrop').click({
|
||||
position: { x: 0, y: 0 },
|
||||
});
|
||||
|
||||
const urlDoc = page.url();
|
||||
|
||||
await page
|
||||
@@ -133,26 +113,6 @@ test.describe('Doc Visibility: Restricted', () => {
|
||||
|
||||
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
await page
|
||||
.getByRole('combobox', {
|
||||
name: 'Visibility',
|
||||
})
|
||||
.click();
|
||||
await page
|
||||
.getByRole('option', {
|
||||
name: 'Restricted',
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByText('The document visibility has been updated.'),
|
||||
).toBeVisible();
|
||||
|
||||
await page.locator('.c__modal__backdrop').click({
|
||||
position: { x: 0, y: 0 },
|
||||
});
|
||||
|
||||
const urlDoc = page.url();
|
||||
|
||||
await page
|
||||
@@ -182,20 +142,6 @@ test.describe('Doc Visibility: Restricted', () => {
|
||||
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
await page
|
||||
.getByRole('combobox', {
|
||||
name: 'Visibility',
|
||||
})
|
||||
.click();
|
||||
await page
|
||||
.getByRole('option', {
|
||||
name: 'Restricted',
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByText('The document visibility has been updated.'),
|
||||
).toBeVisible();
|
||||
|
||||
const inputSearch = page.getByLabel(/Find a member to add to the document/);
|
||||
|
||||
@@ -389,6 +335,26 @@ test.describe('Doc Visibility: Authenticated', () => {
|
||||
|
||||
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
await page
|
||||
.getByRole('combobox', {
|
||||
name: 'Visibility',
|
||||
})
|
||||
.click();
|
||||
await page
|
||||
.getByRole('option', {
|
||||
name: 'Authenticated',
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByText('The document visibility has been updated.'),
|
||||
).toBeVisible();
|
||||
|
||||
await page.locator('.c__modal__backdrop').click({
|
||||
position: { x: 0, y: 0 },
|
||||
});
|
||||
|
||||
const urlDoc = page.url();
|
||||
|
||||
await page
|
||||
@@ -421,6 +387,26 @@ test.describe('Doc Visibility: Authenticated', () => {
|
||||
|
||||
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
await page
|
||||
.getByRole('combobox', {
|
||||
name: 'Visibility',
|
||||
})
|
||||
.click();
|
||||
await page
|
||||
.getByRole('option', {
|
||||
name: 'Authenticated',
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByText('The document visibility has been updated.'),
|
||||
).toBeVisible();
|
||||
|
||||
await page.locator('.c__modal__backdrop').click({
|
||||
position: { x: 0, y: 0 },
|
||||
});
|
||||
|
||||
const urlDoc = page.url();
|
||||
|
||||
await page
|
||||
@@ -435,10 +421,20 @@ test.describe('Doc Visibility: Authenticated', () => {
|
||||
await page.goto(urlDoc);
|
||||
|
||||
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
await expect(
|
||||
page.getByText('Read only, you cannot edit this document'),
|
||||
).toBeVisible();
|
||||
|
||||
const shareModal = page.getByLabel('Share modal');
|
||||
|
||||
await expect(
|
||||
shareModal.getByRole('combobox', {
|
||||
name: 'Visibility',
|
||||
}),
|
||||
).toHaveAttribute('disabled');
|
||||
await expect(shareModal.getByText('Search by email')).toBeHidden();
|
||||
await expect(shareModal.getByLabel('List members card')).toBeHidden();
|
||||
});
|
||||
|
||||
test('It checks a authenticated doc in editable mode', async ({
|
||||
@@ -457,6 +453,26 @@ test.describe('Doc Visibility: Authenticated', () => {
|
||||
|
||||
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
await page
|
||||
.getByRole('combobox', {
|
||||
name: 'Visibility',
|
||||
})
|
||||
.click();
|
||||
await page
|
||||
.getByRole('option', {
|
||||
name: 'Authenticated',
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByText('The document visibility has been updated.'),
|
||||
).toBeVisible();
|
||||
|
||||
await page.locator('.c__modal__backdrop').click({
|
||||
position: { x: 0, y: 0 },
|
||||
});
|
||||
|
||||
const urlDoc = page.url();
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
@@ -483,9 +499,19 @@ test.describe('Doc Visibility: Authenticated', () => {
|
||||
await page.goto(urlDoc);
|
||||
|
||||
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
await expect(
|
||||
page.getByText('Read only, you cannot edit this document'),
|
||||
).toBeHidden();
|
||||
|
||||
const shareModal = page.getByLabel('Share modal');
|
||||
|
||||
await expect(
|
||||
shareModal.getByRole('combobox', {
|
||||
name: 'Visibility',
|
||||
}),
|
||||
).toHaveAttribute('disabled');
|
||||
await expect(shareModal.getByText('Search by email')).toBeHidden();
|
||||
await expect(shareModal.getByLabel('List members card')).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +24,18 @@ test.describe('Language', () => {
|
||||
name: 'Créer un nouveau document',
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
await header.getByRole('combobox').getByText('Français').click();
|
||||
await header.getByRole('option', { name: 'Deutsch' }).click();
|
||||
await expect(
|
||||
header.getByRole('combobox').getByText('Deutsch'),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: 'Neues Dokument erstellen',
|
||||
}),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('checks that backend uses the same language as the frontend', async ({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-e2e",
|
||||
"version": "1.7.0",
|
||||
"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.48.1",
|
||||
"@playwright/test": "1.49.1",
|
||||
"@types/node": "*",
|
||||
"@types/pdf-parse": "1.1.4",
|
||||
"eslint-config-impress": "*",
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -1,5 +1,2 @@
|
||||
NEXT_PUBLIC_API_ORIGIN=
|
||||
NEXT_PUBLIC_Y_PROVIDER_URL=
|
||||
NEXT_PUBLIC_MEDIA_URL=
|
||||
NEXT_PUBLIC_THEME=dsfr
|
||||
NEXT_PUBLIC_SW_DEACTIVATED=
|
||||
|
||||
@@ -1,4 +1,2 @@
|
||||
NEXT_PUBLIC_API_ORIGIN=http://localhost:8071
|
||||
NEXT_PUBLIC_Y_PROVIDER_URL=ws://localhost:4444
|
||||
NEXT_PUBLIC_MEDIA_URL=http://localhost:8083
|
||||
NEXT_PUBLIC_SW_DEACTIVATED=true
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
NEXT_PUBLIC_API_ORIGIN=http://test.jest
|
||||
NEXT_PUBLIC_THEME=test-theme
|
||||
|
||||
2
src/frontend/apps/impress/next-env.d.ts
vendored
2
src/frontend/apps/impress/next-env.d.ts
vendored
@@ -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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-impress",
|
||||
"version": "1.7.0",
|
||||
"version": "1.10.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -19,51 +19,53 @@
|
||||
"@blocknote/mantine": "*",
|
||||
"@blocknote/react": "*",
|
||||
"@gouvfr-lasuite/integration": "1.0.2",
|
||||
"@hocuspocus/provider": "2.13.7",
|
||||
"@hocuspocus/provider": "2.15.0",
|
||||
"@openfun/cunningham-react": "2.9.4",
|
||||
"@tanstack/react-query": "5.59.15",
|
||||
"i18next": "23.16.2",
|
||||
"i18next-browser-languagedetector": "8.0.0",
|
||||
"idb": "8.0.0",
|
||||
"@sentry/nextjs": "8.45.1",
|
||||
"@tanstack/react-query": "5.62.7",
|
||||
"crisp-sdk-web": "1.0.25",
|
||||
"i18next": "24.1.0",
|
||||
"i18next-browser-languagedetector": "8.0.2",
|
||||
"idb": "8.0.1",
|
||||
"lodash": "4.17.21",
|
||||
"luxon": "3.5.0",
|
||||
"next": "14.2.15",
|
||||
"next": "15.1.0",
|
||||
"react": "*",
|
||||
"react-aria-components": "1.4.1",
|
||||
"react-aria-components": "1.5.0",
|
||||
"react-dom": "*",
|
||||
"react-i18next": "15.0.3",
|
||||
"react-select": "5.8.1",
|
||||
"react-i18next": "15.2.0",
|
||||
"react-select": "5.9.0",
|
||||
"styled-components": "6.1.13",
|
||||
"y-protocols": "1.0.6",
|
||||
"yjs": "*",
|
||||
"zustand": "5.0.0"
|
||||
"zustand": "5.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@svgr/webpack": "8.1.0",
|
||||
"@tanstack/react-query-devtools": "5.59.15",
|
||||
"@tanstack/react-query-devtools": "5.62.7",
|
||||
"@testing-library/dom": "10.4.0",
|
||||
"@testing-library/jest-dom": "6.6.2",
|
||||
"@testing-library/react": "16.0.1",
|
||||
"@testing-library/jest-dom": "6.6.3",
|
||||
"@testing-library/react": "16.1.0",
|
||||
"@testing-library/user-event": "14.5.2",
|
||||
"@types/jest": "29.5.13",
|
||||
"@types/lodash": "4.17.12",
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/lodash": "4.17.13",
|
||||
"@types/luxon": "3.4.2",
|
||||
"@types/node": "*",
|
||||
"@types/react": "18.3.11",
|
||||
"@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.95.0",
|
||||
"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 |
@@ -5,7 +5,7 @@ import { AppWrapper } from '@/tests/utils';
|
||||
|
||||
import Page from '../pages';
|
||||
|
||||
jest.mock('next/navigation', () => ({
|
||||
jest.mock('next/router', () => ({
|
||||
useRouter() {
|
||||
return {
|
||||
push: jest.fn(),
|
||||
@@ -13,6 +13,12 @@ jest.mock('next/navigation', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@sentry/nextjs', () => ({
|
||||
captureException: jest.fn(),
|
||||
captureMessage: jest.fn(),
|
||||
setUser: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('Page', () => {
|
||||
it('checks Page rendering', () => {
|
||||
render(<Page />, { wrapper: AppWrapper });
|
||||
|
||||
6
src/frontend/apps/impress/src/api/config.ts
Normal file
6
src/frontend/apps/impress/src/api/config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export const backendUrl = () =>
|
||||
process.env.NEXT_PUBLIC_API_ORIGIN ||
|
||||
(typeof window !== 'undefined' ? window.location.origin : '');
|
||||
|
||||
export const baseApiUrl = (apiVersion: string = '1.0') =>
|
||||
`${backendUrl()}/api/v${apiVersion}/`;
|
||||
@@ -1,5 +1,4 @@
|
||||
import { baseApiUrl } from '@/core';
|
||||
|
||||
import { baseApiUrl } from './config';
|
||||
import { getCSRFToken } from './utils';
|
||||
|
||||
interface FetchAPIInit extends RequestInit {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './APIError';
|
||||
export * from './config';
|
||||
export * from './fetchApi';
|
||||
export * from './helpers';
|
||||
export * from './types';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ComponentPropsWithRef, ReactHTML } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { CSSProperties } from 'styled-components/dist/types';
|
||||
import { CSSProperties, RuleSet } from 'styled-components/dist/types';
|
||||
|
||||
import {
|
||||
MarginPadding,
|
||||
@@ -15,7 +15,7 @@ export interface BoxProps {
|
||||
$align?: CSSProperties['alignItems'];
|
||||
$background?: CSSProperties['background'];
|
||||
$color?: CSSProperties['color'];
|
||||
$css?: string;
|
||||
$css?: string | RuleSet<object>;
|
||||
$direction?: CSSProperties['flexDirection'];
|
||||
$display?: CSSProperties['display'];
|
||||
$effect?: 'show' | 'hide';
|
||||
@@ -73,7 +73,7 @@ export const Box = styled('div')<BoxProps>`
|
||||
${({ $transition }) => $transition && `transition: ${$transition};`}
|
||||
${({ $width }) => $width && `width: ${$width};`}
|
||||
${({ $wrap }) => $wrap && `flex-wrap: ${$wrap};`}
|
||||
${({ $css }) => $css && `${$css};`}
|
||||
${({ $css }) => $css && (typeof $css === 'string' ? `${$css};` : $css)}
|
||||
${({ $zIndex }) => $zIndex && `z-index: ${$zIndex};`}
|
||||
${({ $effect }) => {
|
||||
let effect;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ComponentPropsWithRef, forwardRef } from 'react';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { Box, BoxType } from './Box';
|
||||
|
||||
@@ -26,7 +27,7 @@ const BoxButton = forwardRef<HTMLDivElement, BoxType>(
|
||||
$background="none"
|
||||
$margin="none"
|
||||
$padding="none"
|
||||
$css={`
|
||||
$css={css`
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
outline: none;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
|
||||
@@ -15,7 +16,7 @@ export const Card = ({
|
||||
<Box
|
||||
$background="white"
|
||||
$radius="4px"
|
||||
$css={`
|
||||
$css={css`
|
||||
box-shadow: 2px 2px 5px ${colorsTokens()['greyscale-300']};
|
||||
border: 1px solid ${colorsTokens()['card-border']};
|
||||
${$css}
|
||||
|
||||
@@ -7,6 +7,7 @@ import '@/i18n/initI18n';
|
||||
import { useResponsiveStore } from '@/stores/';
|
||||
|
||||
import { Auth } from './auth/';
|
||||
import { ConfigProvider } from './config/';
|
||||
|
||||
/**
|
||||
* QueryClient:
|
||||
@@ -39,7 +40,9 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CunninghamProvider theme={theme}>
|
||||
<Auth>{children}</Auth>
|
||||
<ConfigProvider>
|
||||
<Auth>{children}</Auth>
|
||||
</ConfigProvider>
|
||||
</CunninghamProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Crisp } from 'crisp-sdk-web';
|
||||
import fetchMock from 'fetch-mock';
|
||||
|
||||
import { useAuthStore } from '../useAuthStore';
|
||||
|
||||
jest.mock('crisp-sdk-web', () => ({
|
||||
...jest.requireActual('crisp-sdk-web'),
|
||||
Crisp: {
|
||||
isCrispInjected: jest.fn().mockReturnValue(true),
|
||||
setTokenId: jest.fn(),
|
||||
user: {
|
||||
setEmail: jest.fn(),
|
||||
},
|
||||
session: {
|
||||
reset: jest.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe('useAuthStore', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
fetchMock.restore();
|
||||
});
|
||||
|
||||
it('checks support session is terminated when logout', () => {
|
||||
window.$crisp = true;
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: {
|
||||
...window.location,
|
||||
replace: jest.fn(),
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
|
||||
useAuthStore.getState().logout();
|
||||
|
||||
expect(Crisp.session.reset).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { baseApiUrl } from '@/core/conf';
|
||||
import { baseApiUrl } from '@/api';
|
||||
import { terminateCrispSession } from '@/services';
|
||||
|
||||
import { User, getMe } from './api';
|
||||
import { PATH_AUTH_LOCAL_STORAGE } from './conf';
|
||||
@@ -42,6 +43,7 @@ export const useAuthStore = create<AuthStore>((set, get) => ({
|
||||
window.location.replace(`${baseApiUrl()}authenticate/`);
|
||||
},
|
||||
logout: () => {
|
||||
terminateCrispSession();
|
||||
window.location.replace(`${baseApiUrl()}logout/`);
|
||||
},
|
||||
// If we try to access a specific page and we are not authenticated
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
export const mediaUrl = () =>
|
||||
process.env.NEXT_PUBLIC_MEDIA_URL ||
|
||||
(typeof window !== 'undefined' ? window.location.origin : '');
|
||||
|
||||
export const backendUrl = () =>
|
||||
process.env.NEXT_PUBLIC_API_ORIGIN ||
|
||||
(typeof window !== 'undefined' ? window.location.origin : '');
|
||||
|
||||
export const baseApiUrl = (apiVersion: string = '1.0') =>
|
||||
`${backendUrl()}/api/v${apiVersion}/`;
|
||||
|
||||
export const providerUrl = (docId: string) => {
|
||||
const base =
|
||||
process.env.NEXT_PUBLIC_Y_PROVIDER_URL ||
|
||||
(typeof window !== 'undefined' ? `wss://${window.location.host}/ws` : '');
|
||||
|
||||
return `${base}/${docId}`;
|
||||
};
|
||||
49
src/frontend/apps/impress/src/core/config/ConfigProvider.tsx
Normal file
49
src/frontend/apps/impress/src/core/config/ConfigProvider.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Loader } from '@openfun/cunningham-react';
|
||||
import { PropsWithChildren, useEffect } from 'react';
|
||||
|
||||
import { Box } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { configureCrispSession } from '@/services';
|
||||
import { useSentryStore } from '@/stores/useSentryStore';
|
||||
|
||||
import { useConfig } from './api/useConfig';
|
||||
|
||||
export const ConfigProvider = ({ children }: PropsWithChildren) => {
|
||||
const { data: conf } = useConfig();
|
||||
const { setSentry } = useSentryStore();
|
||||
const { setTheme } = useCunninghamTheme();
|
||||
|
||||
useEffect(() => {
|
||||
if (!conf?.SENTRY_DSN) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSentry(conf.SENTRY_DSN, conf.ENVIRONMENT);
|
||||
}, [conf?.SENTRY_DSN, conf?.ENVIRONMENT, setSentry]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!conf?.FRONTEND_THEME) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTheme(conf.FRONTEND_THEME);
|
||||
}, [conf?.FRONTEND_THEME, setTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!conf?.CRISP_WEBSITE_ID) {
|
||||
return;
|
||||
}
|
||||
|
||||
configureCrispSession(conf.CRISP_WEBSITE_ID);
|
||||
}, [conf?.CRISP_WEBSITE_ID]);
|
||||
|
||||
if (!conf) {
|
||||
return (
|
||||
<Box $height="100vh" $width="100vw" $align="center" $justify="center">
|
||||
<Loader />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
1
src/frontend/apps/impress/src/core/config/api/index.ts
Normal file
1
src/frontend/apps/impress/src/core/config/api/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './useConfig';
|
||||
35
src/frontend/apps/impress/src/core/config/api/useConfig.tsx
Normal file
35
src/frontend/apps/impress/src/core/config/api/useConfig.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||
import { Theme } from '@/cunningham/';
|
||||
|
||||
interface ConfigResponse {
|
||||
LANGUAGES: [string, string][];
|
||||
LANGUAGE_CODE: string;
|
||||
ENVIRONMENT: string;
|
||||
COLLABORATION_WS_URL?: string;
|
||||
CRISP_WEBSITE_ID?: string;
|
||||
FRONTEND_THEME?: Theme;
|
||||
MEDIA_BASE_URL?: string;
|
||||
SENTRY_DSN?: string;
|
||||
}
|
||||
|
||||
export const getConfig = async (): Promise<ConfigResponse> => {
|
||||
const response = await fetchAPI(`config/`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError('Failed to get the doc', await errorCauses(response));
|
||||
}
|
||||
|
||||
return response.json() as Promise<ConfigResponse>;
|
||||
};
|
||||
|
||||
export const KEY_CONFIG = 'config';
|
||||
|
||||
export function useConfig() {
|
||||
return useQuery<ConfigResponse, APIError, ConfigResponse>({
|
||||
queryKey: [KEY_CONFIG],
|
||||
queryFn: () => getConfig(),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
2
src/frontend/apps/impress/src/core/config/hooks/index.ts
Normal file
2
src/frontend/apps/impress/src/core/config/hooks/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './useMediaUrl';
|
||||
export * from './useCollaborationUrl';
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useConfig } from '../api';
|
||||
|
||||
export const useCollaborationUrl = (room?: string) => {
|
||||
const { data: conf } = useConfig();
|
||||
|
||||
if (!room) {
|
||||
return;
|
||||
}
|
||||
|
||||
const base =
|
||||
conf?.COLLABORATION_WS_URL ||
|
||||
(typeof window !== 'undefined'
|
||||
? `wss://${window.location.host}/collaboration/ws/`
|
||||
: '');
|
||||
|
||||
return `${base}?room=${room}`;
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useConfig } from '../api';
|
||||
|
||||
export const useMediaUrl = () => {
|
||||
const { data: conf } = useConfig();
|
||||
|
||||
return (
|
||||
conf?.MEDIA_BASE_URL ||
|
||||
(typeof window !== 'undefined' ? window.location.origin : '')
|
||||
);
|
||||
};
|
||||
3
src/frontend/apps/impress/src/core/config/index.ts
Normal file
3
src/frontend/apps/impress/src/core/config/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './api/';
|
||||
export * from './ConfigProvider';
|
||||
export * from './hooks';
|
||||
@@ -1,3 +1,3 @@
|
||||
export * from './AppProvider';
|
||||
export * from './auth';
|
||||
export * from './conf';
|
||||
export * from './config';
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import useCunninghamTheme from '../useCunninghamTheme';
|
||||
import { useCunninghamTheme } from '../useCunninghamTheme';
|
||||
|
||||
describe('<useCunninghamTheme />', () => {
|
||||
it('has the theme from NEXT_PUBLIC_THEME', () => {
|
||||
const { theme } = useCunninghamTheme.getState();
|
||||
|
||||
expect(theme).toBe('test-theme');
|
||||
});
|
||||
|
||||
it('has the dsfr logo correctly set', () => {
|
||||
const { themeTokens, setTheme } = useCunninghamTheme.getState();
|
||||
setTheme('dsfr');
|
||||
|
||||
@@ -1,4 +1,2 @@
|
||||
import { tokens } from './cunningham-tokens';
|
||||
import useCunninghamTheme from './useCunninghamTheme';
|
||||
|
||||
export { tokens, useCunninghamTheme };
|
||||
export * from './cunningham-tokens';
|
||||
export * from './useCunninghamTheme';
|
||||
|
||||
@@ -6,22 +6,25 @@ import { tokens } from './cunningham-tokens';
|
||||
type Tokens = typeof tokens.themes.default & Partial<typeof tokens.themes.dsfr>;
|
||||
type ColorsTokens = Tokens['theme']['colors'];
|
||||
type ComponentTokens = Tokens['components'];
|
||||
type Theme = 'default' | 'dsfr';
|
||||
export type Theme = keyof typeof tokens.themes;
|
||||
|
||||
interface AuthStore {
|
||||
theme: Theme;
|
||||
theme: string;
|
||||
setTheme: (theme: Theme) => void;
|
||||
themeTokens: () => Partial<Tokens['theme']>;
|
||||
colorsTokens: () => Partial<ColorsTokens>;
|
||||
componentTokens: () => ComponentTokens;
|
||||
}
|
||||
|
||||
const useCunninghamTheme = create<AuthStore>((set, get) => {
|
||||
export const useCunninghamTheme = create<AuthStore>((set, get) => {
|
||||
const currentTheme = () =>
|
||||
merge(tokens.themes['default'], tokens.themes[get().theme]) as Tokens;
|
||||
merge(
|
||||
tokens.themes['default'],
|
||||
tokens.themes[get().theme as keyof typeof tokens.themes],
|
||||
) as Tokens;
|
||||
|
||||
return {
|
||||
theme: (process.env.NEXT_PUBLIC_THEME as Theme) || 'dsfr',
|
||||
theme: 'dsfr',
|
||||
themeTokens: () => currentTheme().theme,
|
||||
colorsTokens: () => currentTheme().theme.colors,
|
||||
componentTokens: () => currentTheme().components,
|
||||
@@ -30,5 +33,3 @@ const useCunninghamTheme = create<AuthStore>((set, get) => {
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export default useCunninghamTheme;
|
||||
|
||||
@@ -20,9 +20,6 @@ declare module '*.svg?url' {
|
||||
namespace NodeJS {
|
||||
interface ProcessEnv {
|
||||
NEXT_PUBLIC_API_ORIGIN?: string;
|
||||
NEXT_PUBLIC_MEDIA_URL?: string;
|
||||
NEXT_PUBLIC_Y_PROVIDER_URL?: string;
|
||||
NEXT_PUBLIC_SW_DEACTIVATED?: string;
|
||||
NEXT_PUBLIC_THEME?: string;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,14 +14,13 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { isAPIError } from '@/api';
|
||||
import { Box, Text } from '@/components';
|
||||
import { useDocOptions } from '@/features/docs/doc-management/';
|
||||
import { useDocOptions, useDocStore } from '@/features/docs/doc-management/';
|
||||
|
||||
import {
|
||||
AITransformActions,
|
||||
useDocAITransform,
|
||||
useDocAITranslate,
|
||||
} from '../api/';
|
||||
import { useDocStore } from '../stores';
|
||||
|
||||
type LanguageTranslate = {
|
||||
value: string;
|
||||
@@ -282,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);
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import { locales } from '@blocknote/core';
|
||||
import { Dictionary, locales } from '@blocknote/core';
|
||||
import '@blocknote/core/fonts/inter.css';
|
||||
import { BlockNoteView } from '@blocknote/mantine';
|
||||
import '@blocknote/mantine/style.css';
|
||||
import { useCreateBlockNote } from '@blocknote/react';
|
||||
import { HocuspocusProvider } from '@hocuspocus/provider';
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import { Box, TextErrors } from '@/components';
|
||||
import { mediaUrl } from '@/core';
|
||||
import { useAuthStore } from '@/core/auth';
|
||||
import { Doc } from '@/features/docs/doc-management';
|
||||
import { Version } from '@/features/docs/doc-versioning/';
|
||||
import { Doc, Role, currentDocRole } from '@/features/docs/doc-management';
|
||||
|
||||
import { useCreateDocAttachment } from '../api/useCreateDocUpload';
|
||||
import { useUploadFile } from '../hook';
|
||||
import { useHeadings } from '../hook/useHeadings';
|
||||
import useSaveDoc from '../hook/useSaveDoc';
|
||||
import { useDocStore, useHeadingStore } from '../stores';
|
||||
import { useEditorStore } from '../stores';
|
||||
import { randomColor } from '../utils';
|
||||
|
||||
import { BlockNoteToolbar } from './BlockNoteToolbar';
|
||||
@@ -28,9 +28,6 @@ const cssEditor = (readonly: boolean) => `
|
||||
padding-right: 30px;
|
||||
${readonly && `padding-left: 30px;`}
|
||||
};
|
||||
& .collaboration-cursor__caret.ProseMirror-widget{
|
||||
word-wrap: initial;
|
||||
}
|
||||
& .bn-inline-content code {
|
||||
background-color: gainsboro;
|
||||
padding: 2px;
|
||||
@@ -68,70 +65,25 @@ const cssEditor = (readonly: boolean) => `
|
||||
`;
|
||||
|
||||
interface BlockNoteEditorProps {
|
||||
doc: Doc;
|
||||
version?: Version;
|
||||
}
|
||||
|
||||
export const BlockNoteEditor = ({ doc, version }: BlockNoteEditorProps) => {
|
||||
const { createProvider, docsStore } = useDocStore();
|
||||
const storeId = version?.id || doc.id;
|
||||
const initialContent = version?.content || doc.content;
|
||||
const provider = docsStore?.[storeId]?.provider;
|
||||
|
||||
useEffect(() => {
|
||||
if (!provider || provider.document.guid !== storeId) {
|
||||
createProvider(storeId, initialContent);
|
||||
}
|
||||
}, [createProvider, initialContent, provider, storeId]);
|
||||
|
||||
if (!provider) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <BlockNoteContent doc={doc} provider={provider} storeId={storeId} />;
|
||||
};
|
||||
|
||||
interface BlockNoteContentProps {
|
||||
doc: Doc;
|
||||
provider: HocuspocusProvider;
|
||||
storeId: string;
|
||||
}
|
||||
|
||||
export const BlockNoteContent = ({
|
||||
doc,
|
||||
provider,
|
||||
storeId,
|
||||
}: BlockNoteContentProps) => {
|
||||
const isVersion = doc.id !== storeId;
|
||||
export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
|
||||
const { userData } = useAuthStore();
|
||||
const { setStore, docsStore } = useDocStore();
|
||||
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 storedEditor = docsStore?.[storeId]?.editor;
|
||||
const {
|
||||
mutateAsync: createDocAttachment,
|
||||
isError: isErrorAttachment,
|
||||
error: errorAttachment,
|
||||
} = useCreateDocAttachment();
|
||||
const { setHeadings, resetHeadings } = useHeadingStore();
|
||||
const { i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
|
||||
const uploadFile = useCallback(
|
||||
async (file: File) => {
|
||||
const body = new FormData();
|
||||
body.append('file', file);
|
||||
const { uploadFile, errorAttachment } = useUploadFile(doc.id);
|
||||
|
||||
const ret = await createDocAttachment({
|
||||
docId: doc.id,
|
||||
body,
|
||||
});
|
||||
|
||||
return `${mediaUrl()}${ret.file}`;
|
||||
},
|
||||
[createDocAttachment, doc.id],
|
||||
);
|
||||
const collabName = readOnly
|
||||
? 'Reader'
|
||||
: userData?.full_name || userData?.email || t('Anonymous');
|
||||
|
||||
const editor = useCreateBlockNote(
|
||||
{
|
||||
@@ -139,35 +91,70 @@ export const BlockNoteContent = ({
|
||||
provider,
|
||||
fragment: provider.document.getXmlFragment('document-store'),
|
||||
user: {
|
||||
name: userData?.email || 'Anonymous',
|
||||
name: collabName,
|
||||
color: randomColor(),
|
||||
},
|
||||
/**
|
||||
* We re-use the blocknote code to render the cursor but we:
|
||||
* - fix rendering issue with Firefox
|
||||
* - We don't want to show the cursor when anonymous users
|
||||
*/
|
||||
renderCursor: (user: { color: string; name: string }) => {
|
||||
const cursor = document.createElement('span');
|
||||
|
||||
if (user.name === 'Reader') {
|
||||
return cursor;
|
||||
}
|
||||
|
||||
cursor.classList.add('collaboration-cursor__caret');
|
||||
cursor.setAttribute('style', `border-color: ${user.color}`);
|
||||
|
||||
const label = document.createElement('span');
|
||||
|
||||
label.classList.add('collaboration-cursor__label');
|
||||
label.setAttribute('style', `background-color: ${user.color}`);
|
||||
label.insertBefore(document.createTextNode(user.name), null);
|
||||
|
||||
cursor.insertBefore(label, null);
|
||||
|
||||
return cursor;
|
||||
},
|
||||
},
|
||||
dictionary: locales[lang as keyof typeof locales],
|
||||
dictionary: locales[lang as keyof typeof locales] as Dictionary,
|
||||
uploadFile,
|
||||
},
|
||||
[provider, uploadFile, userData?.email, lang],
|
||||
[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(() => {
|
||||
setStore(storeId, { editor });
|
||||
}, [setStore, storeId, editor]);
|
||||
|
||||
useEffect(() => {
|
||||
setHeadings(editor);
|
||||
|
||||
editor?.onEditorContentChange(() => {
|
||||
setHeadings(editor);
|
||||
});
|
||||
setEditor(editor);
|
||||
|
||||
return () => {
|
||||
resetHeadings();
|
||||
setEditor(undefined);
|
||||
};
|
||||
}, [editor, resetHeadings, setHeadings]);
|
||||
}, [setEditor, editor]);
|
||||
|
||||
return (
|
||||
<Box $css={cssEditor(readOnly)}>
|
||||
{isErrorAttachment && (
|
||||
{errorAttachment && (
|
||||
<Box $margin={{ bottom: 'big' }}>
|
||||
<TextErrors
|
||||
causes={errorAttachment.cause}
|
||||
@@ -178,7 +165,7 @@ export const BlockNoteContent = ({
|
||||
)}
|
||||
|
||||
<BlockNoteView
|
||||
editor={storedEditor ?? editor}
|
||||
editor={editor}
|
||||
formattingToolbar={false}
|
||||
editable={!readOnly}
|
||||
theme="light"
|
||||
@@ -188,3 +175,42 @@ export const BlockNoteContent = ({
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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} />;
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user