Compare commits

..

8 Commits

Author SHA1 Message Date
Nathan Panchout
25f0914a8b wip 2024-11-14 17:09:35 +01:00
Nathan Panchout
4a4953b344 wip 2024-11-14 14:39:43 +01:00
Nathan Panchout
8268b26d7a 📱(frontend) make left panel responsive
We modify the left panel so that when the value changes in the
useResponsiveStore it is displayed on the screen
2024-11-14 08:59:44 +01:00
Nathan Panchout
61c093cb54 📱(frontend) update header for the new responsive layout [FIRST_COMMIT_HEADER]
The new version of the responsive layout adds a burger menu to show
the left-panel  with a slide-in animation.
We move the logout and language change button in this responsive panel.
2024-11-14 08:57:40 +01:00
Nathan Panchout
7a072e902d (frontend) update tests
Some minor changes have been integrated into the list of documents.
The tests must therefore be adapted accordingly.
2024-11-13 17:33:25 +01:00
Nathan Panchout
7d14309032 💄(frontend) add left panel
In the new interface there is a new left panel. We implement it and add it
to the MainLayout
2024-11-13 17:33:25 +01:00
Nathan Panchout
d523493fa9 💄(frontend) add cunningham tokens
In order to use the spaces and grays of the DSFR,
we update the cunningham.ts file
2024-11-13 17:33:25 +01:00
Nathan Panchout
9b33a1f464 (frontend) implement new UI
This branch is a transition branch to gradually merge the new UI.
2024-11-12 17:58:34 +01:00
216 changed files with 6460 additions and 15951 deletions

View File

@@ -8,9 +8,6 @@ on:
- 'main'
tags:
- 'v*'
pull_request:
branches:
- 'main'
env:
DOCKER_USER: 1001:127
@@ -55,7 +52,6 @@ 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
@@ -106,7 +102,6 @@ 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
@@ -156,15 +151,14 @@ jobs:
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '-f src/frontend/servers/y-provider/Dockerfile --target y-provider'
docker-build-args: '-f src/frontend/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/servers/y-provider/Dockerfile
file: ./src/frontend/Dockerfile
target: y-provider
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }}

View File

@@ -1,22 +0,0 @@
name: Helmfile lint
run-name: Helmfile lint
on:
pull_request:
branches:
- 'main'
jobs:
helmfile-lint:
runs-on: ubuntu-latest
container:
image: ghcr.io/helmfile/helmfile:latest
steps:
-
uses: numerique-gouv/action-helmfile-lint@main
with:
app-id: ${{ secrets.APP_ID }}
age-key: ${{ secrets.SOPS_PRIVATE }}
private-key: ${{ secrets.PRIVATE_KEY }}
helmfile-src: "src/helm"
repositories: "impress,secrets"

View File

@@ -19,7 +19,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20.x"
node-version: "18.x"
- name: Restore the frontend cache
uses: actions/cache@v4
@@ -46,11 +46,6 @@ 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
@@ -59,7 +54,7 @@ jobs:
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
- name: Test App
run: cd src/frontend/ && yarn test
run: cd src/frontend/ && yarn app:test
lint-front:
runs-on: ubuntu-latest
@@ -95,38 +90,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 chromium
- name: Start Docker services
run: make bootstrap FLUSH_ARGS='--no-input' cache=
# 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: Install Playwright Browsers
run: cd src/frontend/apps/e2e && yarn install-playwright chromium
- name: Run e2e tests
run: cd src/frontend/ && yarn e2e:test --project='chromium'
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v3
if: always()
with:
name: playwright-chromium-report
@@ -151,16 +124,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@v4
- uses: actions/upload-artifact@v3
if: always()
with:
name: playwright-other-report

View File

@@ -107,9 +107,7 @@ jobs:
- name: Install Python
uses: actions/setup-python@v3
with:
python-version: "3.12.6"
- name: Upgrade pip and setuptools
run: pip install --upgrade pip setuptools
python-version: "3.10"
- name: Install development dependencies
run: pip install --user .[dev]
- name: Check code formatting with ruff
@@ -201,7 +199,7 @@ jobs:
- name: Install Python
uses: actions/setup-python@v3
with:
python-version: "3.12.6"
python-version: "3.10"
- name: Install development dependencies
run: pip install --user .[dev]

View File

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

View File

@@ -11,89 +11,22 @@ and this project adheres to
## 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
- 🌐(frontend) Add German translation #255
- ✨(frontend) Add a broadcast store #387
- ✨(frontend) WIP: New ui
- 💄(frontend) Add left panel
- 💄(frontend) update header layout #421
## 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
- 🌐(backend) add german translation #259
## 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
- 🐛(frontend) user have view access when revoked #387
## [1.7.0] - 2024-10-24
@@ -325,12 +258,7 @@ and this project adheres to
- 🚀 Impress, project to manage your documents easily and collaboratively.
[unreleased]: https://github.com/numerique-gouv/impress/compare/v1.10.0...main
[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
[unreleased]: https://github.com/numerique-gouv/impress/compare/v1.7.0...main
[v1.7.0]: https://github.com/numerique-gouv/impress/releases/v1.7.0
[v1.6.0]: https://github.com/numerique-gouv/impress/releases/v1.6.0
[1.5.1]: https://github.com/numerique-gouv/impress/releases/v1.5.1

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,58 +4,9 @@ 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 /media-auth;
auth_request /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;
@@ -70,8 +21,8 @@ server {
proxy_set_header Host minio:9000;
}
location /media-auth {
proxy_pass http://app-dev:8000/api/v1.0/documents/media-auth/;
location /auth {
proxy_pass http://app-dev:8000/api/v1.0/documents/retrieve-auth/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,21 +4,13 @@ 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
@@ -29,7 +21,6 @@ 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
@@ -53,12 +44,3 @@ 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

View File

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

View File

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

View File

@@ -1,69 +0,0 @@
"""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)

View File

@@ -4,7 +4,6 @@ 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
@@ -12,10 +11,6 @@ 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):
@@ -142,69 +137,32 @@ class BaseResourceSerializer(serializers.ModelSerializer):
return {}
class ListDocumentSerializer(BaseResourceSerializer):
"""Serialize documents with limited fields for display in lists."""
is_favorite = serializers.BooleanField(read_only=True)
nb_accesses = serializers.IntegerField(read_only=True)
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_favorite",
"link_role",
"link_reach",
"nb_accesses",
"updated_at",
]
class DocumentSerializer(ListDocumentSerializer):
"""Serialize documents with all fields for display in detail views."""
class DocumentSerializer(BaseResourceSerializer):
"""Serialize documents."""
content = serializers.CharField(required=False)
accesses = DocumentAccessSerializer(many=True, read_only=True)
class Meta:
model = models.Document
fields = [
"id",
"abilities",
"content",
"created_at",
"creator",
"is_favorite",
"title",
"accesses",
"abilities",
"link_role",
"link_reach",
"nb_accesses",
"title",
"created_at",
"updated_at",
]
read_only_fields = [
"id",
"accesses",
"abilities",
"created_at",
"creator",
"is_avorite",
"link_role",
"link_reach",
"nb_accesses",
"created_at",
"updated_at",
]
@@ -232,96 +190,6 @@ class DocumentSerializer(ListDocumentSerializer):
return value
class ServerCreateDocumentSerializer(serializers.Serializer):
"""
Serializer for creating a document from a server-to-server request.
Expects 'content' as a markdown string, which is converted to our internal format
via a Node.js microservice. The conversion is handled automatically, so third parties
only need to provide markdown.
Both "sub" and "email" are required because the external app calling doesn't know
if the user will pre-exist in Docs database. If the user pre-exist, we will ignore the
submitted "email" field and use the email address set on the user account in our database
"""
# Document
title = serializers.CharField(required=True)
content = serializers.CharField(required=True)
# User
sub = serializers.CharField(
required=True, validators=[models.User.sub_validator], max_length=255
)
email = serializers.EmailField(required=True)
language = serializers.ChoiceField(
required=False, choices=lazy(lambda: settings.LANGUAGES, tuple)()
)
# Invitation
message = serializers.CharField(required=False)
subject = serializers.CharField(required=False)
def create(self, validated_data):
"""Create the document and associate it with the user or send an invitation."""
language = validated_data.get("language", settings.LANGUAGE_CODE)
# Get the user based on the sub (unique identifier)
try:
user = models.User.objects.get(sub=validated_data["sub"])
except (models.User.DoesNotExist, KeyError):
user = None
email = validated_data["email"]
else:
email = user.email
language = user.language or language
try:
document_content = YdocConverter().convert_markdown(
validated_data["content"]
)
except ConversionError as err:
raise exceptions.APIException(detail="could not convert content") from err
document = models.Document.objects.create(
title=validated_data["title"],
content=document_content,
creator=user,
)
if user:
# Associate the document with the pre-existing user
models.DocumentAccess.objects.create(
document=document,
role=models.RoleChoices.OWNER,
user=user,
)
else:
# The user doesn't exist in our database: we need to invite him/her
models.Invitation.objects.create(
document=document,
email=email,
role=models.RoleChoices.OWNER,
)
# Notify the user about the newly created document
subject = validated_data.get("subject") or _(
"A new document was created on your behalf!"
)
context = {
"message": validated_data.get("message")
or _("You have been granted ownership of a new document:"),
"title": subject,
}
document.send_email(subject, [email], context, language)
return document
def update(self, instance, validated_data):
"""
This serializer does not support updates.
"""
raise NotImplementedError("Update is not supported for this serializer.")
class LinkDocumentSerializer(BaseResourceSerializer):
"""
Serialize link configuration for documents.

View File

@@ -1,7 +1,5 @@
"""API endpoints"""
# pylint: disable=too-many-lines
import logging
import re
import uuid
from urllib.parse import urlparse
@@ -11,48 +9,50 @@ from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.search import TrigramSimilarity
from django.core.exceptions import ValidationError
from django.core.files.storage import default_storage
from django.db import models as db
from django.db.models import (
Count,
Exists,
Min,
OuterRef,
Q,
Subquery,
Value,
)
from django.http import Http404
import rest_framework as drf
from botocore.exceptions import ClientError
from django_filters import rest_framework as drf_filters
from rest_framework import filters, status
from rest_framework import response as drf_response
from rest_framework.permissions import AllowAny
from rest_framework import (
decorators,
exceptions,
filters,
metadata,
mixins,
pagination,
status,
viewsets,
)
from rest_framework import (
response as drf_response,
)
from core import authentication, enums, models
from core import 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_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})$"
MEDIA_URL_PATTERN = re.compile(
f"{settings.MEDIA_URL:s}({UUID_REGEX:s})/"
f"({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(drf.viewsets.GenericViewSet):
class NestedGenericViewSet(viewsets.GenericViewSet):
"""
A generic Viewset aims to be used in a nested route context.
e.g: `/api/v1.0/resource_1/<resource_1_pk>/resource_2/<resource_2_pk>/`
@@ -124,7 +124,7 @@ class SerializerPerActionMixin:
return self.serializer_classes.get(self.action, self.default_serializer_class)
class Pagination(drf.pagination.PageNumberPagination):
class Pagination(pagination.PageNumberPagination):
"""Pagination to display no more than 100 objects per page sorted by creation date."""
ordering = "-created_on"
@@ -133,7 +133,7 @@ class Pagination(drf.pagination.PageNumberPagination):
class UserViewSet(
drf.mixins.UpdateModelMixin, drf.viewsets.GenericViewSet, drf.mixins.ListModelMixin
mixins.UpdateModelMixin, viewsets.GenericViewSet, mixins.ListModelMixin
):
"""User ViewSet"""
@@ -169,12 +169,12 @@ class UserViewSet(
threshold = 0.6 if "@" in query else 0.1
queryset = queryset.filter(similarity__gt=threshold).order_by(
"-similarity", "email"
"-similarity"
)
return queryset
@drf.decorators.action(
@decorators.action(
detail=False,
methods=["get"],
url_name="me",
@@ -186,11 +186,47 @@ 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."""
@@ -221,7 +257,7 @@ class ResourceAccessViewsetMixin:
teams = user.teams
user_roles_query = (
queryset.filter(
db.Q(user=user) | db.Q(team__in=teams),
Q(user=user) | Q(team__in=teams),
**{self.resource_field_name: self.kwargs["resource_id"]},
)
.values(self.resource_field_name)
@@ -235,13 +271,11 @@ class ResourceAccessViewsetMixin:
# access instances pointing to the logged-in user)
queryset = (
queryset.filter(
db.Q(**{f"{self.resource_field_name}__accesses__user": user})
| db.Q(
**{f"{self.resource_field_name}__accesses__team__in": teams}
),
Q(**{f"{self.resource_field_name}__accesses__user": user})
| Q(**{f"{self.resource_field_name}__accesses__team__in": teams}),
**{self.resource_field_name: self.kwargs["resource_id"]},
)
.annotate(user_roles=db.Subquery(user_roles_query))
.annotate(user_roles=Subquery(user_roles_query))
.distinct()
)
return queryset
@@ -256,9 +290,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=drf.status.HTTP_403_FORBIDDEN,
status=status.HTTP_403_FORBIDDEN,
)
return super().destroy(request, *args, **kwargs)
@@ -279,12 +313,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 drf.exceptions.PermissionDenied({"detail": message})
raise exceptions.PermissionDenied({"detail": message})
serializer.save()
class DocumentMetadata(drf.metadata.SimpleMetadata):
class DocumentMetadata(metadata.SimpleMetadata):
"""Custom metadata class to add information"""
def determine_metadata(self, request, view):
@@ -302,90 +336,35 @@ class DocumentMetadata(drf.metadata.SimpleMetadata):
class DocumentViewSet(
drf.mixins.CreateModelMixin,
drf.mixins.DestroyModelMixin,
drf.mixins.UpdateModelMixin,
drf.viewsets.GenericViewSet,
ResourceViewsetMixin,
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
"""
Document ViewSet for managing documents.
"""Document ViewSet"""
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,
]
queryset = models.Document.objects.all()
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()
access_model_class = models.DocumentAccess
resource_field_name = "document"
queryset = models.Document.objects.all()
ordering = ["-updated_at"]
metadata_class = DocumentMetadata
def list(self, request, *args, **kwargs):
"""Restrict resources returned by the list endpoint"""
queryset = self.filter_queryset(self.get_queryset())
user = self.request.user
if user.is_authenticated:
queryset = queryset.filter(
db.Q(accesses__user=user)
| db.Q(accesses__team__in=user.teams)
Q(accesses__user=user)
| Q(accesses__team__in=user.teams)
| (
db.Q(link_traces__user=user)
& ~db.Q(link_reach=models.LinkReachChoices.RESTRICTED)
Q(link_traces__user=user)
& ~Q(link_reach=models.LinkReachChoices.RESTRICTED)
)
)
else:
@@ -397,7 +376,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):
"""
@@ -420,42 +399,9 @@ class DocumentViewSet(
# The trace already exists, so we just pass without doing anything
pass
return drf.response.Response(serializer.data)
return drf_response.Response(serializer.data)
def perform_create(self, serializer):
"""Set the current user as creator and owner of the newly created object."""
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")
@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
@@ -463,7 +409,7 @@ class DocumentViewSet(
"""
user = request.user
if not user.is_authenticated:
raise drf.exceptions.PermissionDenied("Authentication required.")
raise exceptions.PermissionDenied("Authentication required.")
# Validate query parameters using dedicated serializer
serializer = serializers.VersionFilterSerializer(data=request.query_params)
@@ -474,13 +420,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(
db.Q(user=user) | db.Q(team__in=user.teams)
).aggregate(min_date=db.Min("created_at"))
Q(user=user) | Q(team__in=user.teams)
).aggregate(min_date=Min("created_at"))
# Handle the case where the user has no accesses
min_datetime = access_queryset["min_date"]
if not min_datetime:
return drf.exceptions.PermissionDenied(
return exceptions.PermissionDenied(
"Only users with specific access can see version history"
)
@@ -490,9 +436,9 @@ class DocumentViewSet(
page_size=serializer.validated_data.get("page_size"),
)
return drf.response.Response(versions_data)
return drf_response.Response(versions_data)
@drf.decorators.action(
@decorators.action(
detail=True,
methods=["get", "delete"],
url_path="versions/(?P<version_id>[0-9a-f-]{36})",
@@ -513,7 +459,7 @@ class DocumentViewSet(
min_datetime = min(
access.created_at
for access in document.accesses.filter(
db.Q(user=user) | db.Q(team__in=user.teams),
Q(user=user) | Q(team__in=user.teams),
)
)
if response["LastModified"] < min_datetime:
@@ -521,11 +467,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"],
@@ -533,7 +479,7 @@ class DocumentViewSet(
}
)
@drf.decorators.action(detail=True, methods=["put"], url_path="link-configuration")
@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
@@ -546,50 +492,9 @@ class DocumentViewSet(
serializer.is_valid(raise_exception=True)
serializer.save()
return drf_response.Response(serializer.data, status=status.HTTP_200_OK)
# 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")
@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
@@ -614,15 +519,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=drf.status.HTTP_201_CREATED,
return drf_response.Response(
{"file": f"{settings.MEDIA_URL:s}{key:s}"}, status=status.HTTP_201_CREATED
)
def _authorize_subrequest(self, request, pattern):
@decorators.action(detail=False, methods=["get"], url_path="retrieve-auth")
def retrieve_auth(self, request, *args, **kwargs):
"""
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.
This view is used by an Nginx subrequest to control access to a document's
attachment file.
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
@@ -634,108 +539,33 @@ 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.
"""
url_params, _, _ = self._authorize_subrequest(
request, MEDIA_STORAGE_URL_PATTERN
)
pk, key = url_params.values()
original_url = urlparse(request.META.get("HTTP_X_ORIGINAL_URL"))
match = MEDIA_URL_PATTERN.search(original_url.path)
# Generate S3 authorization headers using the extracted URL parameters
request = utils.generate_s3_authorization_headers(f"{pk:s}/{key:s}")
try:
pk, attachment_key = match.groups()
except AttributeError as excpt:
raise exceptions.PermissionDenied() from excpt
return drf.response.Response("authorized", headers=request.headers, status=200)
# Check permission
try:
document = models.Document.objects.get(pk=pk)
except models.Document.DoesNotExist as excpt:
raise exceptions.PermissionDenied() from excpt
@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"]
if not document.get_abilities(request.user).get("retrieve", False):
raise exceptions.PermissionDenied()
# 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),
}
# 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)
return drf.response.Response("authorized", headers=headers, status=200)
@drf.decorators.action(
@decorators.action(
detail=True,
methods=["post"],
name="Apply a transformation action on a piece of text with AI",
@@ -761,9 +591,9 @@ class DocumentViewSet(
response = AIService().transform(text, action)
return drf.response.Response(response, status=drf.status.HTTP_200_OK)
return drf_response.Response(response, status=status.HTTP_200_OK)
@drf.decorators.action(
@decorators.action(
detail=True,
methods=["post"],
name="Translate a piece of text with AI",
@@ -790,17 +620,17 @@ class DocumentViewSet(
response = AIService().translate(text, language)
return drf.response.Response(response, status=drf.status.HTTP_200_OK)
return drf_response.Response(response, status=status.HTTP_200_OK)
class DocumentAccessViewSet(
ResourceAccessViewsetMixin,
drf.mixins.CreateModelMixin,
drf.mixins.DestroyModelMixin,
drf.mixins.ListModelMixin,
drf.mixins.RetrieveModelMixin,
drf.mixins.UpdateModelMixin,
drf.viewsets.GenericViewSet,
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
"""
API ViewSet for all interactions with document accesses.
@@ -838,83 +668,42 @@ class DocumentAccessViewSet(
access = serializer.save()
language = self.request.headers.get("Content-Language", "en-us")
access.document.send_invitation_email(
access.document.email_invitation(
language,
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(
drf.mixins.CreateModelMixin,
drf.mixins.DestroyModelMixin,
drf.mixins.RetrieveModelMixin,
drf.mixins.UpdateModelMixin,
drf.viewsets.GenericViewSet,
ResourceViewsetMixin,
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
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(
db.Q(accesses__user=user)
| db.Q(accesses__team__in=user.teams)
| db.Q(is_public=True)
Q(accesses__user=user)
| Q(accesses__team__in=user.teams)
| Q(is_public=True)
)
else:
queryset = queryset.filter(is_public=True)
@@ -925,18 +714,9 @@ class TemplateViewSet(
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True)
return drf.response.Response(serializer.data)
return drf_response.Response(serializer.data)
def perform_create(self, serializer):
"""Set the current user as owner of the newly created object."""
obj = serializer.save()
models.TemplateAccess.objects.create(
template=obj,
user=self.request.user,
role=models.RoleChoices.OWNER,
)
@drf.decorators.action(
@decorators.action(
detail=True,
methods=["post"],
url_path="generate-document",
@@ -959,8 +739,8 @@ class TemplateViewSet(
serializer = serializers.DocumentGenerationSerializer(data=request.data)
if not serializer.is_valid():
return drf.response.Response(
serializer.errors, status=drf.status.HTTP_400_BAD_REQUEST
return drf_response.Response(
serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
body = serializer.validated_data["body"]
@@ -973,12 +753,12 @@ class TemplateViewSet(
class TemplateAccessViewSet(
ResourceAccessViewsetMixin,
drf.mixins.CreateModelMixin,
drf.mixins.DestroyModelMixin,
drf.mixins.ListModelMixin,
drf.mixins.RetrieveModelMixin,
drf.mixins.UpdateModelMixin,
drf.viewsets.GenericViewSet,
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
"""
API ViewSet for all interactions with template accesses.
@@ -1013,12 +793,12 @@ class TemplateAccessViewSet(
class InvitationViewset(
drf.mixins.CreateModelMixin,
drf.mixins.ListModelMixin,
drf.mixins.RetrieveModelMixin,
drf.mixins.DestroyModelMixin,
drf.mixins.UpdateModelMixin,
drf.viewsets.GenericViewSet,
mixins.CreateModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.DestroyModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
"""API ViewSet for user invitations to document.
@@ -1070,7 +850,7 @@ class InvitationViewset(
# Determine which role the logged-in user has in the document
user_roles_query = (
models.DocumentAccess.objects.filter(
db.Q(user=user) | db.Q(team__in=teams),
Q(user=user) | Q(team__in=teams),
document=self.kwargs["resource_id"],
)
.values("document")
@@ -1081,18 +861,18 @@ class InvitationViewset(
queryset = (
# The logged-in user should be administrator or owner to see its accesses
queryset.filter(
db.Q(
Q(
document__accesses__user=user,
document__accesses__role__in=models.PRIVILEGED_ROLES,
)
| db.Q(
| 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=db.Subquery(user_roles_query))
.annotate(user_roles=Subquery(user_roles_query))
.distinct()
)
return queryset
@@ -1103,34 +883,6 @@ class InvitationViewset(
language = self.request.headers.get("Content-Language", "en-us")
invitation.document.send_invitation_email(
invitation.email, invitation.role, self.request.user, language
invitation.document.email_invitation(
language, invitation.email, invitation.role, self.request.user
)
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)

View File

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

View File

@@ -56,7 +56,6 @@ 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]
)
@@ -81,13 +80,6 @@ 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."""

View File

@@ -1,37 +0,0 @@
# 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.')],
},
),
]

View File

@@ -1,31 +0,0 @@
# 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'),
),
]

View File

@@ -1,52 +0,0 @@
# 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),
),
]

View File

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

View File

@@ -26,8 +26,8 @@ from django.template.context import Context
from django.template.loader import render_to_string
from django.utils import html, timezone
from django.utils.functional import cached_property, lazy
from django.utils.translation import get_language, override
from django.utils.translation import gettext_lazy as _
from django.utils.translation import override
import frontmatter
import markdown
@@ -239,13 +239,6 @@ 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):
@@ -348,13 +341,6 @@ class Document(BaseModel):
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
@@ -522,86 +508,64 @@ class Document(BaseModel):
is_owner_or_admin = bool(
roles.intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
)
is_editor = bool(RoleChoices.EDITOR in roles)
can_get = bool(roles)
can_update = is_owner_or_admin or RoleChoices.EDITOR in roles
return {
"accesses_manage": is_owner_or_admin,
"accesses_view": has_role,
"ai_transform": can_update,
"ai_translate": can_update,
"attachment_upload": can_update,
"collaboration_auth": can_get,
"ai_transform": is_owner_or_admin or is_editor,
"ai_translate": is_owner_or_admin or is_editor,
"attachment_upload": is_owner_or_admin or is_editor,
"destroy": RoleChoices.OWNER in roles,
"favorite": can_get and user.is_authenticated,
"link_configuration": is_owner_or_admin,
"invite_owner": RoleChoices.OWNER in roles,
"partial_update": can_update,
"partial_update": is_owner_or_admin or is_editor,
"retrieve": can_get,
"media_auth": can_get,
"update": can_update,
"update": is_owner_or_admin or is_editor,
"versions_destroy": is_owner_or_admin,
"versions_list": has_role,
"versions_retrieve": has_role,
}
def send_email(self, subject, emails, context=None, language=None):
"""Generate and send email from a template."""
context = context or {}
def email_invitation(self, language, email, role, sender):
"""Send email invitation."""
sender_name = sender.full_name or sender.email
domain = Site.objects.get_current().domain
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,
}
)
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:
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)
send_mail(
subject.capitalize(),
title,
msg_plain,
settings.EMAIL_FROM,
emails,
[email],
html_message=msg_html,
fail_silently=False,
)
except smtplib.SMTPException as exception:
logger.error("invitation to %s was not sent: %s", emails, 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)
except smtplib.SMTPException as exception:
logger.error("invitation to %s was not sent: %s", email, exception)
class LinkTrace(BaseModel):
@@ -636,37 +600,6 @@ 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."""
@@ -742,15 +675,15 @@ class Template(BaseModel):
is_owner_or_admin = bool(
set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
)
is_editor = bool(RoleChoices.EDITOR in roles)
can_get = self.is_public or bool(roles)
can_update = is_owner_or_admin or RoleChoices.EDITOR in roles
return {
"destroy": RoleChoices.OWNER in roles,
"generate_document": can_get,
"accesses_manage": is_owner_or_admin,
"update": can_update,
"partial_update": can_update,
"update": is_owner_or_admin or is_editor,
"partial_update": is_owner_or_admin or is_editor,
"retrieve": can_get,
}
@@ -917,8 +850,6 @@ class Invitation(BaseModel):
User,
on_delete=models.CASCADE,
related_name="invitations",
blank=True,
null=True,
)
class Meta:

View File

@@ -67,19 +67,10 @@ 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)
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
json_response = json.loads(sanitized_content)
if "answer" not in json_response:
raise RuntimeError("AI response does not contain an answer")

View File

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

View File

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

View File

@@ -11,9 +11,6 @@ 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
@@ -319,11 +316,7 @@ 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,
mock_reset_connections, # pylint: disable=redefined-outer-name
):
def test_api_document_accesses_update_administrator_except_owner(via, mock_user_teams):
"""
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.
@@ -358,21 +351,18 @@ def test_api_document_accesses_update_administrator_except_owner(
for field, value in new_values.items():
new_data = {**old_values, field: value}
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",
)
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
assert response.status_code == 403
else:
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
assert response.status_code == 200
access.refresh_from_db()
updated_values = serializers.DocumentAccessSerializer(instance=access).data
@@ -430,11 +420,7 @@ 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,
mock_reset_connections, # pylint: disable=redefined-outer-name
):
def test_api_document_accesses_update_administrator_to_owner(via, mock_user_teams):
"""
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.
@@ -471,23 +457,16 @@ def test_api_document_accesses_update_administrator_to_owner(
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:
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
assert response.status_code == 200
access.refresh_from_db()
updated_values = serializers.DocumentAccessSerializer(instance=access).data
@@ -495,11 +474,7 @@ def test_api_document_accesses_update_administrator_to_owner(
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_update_owner(
via,
mock_user_teams,
mock_reset_connections, # pylint: disable=redefined-outer-name
):
def test_api_document_accesses_update_owner(via, mock_user_teams):
"""
A user who is an owner in a document should be allowed to update
a user access for this document whatever the role.
@@ -532,24 +507,18 @@ def test_api_document_accesses_update_owner(
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:
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
assert response.status_code == 200
access.refresh_from_db()
updated_values = serializers.DocumentAccessSerializer(instance=access).data
@@ -561,11 +530,7 @@ def test_api_document_accesses_update_owner(
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_update_owner_self(
via,
mock_user_teams,
mock_reset_connections, # pylint: disable=redefined-outer-name
):
def test_api_document_accesses_update_owner_self(via, mock_user_teams):
"""
A user who is owner of a document should be allowed to update
their own user access provided there are other owners in the document.
@@ -603,23 +568,21 @@ def test_api_document_accesses_update_owner_self(
# Add another owner and it should now work
factories.UserDocumentAccessFactory(document=document, role="owner")
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",
)
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
@@ -693,9 +656,7 @@ 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,
mock_reset_connections, # pylint: disable=redefined-outer-name
via, mock_user_teams
):
"""
Users who are administrators in a document should be allowed to delete an access
@@ -724,13 +685,12 @@ def test_api_document_accesses_delete_administrators_except_owners(
assert models.DocumentAccess.objects.count() == 2
assert models.DocumentAccess.objects.filter(user=access.user).exists()
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}/",
)
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)
@@ -769,11 +729,7 @@ 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,
mock_reset_connections, # pylint: disable=redefined-outer-name
):
def test_api_document_accesses_delete_owners(via, mock_user_teams):
"""
Users should be able to delete the document access of another user
for a document of which they are owner.
@@ -797,13 +753,12 @@ def test_api_document_accesses_delete_owners(
assert models.DocumentAccess.objects.count() == 2
assert models.DocumentAccess.objects.filter(user=access.user).exists()
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}/",
)
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 204
assert models.DocumentAccess.objects.count() == 1
assert response.status_code == 204
assert models.DocumentAccess.objects.count() == 1
@pytest.mark.parametrize("via", VIA)

View File

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

View File

@@ -7,7 +7,6 @@ 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
@@ -340,7 +339,6 @@ 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",
(
@@ -404,13 +402,10 @@ 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} ({user.email}) invited you with the role &quot;{invited}&quot; "
f"on the following document: {document.title}"
) in email_content
assert "My brand name" in email_content
assert "my-img.jpg" in email_content
f"{user.full_name} shared a document with you: {document.title}"
in email_content
)
else:
assert models.Invitation.objects.exists() is False
@@ -457,7 +452,10 @@ 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!" in email_content
assert (
f"{user.full_name} a partagé un document avec vous: {document.title}"
in email_content
)
def test_api_document_invitations_create_email_from_content_language_not_supported():
@@ -496,7 +494,10 @@ 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!" in email_content
assert (
f"{user.full_name} shared a document with you: {document.title}"
in email_content
)
def test_api_document_invitations_create_email_full_name_empty():
@@ -534,10 +535,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!" in email_content
assert f"{user.email} shared a document with you: {document.title}" in email_content
assert (
f"{user.email.capitalize()} invited you with the role &quot;reader&quot; on the "
f"following document: {document.title}" in email_content
f'{user.email} invited you with the role "reader" on the '
f"following document : {document.title}" in email_content
)

View File

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

View File

@@ -1,308 +0,0 @@
"""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

View File

@@ -6,9 +6,6 @@ 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
@@ -119,10 +116,7 @@ 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,
mock_reset_connections, # pylint: disable=redefined-outer-name
via, role, mock_user_teams
):
"""
A user who is administrator or owner of a document should be allowed to update
@@ -145,16 +139,14 @@ 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
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]
document = models.Document.objects.get(pk=document.pk)
document_values = serializers.LinkDocumentSerializer(instance=document).data
for key, value in document_values.items():
assert value == new_document_values[key]

View File

@@ -3,9 +3,7 @@ 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
@@ -34,47 +32,7 @@ def test_api_documents_list_anonymous(reach, role):
assert len(results) == 0
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):
def test_api_documents_list_authenticated_direct():
"""
Authenticated users should be able to list documents they are a direct
owner/administrator/member of or documents that have a link reach other
@@ -97,8 +55,9 @@ def test_api_documents_list_authenticated_direct(django_assert_num_queries):
expected_ids = {str(document.id) for document in documents}
with django_assert_num_queries(3):
response = client.get("/api/v1.0/documents/")
response = client.get(
"/api/v1.0/documents/",
)
assert response.status_code == 200
results = response.json()["results"]
@@ -107,9 +66,7 @@ def test_api_documents_list_authenticated_direct(django_assert_num_queries):
assert expected_ids == results_id
def test_api_documents_list_authenticated_via_team(
django_assert_num_queries, mock_user_teams
):
def test_api_documents_list_authenticated_via_team(mock_user_teams):
"""
Authenticated users should be able to list documents they are a
owner/administrator/member of via a team.
@@ -132,8 +89,7 @@ def test_api_documents_list_authenticated_via_team(
expected_ids = {str(document.id) for document in documents_team1 + documents_team2}
with django_assert_num_queries(3):
response = client.get("/api/v1.0/documents/")
response = client.get("/api/v1.0/documents/")
assert response.status_code == 200
results = response.json()["results"]
@@ -142,9 +98,7 @@ def test_api_documents_list_authenticated_via_team(
assert expected_ids == results_id
def test_api_documents_list_authenticated_link_reach_restricted(
django_assert_num_queries,
):
def test_api_documents_list_authenticated_link_reach_restricted():
"""
An authenticated user who has link traces to a document that is restricted should not
see it on the list view
@@ -161,10 +115,9 @@ def test_api_documents_list_authenticated_link_reach_restricted(
other_document = factories.DocumentFactory(link_reach="public")
models.LinkTrace.objects.create(document=other_document, user=user)
with django_assert_num_queries(3):
response = client.get(
"/api/v1.0/documents/",
)
response = client.get(
"/api/v1.0/documents/",
)
assert response.status_code == 200
results = response.json()["results"]
@@ -174,9 +127,7 @@ 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(
django_assert_num_queries,
):
def test_api_documents_list_authenticated_link_reach_public_or_authenticated():
"""
An authenticated user who has link traces to a document with public or authenticated
link reach should see it on the list view.
@@ -193,10 +144,9 @@ def test_api_documents_list_authenticated_link_reach_public_or_authenticated(
]
expected_ids = {str(document.id) for document in documents}
with django_assert_num_queries(3):
response = client.get(
"/api/v1.0/documents/",
)
response = client.get(
"/api/v1.0/documents/",
)
assert response.status_code == 200
results = response.json()["results"]
@@ -274,143 +224,6 @@ 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()
@@ -441,14 +254,10 @@ def test_api_documents_list_ordering_by_fields():
for parameter in [
"created_at",
"-created_at",
"is_favorite",
"-is_favorite",
"nb_accesses",
"-nb_accesses",
"title",
"-title",
"updated_at",
"-updated_at",
"title",
"-title",
]:
is_descending = parameter.startswith("-")
field = parameter.lstrip("-")
@@ -463,212 +272,3 @@ 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()

View File

@@ -26,13 +26,9 @@ def test_api_documents_retrieve_anonymous_public():
"ai_transform": document.link_role == "editor",
"ai_translate": document.link_role == "editor",
"attachment_upload": document.link_role == "editor",
"collaboration_auth": True,
"destroy": False,
# Anonymous user can't favorite a document even with read access
"favorite": False,
"invite_owner": False,
"link_configuration": False,
"media_auth": True,
"partial_update": document.link_role == "editor",
"retrieve": True,
"update": document.link_role == "editor",
@@ -40,14 +36,12 @@ def test_api_documents_retrieve_anonymous_public():
"versions_list": False,
"versions_retrieve": False,
},
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"is_favorite": False,
"accesses": [],
"link_reach": "public",
"link_role": document.link_role,
"nb_accesses": 0,
"title": document.title,
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}
@@ -90,12 +84,9 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
"ai_transform": document.link_role == "editor",
"ai_translate": document.link_role == "editor",
"attachment_upload": document.link_role == "editor",
"collaboration_auth": True,
"destroy": False,
"favorite": True,
"invite_owner": False,
"media_auth": True,
"link_configuration": False,
"destroy": False,
"invite_owner": False,
"partial_update": document.link_role == "editor",
"retrieve": True,
"update": document.link_role == "editor",
@@ -103,14 +94,12 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
"versions_list": False,
"versions_retrieve": False,
},
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"is_favorite": False,
"accesses": [],
"link_reach": reach,
"link_role": document.link_role,
"nb_accesses": 0,
"title": document.title,
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}
assert (
@@ -179,26 +168,43 @@ def test_api_documents_retrieve_authenticated_related_direct():
client.force_login(user)
document = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=document, user=user)
access1 = factories.UserDocumentAccessFactory(document=document, user=user)
access2 = factories.UserDocumentAccessFactory(document=document)
serializers.UserSerializer(instance=user)
serializers.UserSerializer(instance=access2.user)
access1_user = serializers.UserSerializer(instance=user).data
access2_user = serializers.UserSerializer(instance=access2.user).data
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),
"abilities": document.get_abilities(user),
"title": document.title,
"content": document.content,
"creator": str(document.creator.id),
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"is_favorite": False,
"abilities": document.get_abilities(user),
"link_reach": document.link_reach,
"link_role": document.link_role,
"nb_accesses": 2,
"title": document.title,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}
@@ -251,7 +257,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.
are related via a team whatever the role and see all its accesses.
"""
mock_user_teams.return_value = teams
@@ -262,34 +268,81 @@ def test_api_documents_retrieve_authenticated_related_team_members(
document = factories.DocumentFactory(link_reach="restricted")
factories.TeamDocumentAccessFactory(
access_reader = factories.TeamDocumentAccessFactory(
document=document, team="readers", role="reader"
)
factories.TeamDocumentAccessFactory(
access_editor = factories.TeamDocumentAccessFactory(
document=document, team="editors", role="editor"
)
factories.TeamDocumentAccessFactory(
access_administrator = factories.TeamDocumentAccessFactory(
document=document, team="administrators", role="administrator"
)
factories.TeamDocumentAccessFactory(document=document, team="owners", role="owner")
factories.TeamDocumentAccessFactory(document=document)
access_owner = factories.TeamDocumentAccessFactory(
document=document, team="owners", role="owner"
)
other_access = 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),
"abilities": document.get_abilities(user),
"title": document.title,
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"is_favorite": False,
"abilities": document.get_abilities(user),
"link_reach": "restricted",
"link_role": document.link_role,
"nb_accesses": 5,
"title": document.title,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}
@@ -307,7 +360,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.
are related via a team whatever the role and see all its accesses.
"""
mock_user_teams.return_value = teams
@@ -318,34 +371,98 @@ def test_api_documents_retrieve_authenticated_related_team_administrators(
document = factories.DocumentFactory(link_reach="restricted")
factories.TeamDocumentAccessFactory(
access_reader = factories.TeamDocumentAccessFactory(
document=document, team="readers", role="reader"
)
factories.TeamDocumentAccessFactory(
access_editor = factories.TeamDocumentAccessFactory(
document=document, team="editors", role="editor"
)
factories.TeamDocumentAccessFactory(
access_administrator = factories.TeamDocumentAccessFactory(
document=document, team="administrators", role="administrator"
)
factories.TeamDocumentAccessFactory(document=document, team="owners", role="owner")
factories.TeamDocumentAccessFactory(document=document)
access_owner = factories.TeamDocumentAccessFactory(
document=document, team="owners", role="owner"
)
other_access = 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),
"abilities": document.get_abilities(user),
"title": document.title,
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"is_favorite": False,
"abilities": document.get_abilities(user),
"link_reach": "restricted",
"link_role": document.link_role,
"nb_accesses": 5,
"title": document.title,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}
@@ -364,7 +481,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.
they are related via a team whatever the role and see all its accesses.
"""
mock_user_teams.return_value = teams
@@ -375,33 +492,100 @@ def test_api_documents_retrieve_authenticated_related_team_owners(
document = factories.DocumentFactory(link_reach="restricted")
factories.TeamDocumentAccessFactory(
access_reader = factories.TeamDocumentAccessFactory(
document=document, team="readers", role="reader"
)
factories.TeamDocumentAccessFactory(
access_editor = factories.TeamDocumentAccessFactory(
document=document, team="editors", role="editor"
)
factories.TeamDocumentAccessFactory(
access_administrator = factories.TeamDocumentAccessFactory(
document=document, team="administrators", role="administrator"
)
factories.TeamDocumentAccessFactory(document=document, team="owners", role="owner")
factories.TeamDocumentAccessFactory(document=document)
access_owner = factories.TeamDocumentAccessFactory(
document=document, team="owners", role="owner"
)
other_access = 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),
"abilities": document.get_abilities(user),
"title": document.title,
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"is_favorite": False,
"abilities": document.get_abilities(user),
"link_reach": "restricted",
"link_role": document.link_role,
"nb_accesses": 5,
"title": document.title,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}

View File

@@ -20,7 +20,7 @@ from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
def test_api_documents_media_auth_anonymous_public():
def test_api_documents_retrieve_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_media_auth_anonymous_public():
original_url = f"http://localhost/media/{key:s}"
response = APIClient().get(
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url
"/api/v1.0/documents/retrieve-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 200
@@ -65,7 +65,7 @@ def test_api_documents_media_auth_anonymous_public():
@pytest.mark.parametrize("reach", ["authenticated", "restricted"])
def test_api_documents_media_auth_anonymous_authenticated_or_restricted(reach):
def test_api_documents_retrieve_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_media_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/media-auth/", HTTP_X_ORIGINAL_URL=media_url
"/api/v1.0/documents/retrieve-auth/", HTTP_X_ORIGINAL_URL=media_url
)
assert response.status_code == 403
@@ -84,7 +84,7 @@ def test_api_documents_media_auth_anonymous_authenticated_or_restricted(reach):
@pytest.mark.parametrize("reach", ["public", "authenticated"])
def test_api_documents_media_auth_authenticated_public_or_authenticated(reach):
def test_api_documents_retrieve_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_media_auth_authenticated_public_or_authenticated(reach):
original_url = f"http://localhost/media/{key:s}"
response = client.get(
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url
"/api/v1.0/documents/retrieve-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 200
@@ -135,7 +135,7 @@ def test_api_documents_media_auth_authenticated_public_or_authenticated(reach):
assert response.content.decode("utf-8") == "my prose"
def test_api_documents_media_auth_authenticated_restricted():
def test_api_documents_retrieve_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_media_auth_authenticated_restricted():
media_url = f"http://localhost/media/{document.pk!s}/attachments/{filename:s}"
response = client.get(
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url
"/api/v1.0/documents/retrieve-auth/", HTTP_X_ORIGINAL_URL=media_url
)
assert response.status_code == 403
@@ -158,7 +158,7 @@ def test_api_documents_media_auth_authenticated_restricted():
@pytest.mark.parametrize("via", VIA)
def test_api_documents_media_auth_related(via, mock_user_teams):
def test_api_documents_retrieve_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_media_auth_related(via, mock_user_teams):
original_url = f"http://localhost/media/{key:s}"
response = client.get(
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url
"/api/v1.0/documents/retrieve-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 200

View File

@@ -132,14 +132,7 @@ 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",
"creator",
"link_reach",
"link_role",
]:
if key in ["id", "accesses", "created_at", "link_reach", "link_role"]:
assert value == old_document_values[key]
elif key == "updated_at":
assert value > old_document_values[key]
@@ -223,14 +216,7 @@ def test_api_documents_update_authenticated_editor_administrator_or_owner(
document = models.Document.objects.get(pk=document.pk)
document_values = serializers.DocumentSerializer(instance=document).data
for key, value in document_values.items():
if key in [
"id",
"created_at",
"creator",
"link_reach",
"link_role",
"nb_accesses",
]:
if key in ["id", "accesses", "created_at", "link_reach", "link_role"]:
assert value == old_document_values[key]
elif key == "updated_at":
assert value > old_document_values[key]
@@ -269,14 +255,7 @@ def test_api_documents_update_authenticated_owners(via, mock_user_teams):
document = models.Document.objects.get(pk=document.pk)
document_values = serializers.DocumentSerializer(instance=document).data
for key, value in document_values.items():
if key in [
"id",
"created_at",
"creator",
"link_reach",
"link_role",
"nb_accesses",
]:
if key in ["id", "accesses", "created_at", "link_reach", "link_role"]:
assert value == old_document_values[key]
elif key == "updated_at":
assert value > old_document_values[key]

View File

@@ -1,45 +0,0 @@
"""
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",
}

View File

@@ -32,22 +32,15 @@ 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, creator=factories.UserFactory()
)
document = models.Document.objects.create(title=None)
assert document.title is None
def test_models_documents_title_empty():
"""The "title" field can be empty."""
document = models.Document.objects.create(title="", creator=factories.UserFactory())
document = models.Document.objects.create(title="")
assert document.title == ""
@@ -95,12 +88,9 @@ def test_models_documents_get_abilities_forbidden(is_authenticated, reach, role)
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
"collaboration_auth": False,
"destroy": False,
"favorite": False,
"invite_owner": False,
"media_auth": False,
"link_configuration": False,
"destroy": False,
"invite_owner": False,
"partial_update": False,
"retrieve": False,
"update": False,
@@ -132,12 +122,9 @@ def test_models_documents_get_abilities_reader(is_authenticated, reach):
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
"collaboration_auth": True,
"destroy": False,
"favorite": is_authenticated,
"invite_owner": False,
"link_configuration": False,
"media_auth": True,
"invite_owner": False,
"partial_update": False,
"retrieve": True,
"update": False,
@@ -169,12 +156,9 @@ def test_models_documents_get_abilities_editor(is_authenticated, reach):
"ai_transform": True,
"ai_translate": True,
"attachment_upload": True,
"collaboration_auth": True,
"destroy": False,
"favorite": is_authenticated,
"invite_owner": False,
"link_configuration": False,
"media_auth": True,
"invite_owner": False,
"partial_update": True,
"retrieve": True,
"update": True,
@@ -195,12 +179,9 @@ def test_models_documents_get_abilities_owner():
"ai_transform": True,
"ai_translate": True,
"attachment_upload": True,
"collaboration_auth": True,
"destroy": True,
"favorite": True,
"invite_owner": True,
"link_configuration": True,
"media_auth": True,
"invite_owner": True,
"partial_update": True,
"retrieve": True,
"update": True,
@@ -220,12 +201,9 @@ def test_models_documents_get_abilities_administrator():
"ai_transform": True,
"ai_translate": True,
"attachment_upload": True,
"collaboration_auth": True,
"destroy": False,
"favorite": True,
"invite_owner": False,
"link_configuration": True,
"media_auth": True,
"invite_owner": False,
"partial_update": True,
"retrieve": True,
"update": True,
@@ -248,12 +226,9 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
"ai_transform": True,
"ai_translate": True,
"attachment_upload": True,
"collaboration_auth": True,
"destroy": False,
"favorite": True,
"invite_owner": False,
"link_configuration": False,
"media_auth": True,
"invite_owner": False,
"partial_update": True,
"retrieve": True,
"update": True,
@@ -278,12 +253,9 @@ def test_models_documents_get_abilities_reader_user(django_assert_num_queries):
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
"collaboration_auth": True,
"destroy": False,
"favorite": True,
"invite_owner": False,
"link_configuration": False,
"media_auth": True,
"invite_owner": False,
"partial_update": False,
"retrieve": True,
"update": False,
@@ -309,12 +281,9 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
"collaboration_auth": True,
"destroy": False,
"favorite": True,
"invite_owner": False,
"link_configuration": False,
"media_auth": True,
"invite_owner": False,
"partial_update": False,
"retrieve": True,
"update": False,
@@ -427,8 +396,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.send_invitation_email(
"guest@example.com", models.RoleChoices.EDITOR, sender, "en"
document.email_invitation(
"en", "guest@example.com", models.RoleChoices.EDITOR, sender
)
# pylint: disable-next=no-member
@@ -441,8 +410,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 &quot;editor&quot; "
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
@@ -459,11 +428,11 @@ def test_models_documents__email_invitation__success_fr():
sender = factories.UserFactory(
full_name="Test Sender2", email="sender2@example.com"
)
document.send_invitation_email(
document.email_invitation(
"fr-fr",
"guest2@example.com",
models.RoleChoices.OWNER,
sender,
"fr-fr",
)
# pylint: disable-next=no-member
@@ -476,8 +445,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 &quot;propriétaire&quot; "
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
@@ -495,11 +464,11 @@ def test_models_documents__email_invitation__failed(mock_logger, _mock_send_mail
assert len(mail.outbox) == 0
sender = factories.UserFactory()
document.send_invitation_email(
document.email_invitation(
"en",
"guest3@example.com",
models.RoleChoices.ADMIN,
sender,
"en",
)
# No email has been sent
@@ -511,9 +480,9 @@ def test_models_documents__email_invitation__failed(mock_logger, _mock_send_mail
(
_,
emails,
email,
exception,
) = mock_logger.call_args.args
assert emails == ["guest3@example.com"]
assert email == "guest3@example.com"
assert isinstance(exception, smtplib.SMTPException)

View File

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

View File

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

View File

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

View File

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

View File

@@ -55,5 +55,4 @@ urlpatterns = [
]
),
),
path(f"api/{settings.API_VERSION}/config/", viewsets.ConfigView.as_view()),
]

View File

@@ -132,13 +132,10 @@ 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)
@@ -150,6 +147,7 @@ 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,

View File

@@ -10,9 +10,8 @@ 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 _
@@ -28,12 +27,19 @@ 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, "pyproject.toml"), "rb") as f:
pyproject_data = tomllib.load(f)
return pyproject_data["project"]["version"]
except (FileNotFoundError, KeyError):
with open(os.path.join(BASE_DIR, "version.json"), encoding="utf8") as version:
return json.load(version)["version"]
except FileNotFoundError:
return "NA" # Default: not available
@@ -50,7 +56,7 @@ class Base(Configuration):
You may also want to override default configuration by setting the following environment
variables:
* SENTRY_DSN
* DJANGO_SENTRY_DSN
* DB_NAME
* DB_HOST
* DB_PASSWORD
@@ -65,7 +71,6 @@ 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"
@@ -99,9 +104,6 @@ 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
@@ -352,11 +354,9 @@ class Base(Configuration):
# Mail
EMAIL_BACKEND = values.Value("django.core.mail.backends.smtp.EmailBackend")
EMAIL_BRAND_NAME = values.Value(None)
EMAIL_HOST = values.Value(None)
EMAIL_HOST_USER = values.Value(None)
EMAIL_HOST_PASSWORD = values.Value(None)
EMAIL_LOGO_IMG = values.Value(None)
EMAIL_PORT = values.PositiveIntegerValue(None)
EMAIL_USE_TLS = values.BooleanValue(False)
EMAIL_USE_SSL = values.BooleanValue(False)
@@ -372,28 +372,7 @@ class Base(Configuration):
CORS_ALLOWED_ORIGIN_REGEXES = values.ListValue([])
# Sentry
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
)
SENTRY_DSN = values.Value(None, environ_name="SENTRY_DSN")
# Easy thumbnails
THUMBNAIL_EXTENSION = "webp"
@@ -474,22 +453,9 @@ 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)
@@ -505,74 +471,17 @@ class Base(Configuration):
"day": 200,
}
# Y provider microservice
Y_PROVIDER_API_KEY = values.Value(
environ_name="Y_PROVIDER_API_KEY",
USER_OIDC_FIELDS_TO_FULLNAME = values.ListValue(
default=["first_name", "last_name"],
environ_name="USER_OIDC_FIELDS_TO_FULLNAME",
environ_prefix=None,
)
Y_PROVIDER_API_BASE_URL = values.Value(
environ_name="Y_PROVIDER_API_BASE_URL",
USER_OIDC_FIELD_TO_SHORTNAME = values.Value(
default="first_name",
environ_name="USER_OIDC_FIELD_TO_SHORTNAME",
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):
@@ -668,6 +577,23 @@ 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",
]
@@ -698,13 +624,7 @@ class Production(Base):
"""
# Security
# 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()),
]
ALLOWED_HOSTS = values.ListValue(None)
CSRF_TRUSTED_ORIGINS = values.ListValue([])
SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True

View File

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

View File

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

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-people\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-12-17 15:50+0000\n"
"PO-Revision-Date: 2024-12-17 15:53\n"
"POT-Creation-Date: 2024-10-15 07:19+0000\n"
"PO-Revision-Date: 2024-10-15 07:23\n"
"Last-Translator: \n"
"Language-Team: French\n"
"Language: fr_FR\n"
@@ -29,35 +29,15 @@ msgstr "Permissions"
msgid "Important dates"
msgstr "Dates importantes"
#: 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
#: core/api/serializers.py:253
msgid "Body"
msgstr ""
#: core/api/serializers.py:417
#: core/api/serializers.py:256
msgid "Body type"
msgstr ""
#: core/api/serializers.py:423
#: core/api/serializers.py:262
msgid "Format"
msgstr ""
@@ -69,10 +49,6 @@ 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"
@@ -89,246 +65,224 @@ msgstr "Administrateur"
msgid "Owner"
msgstr "Propriétaire"
#: core/models.py:83
#: core/models.py:80
msgid "Restricted"
msgstr "Restreint"
#: core/models.py:87
#: core/models.py:84
msgid "Authenticated"
msgstr "Authentifié"
#: core/models.py:89
#: core/models.py:86
msgid "Public"
msgstr "Public"
#: core/models.py:101
#: core/models.py:98
msgid "id"
msgstr ""
#: core/models.py:102
#: core/models.py:99
msgid "primary key for the record as UUID"
msgstr ""
#: core/models.py:108
#: core/models.py:105
msgid "created on"
msgstr ""
#: core/models.py:109
#: core/models.py:106
msgid "date and time at which a record was created"
msgstr ""
#: core/models.py:114
#: core/models.py:111
msgid "updated on"
msgstr ""
#: core/models.py:115
#: core/models.py:112
msgid "date and time at which a record was last updated"
msgstr ""
#: core/models.py:135
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
#: core/models.py:132
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters."
msgstr ""
#: core/models.py:141
#: core/models.py:138
msgid "sub"
msgstr ""
#: core/models.py:143
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
#: core/models.py:140
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
msgstr ""
#: core/models.py:152
#: core/models.py:149
msgid "full name"
msgstr ""
#: core/models.py:153
#: core/models.py:150
msgid "short name"
msgstr ""
#: core/models.py:155
#: core/models.py:152
msgid "identity email address"
msgstr ""
#: core/models.py:160
#: core/models.py:157
msgid "admin email address"
msgstr ""
#: core/models.py:167
#: core/models.py:164
msgid "language"
msgstr ""
#: core/models.py:168
#: core/models.py:165
msgid "The language in which the user wants to see the interface."
msgstr ""
#: core/models.py:174
#: core/models.py:171
msgid "The timezone in which the user wants to see times."
msgstr ""
#: core/models.py:177
#: core/models.py:174
msgid "device"
msgstr ""
#: core/models.py:179
#: core/models.py:176
msgid "Whether the user is a device or a real user."
msgstr ""
#: core/models.py:182
#: core/models.py:179
msgid "staff status"
msgstr ""
#: core/models.py:184
#: core/models.py:181
msgid "Whether the user can log into this admin site."
msgstr ""
#: core/models.py:187
#: core/models.py:184
msgid "active"
msgstr ""
#: core/models.py:190
#: core/models.py:187
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: core/models.py:202
#: core/models.py:199
msgid "user"
msgstr ""
#: core/models.py:203
#: core/models.py:200
msgid "users"
msgstr ""
#: core/models.py:342 core/models.py:718
#: core/models.py:332 core/models.py:638
msgid "title"
msgstr ""
#: core/models.py:364
#: core/models.py:347
msgid "Document"
msgstr ""
#: core/models.py:365
#: core/models.py:348
msgid "Documents"
msgstr ""
#: core/models.py:368
#: core/models.py:351
msgid "Untitled Document"
msgstr ""
#: 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: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: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
#: core/models.py:574
msgid "Document/user link trace"
msgstr ""
#: core/models.py:624
#: core/models.py:575
msgid "Document/user link traces"
msgstr ""
#: core/models.py:630
#: core/models.py:581
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
#: core/models.py:602
msgid "Document/user relation"
msgstr ""
#: core/models.py:683
#: core/models.py:603
msgid "Document/user relations"
msgstr ""
#: core/models.py:689
#: core/models.py:609
msgid "This user is already in this document."
msgstr ""
#: core/models.py:695
#: core/models.py:615
msgid "This team is already in this document."
msgstr ""
#: core/models.py:701 core/models.py:890
#: core/models.py:621 core/models.py:810
msgid "Either user or team must be set, not both."
msgstr ""
#: core/models.py:719
#: core/models.py:639
msgid "description"
msgstr ""
#: core/models.py:720
#: core/models.py:640
msgid "code"
msgstr ""
#: core/models.py:721
#: core/models.py:641
msgid "css"
msgstr ""
#: core/models.py:723
#: core/models.py:643
msgid "public"
msgstr ""
#: core/models.py:725
#: core/models.py:645
msgid "Whether this template is public for anyone to use."
msgstr ""
#: core/models.py:731
#: core/models.py:651
msgid "Template"
msgstr ""
#: core/models.py:732
#: core/models.py:652
msgid "Templates"
msgstr ""
#: core/models.py:871
#: core/models.py:791
msgid "Template/user relation"
msgstr ""
#: core/models.py:872
#: core/models.py:792
msgid "Template/user relations"
msgstr ""
#: core/models.py:878
#: core/models.py:798
msgid "This user is already in this template."
msgstr ""
#: core/models.py:884
#: core/models.py:804
msgid "This team is already in this template."
msgstr ""
#: core/models.py:907
#: core/models.py:827
msgid "email address"
msgstr ""
#: core/models.py:926
#: core/models.py:844
msgid "Document invitation"
msgstr ""
#: core/models.py:927
#: core/models.py:845
msgid "Document invitations"
msgstr ""
#: core/models.py:944
#: core/models.py:862
msgid "This email is already associated to a registered user."
msgstr ""
@@ -354,41 +308,51 @@ 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:162
#: core/templates/mail/html/invitation.html:159
#: core/templates/mail/text/invitation.txt:3
msgid "Logo email"
msgid "La Suite Numérique"
msgstr ""
#: core/templates/mail/html/invitation.html:209
#: 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/text/invitation.txt:10
msgid "Open"
msgstr "Ouvrir"
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/html/invitation.html:222
#: 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:233
#: core/templates/mail/html/invitation.html:229
#: core/templates/mail/text/invitation.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
msgstr " Proposé par %(brandname)s "
msgid "Brought to you by La Suite Numérique"
msgstr "Proposé par La Suite Numérique"
#: core/templates/mail/text/hello.txt:8
#, python-format
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
msgstr ""
#: impress/settings.py:236
#: impress/settings.py:177
msgid "English"
msgstr ""
#: impress/settings.py:237
#: impress/settings.py:178
msgid "French"
msgstr ""
#: impress/settings.py:238
#: impress/settings.py:176
msgid "German"
msgstr ""

View File

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

View File

@@ -1,3 +1,33 @@
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/
@@ -10,9 +40,7 @@ COPY ./src/frontend/packages/eslint-config-impress/package.json ./packages/eslin
RUN yarn install --frozen-lockfile
COPY .dockerignore ./.dockerignore
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
COPY ./src/frontend/ .
### ---- Front-end builder image ----
FROM frontend-deps AS impress
@@ -33,9 +61,18 @@ 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}

View File

@@ -36,7 +36,7 @@ export const createDoc = async (
await page
.getByRole('button', {
name: 'Create a new document',
name: 'New doc',
})
.click();

View File

@@ -1,161 +0,0 @@
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();
});
});

View File

@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
import { createDoc, goToGridDoc, keyCloakSignIn, randomName } from './common';
import { createDoc } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -29,47 +29,3 @@ 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();
});
});

View File

@@ -81,69 +81,26 @@ test.describe('Doc Editor', () => {
).toBeVisible();
});
/**
* 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 ({
test('checks the Doc is connected to the provider server', async ({
page,
browserName,
}) => {
let webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
return webSocket
.url()
.includes('ws://localhost:8083/collaboration/ws/?room=');
const webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
return webSocket.url().includes('ws://localhost:4444/');
});
const randomDoc = await createDoc(page, 'doc-editor', browserName, 1);
await expect(page.locator('h2').getByText(randomDoc[0])).toBeVisible();
let webSocket = await webSocketPromise;
expect(webSocket.url()).toContain(
'ws://localhost:8083/collaboration/ws/?room=',
);
const webSocket = await webSocketPromise;
expect(webSocket.url()).toContain('ws://localhost:4444/');
// Is connected
let framesentPromise = webSocket.waitForEvent('framesent');
const framesentPromise = webSocket.waitForEvent('framesent');
await page.locator('.ProseMirror.bn-editor').click();
await page.locator('.ProseMirror.bn-editor').fill('Hello World');
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;
const framesent = await framesentPromise;
expect(framesent.payload).not.toBeNull();
});
@@ -233,7 +190,7 @@ test.describe('Doc Editor', () => {
test.skip(browserName === 'webkit', 'This test is very flaky with webkit');
// Check the first doc
const [doc] = await createDoc(page, 'doc-quit-1', browserName, 1);
const doc = await goToGridDoc(page);
await expect(page.locator('h2').getByText(doc)).toBeVisible();
const editor = page.locator('.ProseMirror');
@@ -272,8 +229,8 @@ test.describe('Doc Editor', () => {
).toBeVisible();
});
test('it adds an image to the doc editor', async ({ page, browserName }) => {
await createDoc(page, 'doc-image', browserName, 1);
test('it adds an image to the doc editor', async ({ page }) => {
await goToGridDoc(page);
const fileChooserPromise = page.waitForEvent('filechooser');

View File

@@ -65,6 +65,9 @@ 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();
});
@@ -83,7 +86,7 @@ test.describe('Doc Header', () => {
test('it updates the title doc from editor heading', async ({ page }) => {
await page
.getByRole('button', {
name: 'Create a new document',
name: 'New doc',
})
.click();
@@ -120,9 +123,6 @@ 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(' ');
@@ -159,9 +159,7 @@ test.describe('Doc Header', () => {
page.getByText('The document has been deleted.'),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Create a new document' }),
).toBeVisible();
await expect(page.getByRole('button', { name: 'New do' })).toBeVisible();
const row = page
.getByLabel('Datagrid of the documents page 1')
@@ -414,7 +412,7 @@ test.describe('Doc Header', () => {
// create page and navigate to it
await page
.getByRole('button', {
name: 'Create a new document',
name: 'New doc',
})
.click();
@@ -449,7 +447,7 @@ test.describe('Doc Header', () => {
// create page and navigate to it
await page
.getByRole('button', {
name: 'Create a new document',
name: 'New doc',
})
.click();

View File

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

View File

@@ -36,6 +36,7 @@ test.describe('Doc Table Content', () => {
await page.getByRole('button', { name: 'Strike' }).click();
await page.locator('.bn-block-outer').first().click();
await editor.click();
await page.locator('.bn-block-outer').last().click();
// Create space to fill the viewport

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,2 +1,5 @@
NEXT_PUBLIC_API_ORIGIN=
NEXT_PUBLIC_Y_PROVIDER_URL=
NEXT_PUBLIC_MEDIA_URL=
NEXT_PUBLIC_THEME=dsfr
NEXT_PUBLIC_SW_DEACTIVATED=

View File

@@ -1,2 +1,4 @@
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

View File

@@ -1 +1,2 @@
NEXT_PUBLIC_API_ORIGIN=http://test.jest
NEXT_PUBLIC_THEME=test-theme

View File

@@ -3,24 +3,30 @@ const config = {
default: {
theme: {
colors: {
'card-border': '#ededed',
'card-border': '#E5E5E5',
'primary-bg': '#FAFAFA',
'primary-100': '#EDF5FA',
'primary-150': '#E5EEFA',
'info-150': '#E5EEFA',
'greyscale-000': '#fff',
'greyscale-1000': '#161616',
},
font: {
sizes: {
ml: '0.938rem',
xl: '1.50rem',
t: '0.6875rem',
s: '0.75rem',
h1: '2.2rem',
h2: '1.7rem',
h3: '1.37rem',
h4: '1.15rem',
h5: '1rem',
h6: '0.87rem',
xl: '20px',
lg: '18px',
md: '16px',
sm: '14px',
xs: '12px',
h1: '32px',
h2: '28px',
h3: '24px',
h4: '22px',
h5: '20px',
h6: '18px',
},
weights: {
thin: 100,
@@ -34,6 +40,21 @@ const config = {
auto: 'auto',
bx: '2.2rem',
full: '100%',
'050V': '2px',
'100V': '4px',
'150V': '6px',
'100W': '8px',
'300V': '12px',
'200W': '16px',
'300W': '24px',
'400W': '32px',
'500W': '40px',
'600W': '48px',
'700W': '56px',
'800W': '64px',
'900W': '72px',
'1200W': '96px',
'1500W': '120px',
},
breakpoints: {
xxs: '320px',
@@ -46,6 +67,11 @@ const config = {
alt: '',
},
},
global: {
hover: {
'greyscale-100': '#055fd214',
},
},
components: {
datagrid: {
header: {
@@ -104,7 +130,7 @@ const config = {
focus: 'var(--c--components--forms-select--border-radius)',
},
'font-size': 'var(--c--theme--font--sizes--ml)',
'menu-background-color': '#ffffff',
'menu-background-color': '#fff',
'item-background-color': {
hover: 'var(--c--theme--colors--primary-300)',
},
@@ -126,7 +152,7 @@ const config = {
},
},
modal: {
'background-color': '#ffffff',
'background-color': '#fff',
},
button: {
'border-radius': {
@@ -196,6 +222,23 @@ const config = {
},
dsfr: {
theme: {
spacings: {
'050V': '2px',
'100V': '4px',
'150V': '6px',
'100W': '8px',
'300V': '12px',
'200W': '16px',
'300W': '24px',
'400W': '32px',
'500W': '40px',
'600W': '48px',
'700W': '56px',
'800W': '64px',
'900W': '72px',
'1200W': '96px',
'1500W': '120px',
},
colors: {
'card-border': '#ededed',
'primary-text': '#000091',
@@ -209,7 +252,7 @@ const config = {
'primary-700': '#272747',
'primary-800': '#21213f',
'primary-900': '#1c1a36',
'secondary-text': '#FFFFFF',
'secondary-text': '#fff',
'secondary-100': '#fee9ea',
'secondary-200': '#fedfdf',
'secondary-300': '#fdbfbf',
@@ -220,16 +263,16 @@ const config = {
'secondary-800': '#341f1f',
'secondary-900': '#2b1919',
'greyscale-text': '#303C4B',
'greyscale-000': '#f6f6f6',
'greyscale-100': '#eeeeee',
'greyscale-200': '#e5e5e5',
'greyscale-300': '#e1e1e1',
'greyscale-400': '#dddddd',
'greyscale-500': '#cecece',
'greyscale-600': '#7b7b7b',
'greyscale-700': '#666666',
'greyscale-800': '#2a2a2a',
'greyscale-900': '#1e1e1e',
'greyscale-000': '#fff',
'greyscale-050': '#F6F6F6',
'greyscale-100': '#eee',
'greyscale-200': '#E5E5E5',
'greyscale-250': '#ddd',
'greyscale-300': '#CECECE',
'greyscale-400': '#929292',
'greyscale-500': '#666',
'greyscale-700': '#3A3A3A',
'greyscale-1000': '#161616',
'success-text': '#1f8d49',
'success-100': '#dffee6',
'success-200': '#b8fec9',
@@ -276,6 +319,22 @@ const config = {
accent: 'Marianne',
base: 'Marianne',
},
size: {
ml: '0.938rem',
t: '0.6875rem',
s: '0.75rem',
xl: '20px',
lg: '18px',
md: '16px',
sm: '14px',
xs: '12px',
h1: '32px',
h2: '28px',
h3: '24px',
h4: '22px',
h5: '20px',
h6: '18px',
},
},
logo: {
src: '/assets/logo-gouv.svg',
@@ -297,9 +356,9 @@ const config = {
'color-hover': '#1212ff',
'color-active': '#2323ff',
},
color: '#ffffff',
'color-hover': '#ffffff',
'color-active': '#ffffff',
color: '#fff',
'color-hover': '#fff',
'color-active': '#fff',
},
'primary-text': {
background: {
@@ -363,7 +422,7 @@ const config = {
},
'forms-input': {
'border-radius': '4px',
'background-color': '#ffffff',
'background-color': '#fff',
'border-color': 'var(--c--theme--colors--primary-text)',
'box-shadow-color': 'var(--c--theme--colors--primary-text)',
'value-color': 'var(--c--theme--colors--primary-text)',
@@ -381,7 +440,7 @@ const config = {
'item-font-size': '14px',
'border-radius': '4px',
'border-radius-hover': '4px',
'background-color': '#ffffff',
'background-color': '#fff',
'border-color': 'var(--c--theme--colors--primary-text)',
'border-color-hover': 'var(--c--theme--colors--primary-text)',
'box-shadow-color': 'var(--c--theme--colors--primary-text)',

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "app-impress",
"version": "1.10.0",
"version": "1.7.0",
"private": true,
"scripts": {
"dev": "next dev",
@@ -19,53 +19,52 @@
"@blocknote/mantine": "*",
"@blocknote/react": "*",
"@gouvfr-lasuite/integration": "1.0.2",
"@hocuspocus/provider": "2.15.0",
"@hocuspocus/provider": "2.13.7",
"@openfun/cunningham-react": "2.9.4",
"@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",
"@tanstack/react-query": "5.59.15",
"i18next": "23.16.2",
"i18next-browser-languagedetector": "8.0.0",
"idb": "8.0.0",
"lodash": "4.17.21",
"luxon": "3.5.0",
"next": "15.1.0",
"next": "14.2.15",
"react": "*",
"react-aria-components": "1.5.0",
"react-aria-components": "1.4.1",
"react-dom": "*",
"react-i18next": "15.2.0",
"react-select": "5.9.0",
"react-i18next": "15.0.3",
"react-select": "5.8.1",
"react-intersection-observer": "9.13.1",
"styled-components": "6.1.13",
"y-protocols": "1.0.6",
"yjs": "*",
"zustand": "5.0.2"
"zustand": "5.0.0"
},
"devDependencies": {
"@svgr/webpack": "8.1.0",
"@tanstack/react-query-devtools": "5.62.7",
"@tanstack/react-query-devtools": "5.59.15",
"@testing-library/dom": "10.4.0",
"@testing-library/jest-dom": "6.6.3",
"@testing-library/react": "16.1.0",
"@testing-library/jest-dom": "6.6.2",
"@testing-library/react": "16.0.1",
"@testing-library/user-event": "14.5.2",
"@types/jest": "29.5.14",
"@types/lodash": "4.17.13",
"@types/jest": "29.5.13",
"@types/lodash": "4.17.12",
"@types/luxon": "3.4.2",
"@types/node": "*",
"@types/react": "18.3.12",
"@types/react": "18.3.11",
"@types/react-dom": "*",
"cross-env": "*",
"dotenv": "16.4.7",
"dotenv": "16.4.5",
"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.4.2",
"stylelint": "16.12.0",
"prettier": "3.3.3",
"stylelint": "16.10.0",
"stylelint-config-standard": "36.0.1",
"stylelint-prettier": "5.0.2",
"typescript": "*",
"webpack": "5.97.1",
"webpack": "5.95.0",
"workbox-webpack-plugin": "7.1.0"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

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

View File

@@ -1,6 +0,0 @@
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}/`;

View File

@@ -1,4 +1,5 @@
import { baseApiUrl } from './config';
import { baseApiUrl } from '@/core';
import { getCSRFToken } from './utils';
interface FetchAPIInit extends RequestInit {

View File

@@ -1,5 +1,4 @@
export * from './APIError';
export * from './config';
export * from './fetchApi';
export * from './helpers';
export * from './types';

View File

@@ -45,6 +45,7 @@ export type BoxType = ComponentPropsWithRef<typeof Box>;
export const Box = styled('div')<BoxProps>`
display: flex;
flex-direction: column;
${({ $align }) => $align && `align-items: ${$align};`}
${({ $background }) => $background && `background: ${$background};`}
${({ $color }) => $color && `color: ${$color};`}

View File

@@ -1,5 +1,4 @@
import { ComponentPropsWithRef, forwardRef } from 'react';
import { css } from 'styled-components';
import { Box, BoxType } from './Box';
@@ -27,7 +26,7 @@ const BoxButton = forwardRef<HTMLDivElement, BoxType>(
$background="none"
$margin="none"
$padding="none"
$css={css`
$css={`
cursor: pointer;
border: none;
outline: none;

View File

@@ -1,5 +1,4 @@
import { PropsWithChildren } from 'react';
import { css } from 'styled-components';
import { useCunninghamTheme } from '@/cunningham';
@@ -16,8 +15,8 @@ export const Card = ({
<Box
$background="white"
$radius="4px"
$css={css`
box-shadow: 2px 2px 5px ${colorsTokens()['greyscale-300']};
$css={`
border: 1px solid ${colorsTokens()['card-border']};
${$css}
`}

View File

@@ -1,9 +1,4 @@
import React, {
PropsWithChildren,
ReactNode,
useEffect,
useState,
} from 'react';
import { PropsWithChildren, ReactNode, useEffect, useState } from 'react';
import { Button, DialogTrigger, Popover } from 'react-aria-components';
import styled from 'styled-components';
@@ -29,7 +24,7 @@ const StyledButton = styled(Button)`
text-wrap: nowrap;
`;
interface DropButtonProps {
export interface DropButtonProps {
button: ReactNode;
isOpen?: boolean;
onOpenChange?: (isOpen: boolean) => void;

View File

@@ -1,6 +1,14 @@
import { Text, TextType } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
type Props = {
iconName: string;
className?: string;
};
export const Icon = ({ iconName, className }: Props) => {
return <span className={`material-icons ${className}`}>{iconName}</span>;
};
interface IconBGProps extends TextType {
iconName: string;
}

View File

@@ -23,6 +23,7 @@ export interface TextProps extends BoxProps {
$weight?: CSSProperties['fontWeight'];
$textAlign?: CSSProperties['textAlign'];
$size?: TextSizes | (string & {});
$theme?:
| 'primary'
| 'secondary'
@@ -31,6 +32,7 @@ export interface TextProps extends BoxProps {
| 'warning'
| 'danger'
| 'greyscale';
$variation?:
| 'text'
| '100'
@@ -41,7 +43,8 @@ export interface TextProps extends BoxProps {
| '600'
| '700'
| '800'
| '900';
| '900'
| '1000';
}
export type TextType = ComponentPropsWithRef<typeof Text>;
@@ -63,14 +66,16 @@ export const TextStyled = styled(Box)<TextProps>`
const Text = forwardRef<HTMLElement, ComponentPropsWithRef<typeof TextStyled>>(
({ className, $isMaterialIcon, ...props }, ref) => {
return (
<TextStyled
ref={ref}
as="span"
$theme="greyscale"
$variation="text"
className={`${className || ''}${$isMaterialIcon ? ' material-icons' : ''}`}
{...props}
/>
<>
<TextStyled
ref={ref}
as="span"
$theme={props.$theme ?? 'greyscale'}
$variation={props.$variation ?? 'text'}
className={`${className || ''}${$isMaterialIcon ? ' material-icons' : ''}`}
{...props}
/>
</>
);
},
);

View File

@@ -0,0 +1,87 @@
import { PropsWithChildren, useMemo } from 'react';
import { DropButton, DropButtonProps } from '@/components';
import { Icon } from '@/components/Icon';
import styles from './dropdown-menu.module.scss';
export type DropdownMenuOption = {
icon?: string;
label: string;
callback?: () => void | Promise<unknown>;
danger?: boolean;
show?: boolean;
};
export type DropdownMenuProps = Omit<DropButtonProps, 'button'> & {
options: DropdownMenuOption[];
showArrow?: boolean;
arrowClassname?: string;
};
export const DropdownMenu = ({
options,
children,
showArrow = false,
arrowClassname,
...dropButtonProps
}: PropsWithChildren<DropdownMenuProps>) => {
const showDropdown = useMemo(() => {
let show = false;
options.forEach((option) => {
show = show || (option.show !== undefined ? option.show : true);
});
return show;
}, [options]);
const getButton = () => {
if (!showArrow) {
return children;
}
return (
<div className={styles.withArrowContainer}>
<div>{children}</div>
<Icon
className={arrowClassname ?? 'clr-primary-600'}
iconName={
dropButtonProps.isOpen ? 'arrow_drop_up' : 'arrow_drop_down'
}
/>
</div>
);
};
if (!showDropdown) {
return;
}
return (
<DropButton {...dropButtonProps} button={getButton()}>
<div className={styles.listOption}>
{options.map((option) => {
if (option.show !== undefined && !option.show) {
return;
}
return (
<button
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
dropButtonProps.onOpenChange?.(false);
void option.callback?.();
}}
key={option.label}
className={styles.item}
>
{option.icon && (
<Icon className={styles.itemIcon} iconName={option.icon} />
)}
{option.label}
</button>
);
})}
</div>
</DropButton>
);
};

View File

@@ -0,0 +1,42 @@
.simpleContent {
display: flex;
align-items: center;
gap: var(--c--theme--spacings--st);
}
.listOption {
display: flex;
flex-direction: column;
.item:not(:last-child) {
border-bottom: 1px solid var(--c--theme--colors--greyscale-200);
}
}
.item {
display: flex;
align-items: center;
gap: var(--c--theme--spacings--200W);
border: none;
background-color: white;
font-size: var(--c--theme--font--sizes--sm);
color: var(--c--theme--colors--primary-600);
font-weight: 500;
padding: var(--c--theme--spacings--100W) var(--c--theme--spacings--200W);
width: 100%;
cursor: pointer;
user-select: none;
&:hover {
background-color: var(--c--theme--colors--greyscale-050);
}
.itemIcon {
font-size: 24px;
}
}
.withArrowContainer {
display: flex;
align-items: center;
gap: 5px;
}

View File

@@ -0,0 +1,14 @@
import { useState } from 'react';
export const useDropdownMenu = () => {
const [isOpen, setIsOpen] = useState(false);
const onOpenChange = (isOpen: boolean) => {
setIsOpen(isOpen);
};
return {
isOpen,
onOpenChange,
};
};

View File

@@ -0,0 +1,34 @@
import { useCunninghamTheme } from '@/cunningham';
import { Box } from '../Box';
export enum SeparatorVariant {
LIGHT = 'light',
DARK = 'dark',
}
type Props = {
variant?: SeparatorVariant;
};
export const HorizontalSeparator = ({
variant = SeparatorVariant.LIGHT,
}: Props) => {
const { colorsTokens, themeTokens } = useCunninghamTheme();
const colors = colorsTokens();
const spacings = themeTokens().spacings;
return (
<Box
$css={`
width: 100%;
height: 1px;
margin: ${spacings?.['300W'] ?? '0.5rem'} 0 ;
background-color: ${
variant === SeparatorVariant.LIGHT
? colors['greyscale-200']
: colors['greyscale-800']
};
`}
/>
);
};

View File

@@ -0,0 +1,29 @@
import { PropsWithChildren } from 'react';
import { useCunninghamTheme } from '@/cunningham';
import { Box } from '../Box';
type Props = {
showSeparator?: boolean;
};
export const SeparatedSection = ({
showSeparator = true,
children,
}: PropsWithChildren<Props>) => {
const theme = useCunninghamTheme();
const colors = theme.colorsTokens();
return (
<Box
className="toto"
$padding={{ vertical: '100V' }}
$css={`
padding: 12px 0;
${showSeparator ? `border-bottom: 1px solid ${(colors?.['greyscale-200'] as string) ?? '#E5E5E5'};` : ''}
`}
>
{children}
</Box>
);
};

View File

@@ -7,7 +7,6 @@ import '@/i18n/initI18n';
import { useResponsiveStore } from '@/stores/';
import { Auth } from './auth/';
import { ConfigProvider } from './config/';
/**
* QueryClient:
@@ -40,9 +39,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<CunninghamProvider theme={theme}>
<ConfigProvider>
<Auth>{children}</Auth>
</ConfigProvider>
<Auth>{children}</Auth>
</CunninghamProvider>
</QueryClientProvider>
);

View File

@@ -1,40 +0,0 @@
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();
});
});

View File

@@ -1,7 +1,6 @@
import { create } from 'zustand';
import { baseApiUrl } from '@/api';
import { terminateCrispSession } from '@/services';
import { baseApiUrl } from '@/core/conf';
import { User, getMe } from './api';
import { PATH_AUTH_LOCAL_STORAGE } from './conf';
@@ -43,7 +42,6 @@ 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

View File

@@ -0,0 +1,18 @@
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}`;
};

View File

@@ -1,49 +0,0 @@
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;
};

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