Compare commits

..

33 Commits

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

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

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

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

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

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

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

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

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

Will be used in the upcoming commits.
2024-12-12 14:37:30 +01:00
Samuel Paccoud - DINUM
5cdd06d432 (backend) add server-to-server API endpoint to create documents
We want trusted external applications to be able to create documents
via the API on behalf of any user. The user may or may not pre-exist
in our database and should be notified of the document creation by
email.
2024-12-12 14:01:46 +01:00
Samuel Paccoud - DINUM
47e23bff90 🔥(emails) remove dead template code
The email footer file is not being used and should be deleted.
2024-12-12 14:01:46 +01:00
78 changed files with 5675 additions and 1852 deletions

View File

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

View File

@@ -95,12 +95,12 @@ jobs:
- name: Set e2e env variables
run: cat env.d/development/common.e2e.dist >> env.d/development/common.dist
- name: Install Playwright Browsers
run: cd src/frontend/apps/e2e && yarn install --frozen-lockfile && yarn install-playwright chromium
- name: Start Docker services
run: make bootstrap FLUSH_ARGS='--no-input' cache=
- name: Install Playwright Browsers
run: cd src/frontend/apps/e2e && yarn install-playwright chromium
# Tool to wait for a service to be ready
- name: Install Dockerize
run: |
@@ -151,12 +151,12 @@ jobs:
- name: Set e2e env variables
run: cat env.d/development/common.e2e.dist >> env.d/development/common.dist
- name: Install Playwright Browsers
run: cd src/frontend/apps/e2e && yarn install --frozen-lockfile && yarn install-playwright firefox webkit chromium
- name: Start Docker services
run: make bootstrap FLUSH_ARGS='--no-input' cache=
- name: Install Playwright Browsers
run: cd src/frontend/apps/e2e && yarn install-playwright firefox webkit chromium
- name: Run e2e tests
run: cd src/frontend/ && yarn e2e:test --project=firefox --project=webkit

View File

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

View File

@@ -9,6 +9,28 @@ and this project adheres to
## [Unreleased]
## Added
🔧(helm) add option to disable default tls setting by @dominikkaminski #519
## [1.10.0] - 2024-12-17
## Added
- ✨(backend) add server-to-server API endpoint to create documents #467
- ✨(email) white brand email #412
- ✨(y-provider) create a markdown converter endpoint #488
## Changed
- ⚡️(docker) improve y-provider image #422
## Fixed
- ⚡️(e2e) reduce flakiness on e2e tests #511
## [1.9.0] - 2024-12-11
## Added
@@ -303,7 +325,8 @@ and this project adheres to
- 🚀 Impress, project to manage your documents easily and collaboratively.
[unreleased]: https://github.com/numerique-gouv/impress/compare/v1.9.0...main
[unreleased]: https://github.com/numerique-gouv/impress/compare/v1.10.0...main
[v1.10.0]: https://github.com/numerique-gouv/impress/releases/v1.10.0
[v1.9.0]: https://github.com/numerique-gouv/impress/releases/v1.9.0
[v1.8.2]: https://github.com/numerique-gouv/impress/releases/v1.8.2
[v1.8.1]: https://github.com/numerique-gouv/impress/releases/v1.8.1

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

231
docs/installation.md Normal file
View File

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

View File

@@ -16,7 +16,9 @@ PYTHONPATH=/app
# impress settings
# Mail
DJANGO_EMAIL_BRAND_NAME="La Suite Numérique"
DJANGO_EMAIL_HOST="mailcatcher"
DJANGO_EMAIL_LOGO_IMG="http://localhost:3000/assets/logo-suite-numerique.png"
DJANGO_EMAIL_PORT=1025
# Backend url

View File

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

View File

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

View File

@@ -25,10 +25,11 @@ 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
from rest_framework import filters, status
from rest_framework import response as drf_response
from rest_framework.permissions import AllowAny
from core import enums, models
from core import authentication, enums, models
from core.services.ai_services import AIService
from core.services.collaboration_services import CollaborationService
@@ -430,6 +431,30 @@ class DocumentViewSet(
role=models.RoleChoices.OWNER,
)
@drf.decorators.action(
authentication_classes=[authentication.ServerToServerAuthentication],
detail=False,
methods=["post"],
permission_classes=[],
url_path="create-for-owner",
)
def create_for_owner(self, request):
"""
Create a document on behalf of a specified owner (pre-existing user or invited).
"""
# Deserialize and validate the data
serializer = serializers.ServerCreateDocumentSerializer(data=request.data)
if not serializer.is_valid():
return drf_response.Response(
serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
document = serializer.save()
return drf_response.Response(
{"id": str(document.id)}, status=status.HTTP_201_CREATED
)
@drf.decorators.action(detail=True, methods=["get"], url_path="versions")
def versions_list(self, request, *args, **kwargs):
"""
@@ -813,11 +838,11 @@ class DocumentAccessViewSet(
access = serializer.save()
language = self.request.headers.get("Content-Language", "en-us")
access.document.email_invitation(
language,
access.document.send_invitation_email(
access.user.email,
access.role,
self.request.user,
language,
)
def perform_update(self, serializer):
@@ -1078,8 +1103,8 @@ class InvitationViewset(
language = self.request.headers.get("Content-Language", "en-us")
invitation.document.email_invitation(
language, invitation.email, invitation.role, self.request.user
invitation.document.send_invitation_email(
invitation.email, invitation.role, self.request.user, language
)

View File

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

View File

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

View File

@@ -26,8 +26,8 @@ from django.template.context import Context
from django.template.loader import render_to_string
from django.utils import html, timezone
from django.utils.functional import cached_property, lazy
from django.utils.translation import get_language, override
from django.utils.translation import gettext_lazy as _
from django.utils.translation import override
import frontmatter
import markdown
@@ -239,6 +239,13 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
for invitation in valid_invitations
]
)
# Set creator of documents if not yet set (e.g. documents created via server-to-server API)
document_ids = [invitation.document_id for invitation in valid_invitations]
Document.objects.filter(id__in=document_ids, creator__isnull=True).update(
creator=self
)
valid_invitations.delete()
def email_user(self, subject, message, from_email=None, **kwargs):
@@ -342,7 +349,11 @@ class Document(BaseModel):
max_length=20, choices=LinkRoleChoices.choices, default=LinkRoleChoices.READER
)
creator = models.ForeignKey(
User, on_delete=models.RESTRICT, related_name="documents_created"
User,
on_delete=models.RESTRICT,
related_name="documents_created",
blank=True,
null=True,
)
_content = None
@@ -534,44 +545,63 @@ class Document(BaseModel):
"versions_retrieve": has_role,
}
def email_invitation(self, language, email, role, sender):
"""Send email invitation."""
sender_name = sender.full_name or sender.email
def send_email(self, subject, emails, context=None, language=None):
"""Generate and send email from a template."""
context = context or {}
domain = Site.objects.get_current().domain
language = language or get_language()
context.update(
{
"brandname": settings.EMAIL_BRAND_NAME,
"document": self,
"domain": domain,
"link": f"{domain}/docs/{self.id}/",
"logo_img": settings.EMAIL_LOGO_IMG,
}
)
try:
with override(language):
title = _(
"%(sender_name)s shared a document with you: %(document)s"
) % {
"sender_name": sender_name,
"document": self.title,
}
template_vars = {
"title": title,
"domain": domain,
"document": self,
"link": f"{domain}/docs/{self.id}/",
"sender_name": sender_name,
"sender_name_email": f"{sender.full_name} ({sender.email})"
if sender.full_name
else sender.email,
"role": RoleChoices(role).label.lower(),
}
msg_html = render_to_string("mail/html/invitation.html", template_vars)
msg_plain = render_to_string("mail/text/invitation.txt", template_vars)
with override(language):
msg_html = render_to_string("mail/html/invitation.html", context)
msg_plain = render_to_string("mail/text/invitation.txt", context)
subject = str(subject) # Force translation
try:
send_mail(
title,
subject.capitalize(),
msg_plain,
settings.EMAIL_FROM,
[email],
emails,
html_message=msg_html,
fail_silently=False,
)
except smtplib.SMTPException as exception:
logger.error("invitation to %s was not sent: %s", emails, exception)
except smtplib.SMTPException as exception:
logger.error("invitation to %s was not sent: %s", email, exception)
def send_invitation_email(self, email, role, sender, language=None):
"""Method allowing a user to send an email invitation to another user for a document."""
language = language or get_language()
role = RoleChoices(role).label
sender_name = sender.full_name or sender.email
sender_name_email = (
f"{sender.full_name:s} ({sender.email})"
if sender.full_name
else sender.email
)
with override(language):
context = {
"title": _("{name} shared a document with you!").format(
name=sender_name
),
"message": _(
'{name} invited you with the role "{role}" on the following document:'
).format(name=sender_name_email, role=role.lower()),
}
subject = _("{name} shared a document with you: {title}").format(
name=sender_name, title=self.title
)
self.send_email(subject, [email], context, language)
class LinkTrace(BaseModel):
@@ -887,6 +917,8 @@ class Invitation(BaseModel):
User,
on_delete=models.CASCADE,
related_name="invitations",
blank=True,
null=True,
)
class Meta:

View File

@@ -26,6 +26,7 @@ class CollaborationService:
# 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

View File

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

View File

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

View File

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

View File

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

View File

@@ -33,11 +33,8 @@ def test_models_documents_id_unique():
def test_models_documents_creator_required():
"""The "creator" field should be required."""
with pytest.raises(ValidationError) as excinfo:
models.Document.objects.create()
assert excinfo.value.message_dict["creator"] == ["This field cannot be null."]
"""No field should be required on the Document model."""
models.Document.objects.create()
def test_models_documents_title_null():
@@ -430,8 +427,8 @@ def test_models_documents__email_invitation__success():
assert len(mail.outbox) == 0
sender = factories.UserFactory(full_name="Test Sender", email="sender@example.com")
document.email_invitation(
"en", "guest@example.com", models.RoleChoices.EDITOR, sender
document.send_invitation_email(
"guest@example.com", models.RoleChoices.EDITOR, sender, "en"
)
# pylint: disable-next=no-member
@@ -444,8 +441,8 @@ def test_models_documents__email_invitation__success():
email_content = " ".join(email.body.split())
assert (
f'Test Sender (sender@example.com) invited you with the role "editor" '
f"on the following document : {document.title}" in email_content
f"Test Sender (sender@example.com) invited you with the role &quot;editor&quot; "
f"on the following document: {document.title}" in email_content
)
assert f"docs/{document.id}/" in email_content
@@ -462,11 +459,11 @@ def test_models_documents__email_invitation__success_fr():
sender = factories.UserFactory(
full_name="Test Sender2", email="sender2@example.com"
)
document.email_invitation(
"fr-fr",
document.send_invitation_email(
"guest2@example.com",
models.RoleChoices.OWNER,
sender,
"fr-fr",
)
# pylint: disable-next=no-member
@@ -479,8 +476,8 @@ def test_models_documents__email_invitation__success_fr():
email_content = " ".join(email.body.split())
assert (
f'Test Sender2 (sender2@example.com) vous a invité avec le rôle "propriétaire" '
f"sur le document suivant : {document.title}" in email_content
f"Test Sender2 (sender2@example.com) vous a invité avec le rôle &quot;propriétaire&quot; "
f"sur le document suivant: {document.title}" in email_content
)
assert f"docs/{document.id}/" in email_content
@@ -498,11 +495,11 @@ def test_models_documents__email_invitation__failed(mock_logger, _mock_send_mail
assert len(mail.outbox) == 0
sender = factories.UserFactory()
document.email_invitation(
"en",
document.send_invitation_email(
"guest3@example.com",
models.RoleChoices.ADMIN,
sender,
"en",
)
# No email has been sent
@@ -514,9 +511,9 @@ def test_models_documents__email_invitation__failed(mock_logger, _mock_send_mail
(
_,
email,
emails,
exception,
) = mock_logger.call_args.args
assert email == "guest3@example.com"
assert emails == ["guest3@example.com"]
assert isinstance(exception, smtplib.SMTPException)

View File

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

View File

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

View File

@@ -65,6 +65,7 @@ class Base(Configuration):
# Security
ALLOWED_HOSTS = values.ListValue([])
SECRET_KEY = values.Value(None)
SERVER_TO_SERVER_API_TOKENS = values.ListValue([])
# Application definition
ROOT_URLCONF = "impress.urls"
@@ -351,9 +352,11 @@ class Base(Configuration):
# Mail
EMAIL_BACKEND = values.Value("django.core.mail.backends.smtp.EmailBackend")
EMAIL_BRAND_NAME = values.Value(None)
EMAIL_HOST = values.Value(None)
EMAIL_HOST_USER = values.Value(None)
EMAIL_HOST_PASSWORD = values.Value(None)
EMAIL_LOGO_IMG = values.Value(None)
EMAIL_PORT = values.PositiveIntegerValue(None)
EMAIL_USE_TLS = values.BooleanValue(False)
EMAIL_USE_SSL = values.BooleanValue(False)
@@ -502,6 +505,38 @@ class Base(Configuration):
"day": 200,
}
# Y provider microservice
Y_PROVIDER_API_KEY = values.Value(
environ_name="Y_PROVIDER_API_KEY",
environ_prefix=None,
)
Y_PROVIDER_API_BASE_URL = values.Value(
environ_name="Y_PROVIDER_API_BASE_URL",
environ_prefix=None,
)
# Conversion endpoint
CONVERSION_API_ENDPOINT = values.Value(
default="convert-markdown",
environ_name="CONVERSION_API_ENDPOINT",
environ_prefix=None,
)
CONVERSION_API_CONTENT_FIELD = values.Value(
default="content",
environ_name="CONVERSION_API_CONTENT_FIELD",
environ_prefix=None,
)
CONVERSION_API_TIMEOUT = values.Value(
default=30,
environ_name="CONVERSION_API_TIMEOUT",
environ_prefix=None,
)
CONVERSION_API_SECURE = values.Value(
default=False,
environ_name="CONVERSION_API_SECURE",
environ_prefix=None,
)
# Logging
# We want to make it easy to log to console but by default we log production
# to Sentry and don't want to log to console.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
import { createDoc } from './common';
import { createDoc, goToGridDoc, keyCloakSignIn, randomName } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -29,3 +29,47 @@ test.describe('Doc Create', () => {
});
});
});
test.describe('Doc Create: Not loggued', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('it creates a doc server way', async ({
page,
browserName,
request,
}) => {
const markdown = `This is a normal text\n\n# And this is a large heading`;
const [title] = randomName('My server way doc create', browserName, 1);
const data = {
title,
content: markdown,
sub: `user@${browserName}.e2e`,
email: `user@${browserName}.e2e`,
};
const newDoc = await request.post(
`http://localhost:8071/api/v1.0/documents/create-for-owner/`,
{
data,
headers: {
Authorization: 'Bearer test-e2e',
format: 'json',
},
},
);
expect(newDoc.ok()).toBeTruthy();
await keyCloakSignIn(page, browserName);
await goToGridDoc(page, { title });
await expect(page.getByRole('heading', { name: title })).toBeVisible();
const editor = page.locator('.ProseMirror');
await expect(editor.getByText('This is a normal text')).toBeVisible();
await expect(
editor.locator('h1').getByText('And this is a large heading'),
).toBeVisible();
});
});

View File

@@ -233,7 +233,7 @@ test.describe('Doc Editor', () => {
test.skip(browserName === 'webkit', 'This test is very flaky with webkit');
// Check the first doc
const doc = await goToGridDoc(page);
const [doc] = await createDoc(page, 'doc-quit-1', browserName, 1);
await expect(page.locator('h2').getByText(doc)).toBeVisible();
const editor = page.locator('.ProseMirror');
@@ -272,8 +272,8 @@ test.describe('Doc Editor', () => {
).toBeVisible();
});
test('it adds an image to the doc editor', async ({ page }) => {
await goToGridDoc(page);
test('it adds an image to the doc editor', async ({ page, browserName }) => {
await createDoc(page, 'doc-image', browserName, 1);
const fileChooserPromise = page.waitForEvent('filechooser');

View File

@@ -120,13 +120,14 @@ 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(' ');
await page.getByText('Created at').click({
delay: 200,
});
await page.getByText('Created at').click();
await expect(
docHeader.getByRole('heading', { name: 'Untitled document', level: 2 }),

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "app-impress",
"version": "1.9.0",
"version": "1.10.0",
"private": true,
"scripts": {
"dev": "next dev",
@@ -19,33 +19,33 @@
"@blocknote/mantine": "*",
"@blocknote/react": "*",
"@gouvfr-lasuite/integration": "1.0.2",
"@hocuspocus/provider": "2.14.0",
"@hocuspocus/provider": "2.15.0",
"@openfun/cunningham-react": "2.9.4",
"@sentry/nextjs": "8.42.0",
"@tanstack/react-query": "5.62.2",
"@sentry/nextjs": "8.45.1",
"@tanstack/react-query": "5.62.7",
"crisp-sdk-web": "1.0.25",
"i18next": "24.0.5",
"i18next-browser-languagedetector": "8.0.0",
"idb": "8.0.0",
"i18next": "24.1.0",
"i18next-browser-languagedetector": "8.0.2",
"idb": "8.0.1",
"lodash": "4.17.21",
"luxon": "3.5.0",
"next": "15.0.3",
"next": "15.1.0",
"react": "*",
"react-aria-components": "1.5.0",
"react-dom": "*",
"react-i18next": "15.1.3",
"react-select": "5.8.3",
"react-i18next": "15.2.0",
"react-select": "5.9.0",
"styled-components": "6.1.13",
"y-protocols": "1.0.6",
"yjs": "*",
"zustand": "5.0.1"
"zustand": "5.0.2"
},
"devDependencies": {
"@svgr/webpack": "8.1.0",
"@tanstack/react-query-devtools": "5.62.2",
"@tanstack/react-query-devtools": "5.62.7",
"@testing-library/dom": "10.4.0",
"@testing-library/jest-dom": "6.6.3",
"@testing-library/react": "16.0.1",
"@testing-library/react": "16.1.0",
"@testing-library/user-event": "14.5.2",
"@types/jest": "29.5.14",
"@types/lodash": "4.17.13",
@@ -60,12 +60,12 @@
"jest": "29.7.0",
"jest-environment-jsdom": "29.7.0",
"node-fetch": "2.7.0",
"prettier": "3.4.1",
"stylelint": "16.11.0",
"prettier": "3.4.2",
"stylelint": "16.12.0",
"stylelint-config-standard": "36.0.1",
"stylelint-prettier": "5.0.2",
"typescript": "*",
"webpack": "5.97.0",
"webpack": "5.97.1",
"workbox-webpack-plugin": "7.1.0"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

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

View File

@@ -65,10 +65,6 @@ const DocPage = ({ id }: DocProps) => {
setDoc(docQuery);
setCurrentDoc(docQuery);
return () => {
setCurrentDoc(undefined);
};
}, [docQuery, setCurrentDoc]);
useEffect(() => {

View File

@@ -25,7 +25,7 @@ export const useSentryStore = create<SentryState>((set, get) => ({
release: packageJson.version,
replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1.0,
tracesSampleRate: 1.0,
tracesSampleRate: 0.1,
}),
});
},

View File

@@ -1,6 +1,6 @@
{
"name": "impress",
"version": "1.9.0",
"version": "1.10.0",
"private": true,
"workspaces": {
"packages": [
@@ -28,13 +28,13 @@
"server:test": "yarn COLLABORATION_SERVER run test"
},
"resolutions": {
"@blocknote/core": "0.20.0",
"@blocknote/mantine": "0.20.0",
"@blocknote/react": "0.20.0",
"@types/node": "22.10.1",
"@blocknote/core": "0.21.0",
"@blocknote/mantine": "0.21.0",
"@blocknote/react": "0.21.0",
"@types/node": "22.10.2",
"@types/react-dom": "18.3.1",
"@typescript-eslint/eslint-plugin": "8.17.0",
"@typescript-eslint/parser": "8.17.0",
"@typescript-eslint/eslint-plugin": "8.18.0",
"@typescript-eslint/parser": "8.18.0",
"cross-env": "7.0.3",
"eslint": "8.57.0",
"react": "18.3.1",

View File

@@ -1,17 +1,17 @@
{
"name": "eslint-config-impress",
"version": "1.9.0",
"version": "1.10.0",
"license": "MIT",
"scripts": {
"lint": "eslint --ext .js ."
},
"dependencies": {
"@next/eslint-plugin-next": "15.0.3",
"@next/eslint-plugin-next": "15.1.0",
"@tanstack/eslint-plugin-query": "5.62.1",
"@typescript-eslint/eslint-plugin": "*",
"@typescript-eslint/parser": "*",
"eslint": "*",
"eslint-config-next": "15.0.3",
"eslint-config-next": "15.1.0",
"eslint-config-prettier": "9.1.0",
"eslint-plugin-import": "2.31.0",
"eslint-plugin-jest": "28.9.0",
@@ -19,7 +19,7 @@
"eslint-plugin-playwright": "2.1.0",
"eslint-plugin-prettier": "5.2.1",
"eslint-plugin-react": "7.37.2",
"eslint-plugin-testing-library": "7.0.0",
"prettier": "3.4.1"
"eslint-plugin-testing-library": "7.1.1",
"prettier": "3.4.2"
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "packages-i18n",
"version": "1.9.0",
"version": "1.10.0",
"private": true,
"scripts": {
"extract-translation": "yarn extract-translation:impress",

View File

@@ -0,0 +1,42 @@
FROM node:20-alpine AS y-provider-builder
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/packages/eslint-config-impress ./packages/eslint-config-impress
COPY ./src/frontend/servers/y-provider ./servers/y-provider
WORKDIR /home/frontend/servers/y-provider
RUN yarn build
FROM node:20-alpine AS 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
WORKDIR /home/frontend/servers/y-provider
COPY --from=y-provider-builder \
/home/frontend/servers/y-provider/dist \
./dist
RUN NODE_ENV=production yarn install --frozen-lockfile
# Un-privileged user running the application
ARG DOCKER_USER
USER ${DOCKER_USER}
# Copy entrypoint
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
CMD ["yarn", "start"]

View File

@@ -0,0 +1 @@
module.exports = {};

View File

@@ -14,6 +14,7 @@ jest.mock('../src/env', () => {
PORT: port,
COLLABORATION_SERVER_ORIGIN: origin,
COLLABORATION_SERVER_SECRET: 'test-secret-api-key',
Y_PROVIDER_API_KEY: 'yprovider-api-key',
};
});
@@ -91,6 +92,49 @@ describe('Server Tests', () => {
hocuspocusServer.closeConnections = closeConnections;
});
test('POST /api/convert-markdown with incorrect API key should return 403', async () => {
const response = await request(app as any)
.post('/api/convert-markdown')
.set('Origin', origin)
.set('Authorization', 'wrong-api-key');
expect(response.status).toBe(403);
expect(response.body.error).toBe('Forbidden: Invalid API Key');
});
test('POST /api/convert-markdown with a Bearer token', async () => {
const response = await request(app as any)
.post('/api/convert-markdown')
.set('Origin', origin)
.set('Authorization', 'Bearer test-secret-api-key');
// Warning: Changing the authorization header to Bearer token format will break backend compatibility with this microservice.
expect(response.status).toBe(403);
});
test('POST /api/convert-markdown with missing body param content', async () => {
const response = await request(app as any)
.post('/api/convert-markdown')
.set('Origin', origin)
.set('Authorization', 'yprovider-api-key');
expect(response.status).toBe(400);
expect(response.body.error).toBe('Invalid request: missing content');
});
test('POST /api/convert-markdown with body param content being an empty string', async () => {
const response = await request(app as any)
.post('/api/convert-markdown')
.set('Origin', origin)
.set('Authorization', 'yprovider-api-key')
.send({
content: '',
});
expect(response.status).toBe(400);
expect(response.body.error).toBe('Invalid request: missing content');
});
['/collaboration/api/anything/', '/', '/anything'].forEach((path) => {
test(`"${path}" endpoint should be forbidden`, async () => {
const response = await request(app as any).post(path);

View File

@@ -6,6 +6,7 @@ var config = {
},
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/../src/$1',
'^@blocknote/server-util$': '<rootDir>/../__mocks__/mock.js',
},
};
export default config;

View File

@@ -1,6 +1,6 @@
{
"name": "server-y-provider",
"version": "1.9.0",
"version": "1.10.0",
"description": "Y.js provider for docs",
"repository": "https://github.com/numerique-gouv/impress",
"license": "MIT",
@@ -16,15 +16,17 @@
"node": ">=18"
},
"dependencies": {
"@hocuspocus/server": "2.14.0",
"@sentry/node": "8.41.0",
"@sentry/profiling-node": "8.41.0",
"express": "4.21.1",
"@blocknote/server-util": "0.21.0",
"@hocuspocus/server": "2.15.0",
"@sentry/node": "8.45.1",
"@sentry/profiling-node": "8.45.1",
"express": "4.21.2",
"express-ws": "5.0.2",
"y-protocols": "1.0.6"
"y-protocols": "1.0.6",
"yjs": "13.6.20"
},
"devDependencies": {
"@hocuspocus/provider": "2.14.0",
"@hocuspocus/provider": "2.15.0",
"@types/express": "5.0.0",
"@types/express-ws": "3.0.5",
"@types/jest": "29.5.14",
@@ -33,7 +35,7 @@
"@types/ws": "8.5.13",
"eslint-config-impress": "*",
"jest": "29.7.0",
"nodemon": "3.1.7",
"nodemon": "3.1.9",
"supertest": "7.0.0",
"ts-jest": "29.2.5",
"ts-node": "10.9.2",

View File

@@ -4,5 +4,7 @@ export const COLLABORATION_SERVER_ORIGIN =
process.env.COLLABORATION_SERVER_ORIGIN || 'http://localhost:3000';
export const COLLABORATION_SERVER_SECRET =
process.env.COLLABORATION_SERVER_SECRET || 'secret-api-key';
export const Y_PROVIDER_API_KEY =
process.env.Y_PROVIDER_API_KEY || 'yprovider-api-key';
export const PORT = Number(process.env.PORT || 4444);
export const SENTRY_DSN = process.env.SENTRY_DSN || '';

View File

@@ -4,10 +4,13 @@ import * as ws from 'ws';
import {
COLLABORATION_SERVER_ORIGIN,
COLLABORATION_SERVER_SECRET,
Y_PROVIDER_API_KEY,
} from '@/env';
import { logger } from './utils';
const VALID_API_KEYS = [COLLABORATION_SERVER_SECRET, Y_PROVIDER_API_KEY];
export const httpSecurity = (
req: Request,
res: Response,
@@ -25,8 +28,9 @@ export const httpSecurity = (
}
// Secret API Key check
// Note: Changing this header to Bearer token format will break backend compatibility with this microservice.
const apiKey = req.headers['authorization'];
if (apiKey !== COLLABORATION_SERVER_SECRET) {
if (!apiKey || !VALID_API_KEYS.includes(apiKey)) {
res.status(403).json({ error: 'Forbidden: Invalid API Key' });
return;
}

View File

@@ -1,4 +1,5 @@
export const routes = {
COLLABORATION_WS: '/collaboration/ws/',
COLLABORATION_RESET_CONNECTIONS: '/collaboration/api/reset-connections/',
CONVERT_MARKDOWN: '/api/convert-markdown/',
};

View File

@@ -1,14 +1,16 @@
// eslint-disable-next-line import/order
import './services/sentry';
import { ServerBlockNoteEditor } from '@blocknote/server-util';
import { Server } from '@hocuspocus/server';
import * as Sentry from '@sentry/node';
import express, { Request, Response } from 'express';
import expressWebsockets from 'express-ws';
import * as Y from 'yjs';
import { PORT } from './env';
import { httpSecurity, wsSecurity } from './middlewares';
import { routes } from './routes';
import { logger } from './utils';
import { logger, toBase64 } from './utils';
export const hocuspocusServer = Server.configure({
name: 'docs-y-server',
@@ -133,6 +135,63 @@ export const initServer = () => {
},
);
interface ConversionRequest {
content: string;
}
interface ConversionResponse {
content: string;
}
interface ErrorResponse {
error: string;
}
/**
* Route to convert markdown
*/
app.post(
routes.CONVERT_MARKDOWN,
httpSecurity,
async (
req: Request<
object,
ConversionResponse | ErrorResponse,
ConversionRequest,
object
>,
res: Response<ConversionResponse | ErrorResponse>,
) => {
const content = req.body?.content;
if (!content) {
res.status(400).json({ error: 'Invalid request: missing content' });
return;
}
try {
const editor = ServerBlockNoteEditor.create();
// Perform the conversion from markdown to Blocknote.js blocks
const blocks = await editor.tryParseMarkdownToBlocks(content);
if (!blocks || blocks.length === 0) {
res.status(500).json({ error: 'No valid blocks were generated' });
return;
}
// Create a Yjs Document from blocks, and encode it as a base64 string
const yDocument = editor.blocksToYDoc(blocks, 'document-store');
const documentContent = toBase64(Y.encodeStateAsUpdate(yDocument));
res.status(200).json({ content: documentContent });
} catch (e) {
logger('conversion failed:', e);
res.status(500).json({ error: 'An error occurred' });
}
},
);
Sentry.setupExpressErrorHandler(app);
app.get('/ping', (req, res) => {

View File

@@ -6,6 +6,6 @@ import { SENTRY_DSN } from '../env';
Sentry.init({
dsn: SENTRY_DSN,
integrations: [nodeProfilingIntegration()],
tracesSampleRate: 1.0,
tracesSampleRate: 0.1,
profilesSampleRate: 1.0,
});

View File

@@ -7,3 +7,7 @@ export function logger(...args: any[]) {
console.log(...args);
}
}
export const toBase64 = function (str: Uint8Array) {
return Buffer.from(str).toString('base64');
};

File diff suppressed because it is too large Load Diff

View File

@@ -11,10 +11,13 @@ backend:
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: {{ .Values.djangoSecretKey }}
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
@@ -46,10 +49,12 @@ backend:
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: impress
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:
@@ -102,6 +107,7 @@ yProvider:
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

View File

@@ -2,4 +2,4 @@ apiVersion: v2
name: extra
description: A Helm chart to add some manifests to impress
type: application
version: 1.9.0
version: 1.10.0

View File

@@ -62,6 +62,6 @@ releases:
environments:
dev:
values:
- version: 1.9.0
- version: 1.10.0
secrets:
- env.d/{{ .Environment.Name }}/secrets.enc.yaml

View File

@@ -1,4 +1,5 @@
apiVersion: v2
type: application
name: impress
version: 1.9.0
name: docs
version: 0.0.1-debug
appVersion: latest

View File

@@ -29,7 +29,7 @@ spec:
{{- if .Values.ingress.tls.enabled }}
tls:
{{- if .Values.ingress.host }}
- secretName: {{ $fullName }}-tls
- secretName: {{ .Values.ingress.tls.secretName | default (printf "%s-tls" $fullName) | quote }}
hosts:
- {{ .Values.ingress.host | quote }}
{{- end }}
@@ -115,4 +115,3 @@ spec:
{{- end }}
{{- end }}
{{- end }}

View File

@@ -29,7 +29,7 @@ spec:
{{- if .Values.ingressAdmin.tls.enabled }}
tls:
{{- if .Values.ingressAdmin.host }}
- secretName: {{ $fullName }}-tls
- secretName: {{ .Values.ingressAdmin.tls.secretName | default (printf "%s-tls" $fullName) | quote }}
hosts:
- {{ .Values.ingressAdmin.host | quote }}
{{- end }}
@@ -95,4 +95,3 @@ spec:
{{- end }}
{{- end }}
{{- end }}

View File

@@ -29,7 +29,7 @@ spec:
{{- if .Values.ingressCollaborationApi.tls.enabled }}
tls:
{{- if .Values.ingressCollaborationApi.host }}
- secretName: {{ $fullName }}-tls
- secretName: {{ .Values.ingressCollaborationApi.tls.secretName | default (printf "%s-tls" $fullName) | quote }}
hosts:
- {{ .Values.ingressCollaborationApi.host | quote }}
{{- end }}
@@ -69,4 +69,3 @@ spec:
{{- end }}
{{- end }}
{{- end }}

View File

@@ -29,7 +29,7 @@ spec:
{{- if .Values.ingressCollaborationWS.tls.enabled }}
tls:
{{- if .Values.ingressCollaborationWS.host }}
- secretName: {{ $fullName }}-tls
- secretName: {{ .Values.ingressCollaborationWS.tls.secretName | default (printf "%s-tls" $fullName) | quote }}
hosts:
- {{ .Values.ingressCollaborationWS.host | quote }}
{{- end }}
@@ -69,4 +69,3 @@ spec:
{{- end }}
{{- end }}
{{- end }}

View File

@@ -29,7 +29,7 @@ spec:
{{- if .Values.ingressMedia.tls.enabled }}
tls:
{{- if .Values.ingressMedia.host }}
- secretName: {{ $fullName }}-tls
- secretName: {{ .Values.ingressMedia.tls.secretName | default (printf "%s-tls" $fullName) | quote }}
hosts:
- {{ .Values.ingressMedia.host | quote }}
{{- end }}

View File

@@ -1,23 +0,0 @@
apiVersion: v1
kind: Secret
metadata:
name: backend
namespace: {{ .Release.Namespace | quote }}
annotations:
"helm.sh/hook": pre-install,pre-upgrade
"helm.sh/hook-weight": "-5"
"helm.sh/hook-delete-policy": before-hook-creation
stringData:
DJANGO_SUPERUSER_EMAIL: {{ .Values.djangoSuperUserEmail }}
DJANGO_SUPERUSER_PASSWORD: {{ .Values.djangoSuperUserPass }}
DJANGO_SECRET_KEY: {{ .Values.djangoSecretKey }}
{{- if .Values.djangoEmailHostUser }}
DJANGO_EMAIL_HOST_USER: {{ .Values.djangoEmailHostUser }}
{{- end }}
{{- if .Values.djangoEmailHostPassword }}
DJANGO_EMAIL_HOST_PASSWORD: {{ .Values.djangoEmailHostPassword }}
{{- end }}
OIDC_RP_CLIENT_ID: {{ .Values.oidc.clientId }}
OIDC_RP_CLIENT_SECRET: {{ .Values.oidc.clientSecret }}
AI_API_KEY: {{ .Values.aiApiKey }}
AI_BASE_URL: {{ .Values.aiBaseUrl }}

View File

@@ -37,12 +37,14 @@ ingress:
## @param ingress.hosts Additional host to configure for the Ingress
hosts: []
# - chart-example.local
## @param ingress.tls.enabled Wether to enable TLS for the Ingress
## @param ingress.tls.enabled Weather to enable TLS for the Ingress
## @param ingress.tls.secretName Secret name for TLS config
## @skip ingress.tls.additional
## @extra ingress.tls.additional[].secretName Secret name for additional TLS config
## @extra ingress.tls.additional[].hosts[] Hosts for additional TLS config
tls:
enabled: true
secretName: null
additional: []
## @param ingress.customBackends Add custom backends to ingress
@@ -60,21 +62,23 @@ ingressCollaborationWS:
## @param ingress.hosts Additional host to configure for the Ingress
hosts: []
# - chart-example.local
## @param ingressCollaborationWS.tls.enabled Wether to enable TLS for the Ingress
## @param ingressCollaborationWS.tls.enabled Weather to enable TLS for the Ingress
## @param ingressCollaborationWS.tls.secretName Secret name for TLS config
## @skip ingressCollaborationWS.tls.additional
## @extra ingressCollaborationWS.tls.additional[].secretName Secret name for additional TLS config
## @extra ingressCollaborationWS.tls.additional[].hosts[] Hosts for additional TLS config
tls:
enabled: true
secretName: null
additional: []
## @param ingressCollaborationWS.customBackends Add custom backends to ingress
customBackends: []
annotations:
annotations:
nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Can-Edit, X-User-Id"
nginx.ingress.kubernetes.io/auth-url: https://impress.example.com/api/v1.0/documents/collaboration-auth/
nginx.ingress.kubernetes.io/enable-websocket: "true"
nginx.ingress.kubernetes.io/enable-websocket: "true"
nginx.ingress.kubernetes.io/proxy-read-timeout: "86400"
nginx.ingress.kubernetes.io/proxy-send-timeout: "86400"
nginx.ingress.kubernetes.io/upstream-hash-by: $arg_room
@@ -91,20 +95,23 @@ ingressCollaborationApi:
## @param ingress.hosts Additional host to configure for the Ingress
hosts: []
# - chart-example.local
## @param ingressCollaborationApi.tls.enabled Wether to enable TLS for the Ingress
## @param ingressCollaborationApi.tls.enabled Weather to enable TLS for the Ingress
## @param ingressCollaborationApi.tls.secretName Secret name for TLS config
## @skip ingressCollaborationApi.tls.additional
## @extra ingressCollaborationApi.tls.additional[].secretName Secret name for additional TLS config
## @extra ingressCollaborationApi.tls.additional[].hosts[] Hosts for additional TLS config
tls:
enabled: true
secretName: null
additional: []
## @param ingressCollaborationApi.customBackends Add custom backends to ingress
customBackends: []
annotations:
annotations:
nginx.ingress.kubernetes.io/upstream-hash-by: $arg_room
## @param ingressAdmin.enabled whether to enable the Ingress or not
## @param ingressAdmin.className IngressClass to use for the Ingress
## @param ingressAdmin.host Host for the Ingress
@@ -117,12 +124,14 @@ ingressAdmin:
## @param ingressAdmin.hosts Additional host to configure for the Ingress
hosts: [ ]
# - chart-example.local
## @param ingressAdmin.tls.enabled Wether to enable TLS for the Ingress
## @param ingressAdmin.tls.enabled Weather to enable TLS for the Ingress
## @param ingressAdmin.tls.secretName Secret name for TLS config
## @skip ingressAdmin.tls.additional
## @extra ingressAdmin.tls.additional[].secretName Secret name for additional TLS config
## @extra ingressAdmin.tls.additional[].hosts[] Hosts for additional TLS config
tls:
enabled: true
secretName: null
additional: []
## @param ingressMedia.enabled whether to enable the Ingress or not
@@ -137,12 +146,14 @@ ingressMedia:
## @param ingressMedia.hosts Additional host to configure for the Ingress
hosts: [ ]
# - chart-example.local
## @param ingressMedia.tls.enabled Wether to enable TLS for the Ingress
## @param ingressMedia.tls.enabled Weather to enable TLS for the Ingress
## @param ingressMedia.tls.secretName Secret name for TLS config
## @skip ingressMedia.tls.additional
## @extra ingressMedia.tls.additional[].secretName Secret name for additional TLS config
## @extra ingressMedia.tls.additional[].hosts[] Hosts for additional TLS config
tls:
enabled: true
secretName: null
additional: []
annotations:
@@ -214,6 +225,16 @@ backend:
- "--no-input"
restartPolicy: Never
## @param backend.createsuperuser.command backend migrate command
## @param backend.createsuperuser.restartPolicy backend migrate job restart policy
createsuperuser:
command:
- "/bin/sh"
- "-c"
- |
python manage.py createsuperuser --email $DJANGO_SUPERUSER_EMAIL --password $DJANGO_SUPERUSER_PASSWORD
restartPolicy: Never
## @param backend.probes.liveness.path [nullable] Configure path for backend HTTP liveness probe
## @param backend.probes.liveness.targetPort [nullable] Configure port for backend HTTP liveness probe
## @param backend.probes.liveness.initialDelaySeconds [nullable] Configure initial delay for backend liveness probe
@@ -442,4 +463,4 @@ yProvider:
extraVolumeMounts: []
## @param yProvider.extraVolumes Additional volumes to mount on the yProvider.
extraVolumes: []
extraVolumes: []

View File

@@ -3,36 +3,25 @@
<mj-body mj-class="bg--blue-100">
<mj-wrapper css-class="wrapper" padding="0 25px 0px 25px">
<mj-section
background-url="{{domain}}/assets/mail-header-background.png"
background-size="cover"
background-repeat="no-repeat"
background-position="0 -30px"
>
<mj-section css-class="wrapper-logo">
<mj-column>
<mj-image
align="center"
src="{{domain}}/assets/logo-suite-numerique.png"
width="250px"
src="{{logo_img}}"
width="320px"
align="left"
alt="{%trans 'La Suite Numérique' %}"
alt="{%trans 'Logo email' %}"
/>
</mj-column>
</mj-section>
<mj-section mj-class="bg--white-100" padding="30px 20px 60px 20px">
<mj-column>
<mj-text align="center">
<h1>
{% blocktrans %}
{{sender_name}} shared a document with you !
{% endblocktrans %}
</h1>
<h1>{{title|capfirst}}</h1>
</mj-text>
<!-- Main Message -->
<mj-text>
{% blocktrans %}
{{sender_name_email}} invited you with the role "{{role}}" on the following document :
{% endblocktrans %}
{{message|capfirst}}
<a href="{{link}}">{{document.title}}</a>
</mj-text>
<mj-button
@@ -57,7 +46,11 @@
</mj-text>
<!-- Signature -->
<mj-text>
<p>{% trans "Brought to you by La Suite Numérique" %}</p>
<p>
{% blocktrans %}
Brought to you by {{brandname}}
{% endblocktrans %}
</p>
</mj-text>
</mj-column>
</mj-section>

View File

@@ -1,9 +0,0 @@
<mj-section padding="0">
<mj-column>
<mj-text mj-class="text--small" align="center" padding="20px 20px">
{% blocktranslate with href=site.url name=site.name trimmed %}
This mail has been sent to {{email}} by <a href="{{href}}">{{name}}</a>
{% endblocktranslate %}
</mj-text>
</mj-column>
</mj-section>

View File

@@ -11,10 +11,10 @@
<mj-attributes>
<mj-font name="Roboto" href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;700;900&display=swap" />
<mj-all
font-family="Roboto, -apple-system, BlinkMacSystemFont, 'Segoe UI', Oxygen, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif"
font-size="16px"
line-height="1.5em"
color="#3A3A3A"
font-family="Roboto, -apple-system, BlinkMacSystemFont, 'Segoe UI', Oxygen, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif"
font-size="16px"
line-height="1.5em"
color="#3A3A3A"
/>
</mj-attributes>
<mj-style>
@@ -41,5 +41,9 @@
border-radius: 0 0 6px 6px;
box-shadow: 0 0 6px rgba(2 117 180 / 0.3);
}
.wrapper-logo td{
padding: 0!important;
}
</mj-style>
</mj-head>

View File

@@ -1,6 +1,6 @@
{
"name": "mail_mjml",
"version": "1.9.0",
"version": "1.10.0",
"description": "An util to generate html and text django's templates from mjml templates",
"type": "module",
"dependencies": {