Compare commits

..

11 Commits

Author SHA1 Message Date
Raito Bezarius 93c41f4c12 feat(read-docs): decrypt them
Signed-off-by: Raito Bezarius <masterancpp@gmail.com>
2024-09-12 17:23:26 +02:00
Raito Bezarius a9d7d53d90 feat(write-docs): write them encrypted
Signed-off-by: Raito Bezarius <masterancpp@gmail.com>
2024-09-12 17:23:19 +02:00
Raito Bezarius aebbc46e79 feat(doc-mgmt): create a key per document for E2EE
Signed-off-by: Raito Bezarius <masterancpp@gmail.com>
2024-09-12 17:23:12 +02:00
Raito Bezarius 3770a7f988 feat(provider): add E2EESDKClientProvider wrapping the app
Signed-off-by: Raito Bezarius <masterancpp@gmail.com>
2024-09-12 16:42:58 +02:00
Julien Bouquillon 54d063ab2c fix-ts 2024-09-12 16:42:58 +02:00
Julien Bouquillon 70e4518d76 wip 2024-09-12 16:42:58 +02:00
Julien Bouquillon ca54fa11f0 wip 2024-09-12 16:42:58 +02:00
Raito Bezarius 38732db8a4 wip! wip! wip! auth for E2EE
Signed-off-by: Raito Bezarius <masterancpp@gmail.com>
2024-09-12 16:42:58 +02:00
Raito Bezarius c7439be1a5 feat(api,front): expose the sub OIDC field for the frontend
Useful for E2EE encryption identification.

Signed-off-by: Raito Bezarius <masterancpp@gmail.com>
2024-09-12 16:42:58 +02:00
Anthony LC 152e7b1426 ♻️(frontend) save editor as json
Stop to save a y.js doc to save the json editor.
2024-09-12 16:42:58 +02:00
Samuel Paccoud - DINUM 49c89da2da add flag for e2e encoded documents 2024-09-12 16:20:17 +02:00
184 changed files with 16307 additions and 16798 deletions
+3 -22
View File
@@ -1,5 +1,4 @@
name: Docker Hub Workflow
run-name: Docker Hub Workflow
on:
workflow_dispatch:
@@ -49,15 +48,9 @@ jobs:
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '--target backend-production -f Dockerfile'
docker-image-name: 'docker.io/lasuite/impress-backend:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v6
uses: docker/build-push-action@v5
with:
context: .
target: backend-production
@@ -99,15 +92,9 @@ jobs:
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
docker-image-name: 'docker.io/lasuite/impress-frontend:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v6
uses: docker/build-push-action@v5
with:
context: .
file: ./src/frontend/Dockerfile
@@ -150,15 +137,9 @@ jobs:
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '-f src/frontend/Dockerfile --target y-provider'
docker-image-name: 'docker.io/lasuite/impress-frontend:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v6
uses: docker/build-push-action@v5
with:
context: .
file: ./src/frontend/Dockerfile
-22
View File
@@ -1,22 +0,0 @@
name: Helmfile lint
run-name: Helmfile lint
on:
pull_request:
branches:
- 'main'
jobs:
helmfile-lint:
runs-on: ubuntu-latest
container:
image: ghcr.io/helmfile/helmfile:latest
steps:
-
uses: numerique-gouv/action-helmfile-lint@main
with:
app-id: ${{ secrets.APP_ID }}
age-key: ${{ secrets.SOPS_PRIVATE }}
private-key: ${{ secrets.PRIVATE_KEY }}
helmfile-src: "src/helm"
repositories: "impress,secrets"
+119 -7
View File
@@ -39,6 +39,29 @@ jobs:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
build-front:
runs-on: ubuntu-latest
needs: install-front
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Restore the frontend cache
uses: actions/cache@v4
id: front-node_modules
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
- name: Build CI App
run: cd src/frontend/ && yarn ci:build
- name: Cache build frontend
uses: actions/cache@v4
with:
path: src/frontend/apps/impress/out/
key: build-front-${{ github.run_id }}
test-front:
runs-on: ubuntu-latest
needs: install-front
@@ -75,11 +98,25 @@ jobs:
test-e2e-chromium:
runs-on: ubuntu-latest
needs: build-front
timeout-minutes: 20
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set services env variables
run: |
make data/media
make create-env-files
cat env.d/development/common.e2e.dist >> env.d/development/common
- name: Restore the mail templates
uses: actions/cache@v4
id: mail-templates
with:
path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
- name: Restore the frontend cache
uses: actions/cache@v4
id: front-node_modules
@@ -87,11 +124,42 @@ jobs:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
- name: Set e2e env variables
run: cat env.d/development/common.e2e.dist >> env.d/development/common.dist
- name: Restore the build cache
uses: actions/cache@v4
id: cache-build
with:
path: src/frontend/apps/impress/out/
key: build-front-${{ github.run_id }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build the Docker images
uses: docker/bake-action@v4
with:
targets: |
app-dev
y-provider
load: true
set: |
*.cache-from=type=gha,scope=cached-stage
*.cache-to=type=gha,scope=cached-stage,mode=max
- name: Start Docker services
run: make bootstrap FLUSH_ARGS='--no-input' cache=
run: |
make run
- name: Start Nginx for the frontend
run: |
docker compose up --force-recreate -d nginx-front
- name: Apply DRF migrations
run: |
make migrate
- name: Add dummy data
run: |
make demo FLUSH_ARGS='--no-input'
- name: Install Playwright Browsers
run: cd src/frontend/apps/e2e && yarn install-playwright chromium
@@ -108,12 +176,25 @@ jobs:
test-e2e-other-browser:
runs-on: ubuntu-latest
needs: test-e2e-chromium
needs: build-front
timeout-minutes: 20
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set services env variables
run: |
make data/media
make create-env-files
cat env.d/development/common.e2e.dist >> env.d/development/common
- name: Restore the mail templates
uses: actions/cache@v4
id: mail-templates
with:
path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
- name: Restore the frontend cache
uses: actions/cache@v4
id: front-node_modules
@@ -121,11 +202,42 @@ jobs:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
- name: Set e2e env variables
run: cat env.d/development/common.e2e.dist >> env.d/development/common.dist
- name: Restore the build cache
uses: actions/cache@v4
id: cache-build
with:
path: src/frontend/apps/impress/out/
key: build-front-${{ github.run_id }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build the Docker images
uses: docker/bake-action@v4
with:
targets: |
app-dev
y-provider
load: true
set: |
*.cache-from=type=gha,scope=cached-stage
*.cache-to=type=gha,scope=cached-stage,mode=max
- name: Start Docker services
run: make bootstrap FLUSH_ARGS='--no-input' cache=
run: |
make run
- name: Start Nginx for the frontend
run: |
docker compose up --force-recreate -d nginx-front
- name: Apply DRF migrations
run: |
make migrate
- name: Add dummy data
run: |
make demo FLUSH_ARGS='--no-input'
- name: Install Playwright Browsers
run: cd src/frontend/apps/e2e && yarn install-playwright firefox webkit chromium
+139 -148
View File
@@ -9,52 +9,52 @@ on:
- "*"
jobs:
lint-git:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' # Makes sense only for pull requests
steps:
- name: Checkout repository
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: show
run: git log
- name: Enforce absence of print statements in code
run: |
! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- . ':(exclude)**/impress.yml' | grep "print("
- name: Check absence of fixup commits
run: |
! git log | grep 'fixup!'
- name: Install gitlint
run: pip install --user requests gitlint
- name: Lint commit messages added to main
run: ~/.local/bin/gitlint --commits origin/${{ github.event.pull_request.base.ref }}..HEAD
# lint-git:
# runs-on: ubuntu-latest
# if: github.event_name == 'pull_request' # Makes sense only for pull requests
# steps:
# - name: Checkout repository
# uses: actions/checkout@v2
# with:
# fetch-depth: 0
# - name: show
# run: git log
# - name: Enforce absence of print statements in code
# run: |
# ! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- . ':(exclude)**/impress.yml' | grep "print("
# - name: Check absence of fixup commits
# run: |
# ! git log | grep 'fixup!'
# - name: Install gitlint
# run: pip install --user requests gitlint
# - name: Lint commit messages added to main
# run: ~/.local/bin/gitlint --commits origin/${{ github.event.pull_request.base.ref }}..HEAD
check-changelog:
runs-on: ubuntu-latest
if: |
contains(github.event.pull_request.labels.*.name, 'noChangeLog') == false &&
github.event_name == 'pull_request'
steps:
- name: Checkout repository
uses: actions/checkout@v3
with:
fetch-depth: 50
- name: Check that the CHANGELOG has been modified in the current branch
run: git diff --name-only ${{ github.event.pull_request.base.sha }} ${{ github.event.after }} | grep 'CHANGELOG.md'
# check-changelog:
# runs-on: ubuntu-latest
# if: |
# contains(github.event.pull_request.labels.*.name, 'noChangeLog') == false &&
# github.event_name == 'pull_request'
# steps:
# - name: Checkout repository
# uses: actions/checkout@v3
# with:
# fetch-depth: 50
# - name: Check that the CHANGELOG has been modified in the current branch
# run: git diff --name-only ${{ github.event.pull_request.base.sha }} ${{ github.event.after }} | grep 'CHANGELOG.md'
lint-changelog:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v2
- name: Check CHANGELOG max line length
run: |
max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L)
if [ $max_line_length -ge 80 ]; then
echo "ERROR: CHANGELOG has lines longer than 80 characters."
exit 1
fi
# lint-changelog:
# runs-on: ubuntu-latest
# steps:
# - name: Checkout repository
# uses: actions/checkout@v2
# - name: Check CHANGELOG max line length
# run: |
# max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L)
# if [ $max_line_length -ge 80 ]; then
# echo "ERROR: CHANGELOG has lines longer than 80 characters."
# exit 1
# fi
build-mails:
runs-on: ubuntu-latest
@@ -96,121 +96,112 @@ jobs:
path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
lint-back:
runs-on: ubuntu-latest
defaults:
run:
working-directory: src/backend
steps:
- name: Checkout repository
uses: actions/checkout@v2
- name: Install Python
uses: actions/setup-python@v3
with:
python-version: "3.10"
- name: Install development dependencies
run: pip install --user .[dev]
- name: Check code formatting with ruff
run: ~/.local/bin/ruff format . --diff
- name: Lint code with ruff
run: ~/.local/bin/ruff check .
- name: Lint code with pylint
run: ~/.local/bin/pylint .
# lint-back:
# runs-on: ubuntu-latest
# defaults:
# run:
# working-directory: src/backend
# steps:
# - name: Checkout repository
# uses: actions/checkout@v2
# - name: Install Python
# uses: actions/setup-python@v3
# with:
# python-version: "3.10"
# - name: Install development dependencies
# run: pip install --user .[dev]
# - name: Check code formatting with ruff
# run: ~/.local/bin/ruff format . --diff
# - name: Lint code with ruff
# run: ~/.local/bin/ruff check .
# - name: Lint code with pylint
# run: ~/.local/bin/pylint .
test-back:
runs-on: ubuntu-latest
needs: build-mails
# test-back:
# runs-on: ubuntu-latest
# needs: build-mails
defaults:
run:
working-directory: src/backend
# defaults:
# run:
# working-directory: src/backend
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: impress
POSTGRES_USER: dinum
POSTGRES_PASSWORD: pass
ports:
- 5432:5432
# needed because the postgres container does not provide a healthcheck
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
# services:
# postgres:
# image: postgres:16
# env:
# POSTGRES_DB: impress
# POSTGRES_USER: dinum
# POSTGRES_PASSWORD: pass
# ports:
# - 5432:5432
# # needed because the postgres container does not provide a healthcheck
# options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
env:
DJANGO_CONFIGURATION: Test
DJANGO_SETTINGS_MODULE: impress.settings
DJANGO_SECRET_KEY: ThisIsAnExampleKeyForTestPurposeOnly
OIDC_OP_JWKS_ENDPOINT: /endpoint-for-test-purpose-only
DB_HOST: localhost
DB_NAME: impress
DB_USER: dinum
DB_PASSWORD: pass
DB_PORT: 5432
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
AWS_S3_ENDPOINT_URL: http://localhost:9000
AWS_S3_ACCESS_KEY_ID: impress
AWS_S3_SECRET_ACCESS_KEY: password
# env:
# DJANGO_CONFIGURATION: Test
# DJANGO_SETTINGS_MODULE: impress.settings
# DJANGO_SECRET_KEY: ThisIsAnExampleKeyForTestPurposeOnly
# OIDC_OP_JWKS_ENDPOINT: /endpoint-for-test-purpose-only
# DB_HOST: localhost
# DB_NAME: impress
# DB_USER: dinum
# DB_PASSWORD: pass
# DB_PORT: 5432
# STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
# AWS_S3_ENDPOINT_URL: http://localhost:9000
# AWS_S3_ACCESS_KEY_ID: impress
# AWS_S3_SECRET_ACCESS_KEY: password
steps:
- name: Checkout repository
uses: actions/checkout@v4
# steps:
# - name: Checkout repository
# uses: actions/checkout@v4
- name: Create writable /data
run: |
sudo mkdir -p /data/media && \
sudo mkdir -p /data/static
# - name: Create writable /data
# run: |
# sudo mkdir -p /data/media && \
# sudo mkdir -p /data/static
- name: Restore the mail templates
uses: actions/cache@v4
id: mail-templates
with:
path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
# - name: Restore the mail templates
# uses: actions/cache@v4
# id: mail-templates
# with:
# path: "src/backend/core/templates/mail"
# key: mail-templates-${{ hashFiles('src/mail/mjml') }}
- name: Start MinIO
run: |
docker pull minio/minio
docker run -d --name minio \
-p 9000:9000 \
-e "MINIO_ACCESS_KEY=impress" \
-e "MINIO_SECRET_KEY=password" \
-v /data/media:/data \
minio/minio server --console-address :9001 /data
# - name: Start Minio
# run: |
# docker pull minio/minio
# docker run -d --name minio \
# -p 9000:9000 \
# -e "MINIO_ACCESS_KEY=impress" \
# -e "MINIO_SECRET_KEY=password" \
# -v /data/media:/data \
# minio/minio server --console-address :9001 /data
# Tool to wait for a service to be ready
- name: Install Dockerize
run: |
curl -sSL https://github.com/jwilder/dockerize/releases/download/v0.8.0/dockerize-linux-amd64-v0.8.0.tar.gz | sudo tar -C /usr/local/bin -xzv
# - name: Configure MinIO
# run: |
# MINIO=$(docker ps | grep minio/minio | sed -E 's/.*\s+([a-zA-Z0-9_-]+)$/\1/')
# docker exec ${MINIO} sh -c \
# "mc alias set impress http://localhost:9000 impress password && \
# mc alias ls && \
# mc mb impress/impress-media-storage && \
# mc version enable impress/impress-media-storage"
- name: Wait for MinIO to be ready
run: |
dockerize -wait tcp://localhost:9000 -timeout 10s
# - name: Install Python
# uses: actions/setup-python@v3
# with:
# python-version: "3.10"
- name: Configure MinIO
run: |
MINIO=$(docker ps | grep minio/minio | sed -E 's/.*\s+([a-zA-Z0-9_-]+)$/\1/')
docker exec ${MINIO} sh -c \
"mc alias set impress http://localhost:9000 impress password && \
mc alias ls && \
mc mb impress/impress-media-storage && \
mc version enable impress/impress-media-storage"
# - name: Install development dependencies
# run: pip install --user .[dev]
- name: Install Python
uses: actions/setup-python@v3
with:
python-version: "3.10"
# - name: Install gettext (required to compile messages)
# run: |
# sudo apt-get update
# sudo apt-get install -y gettext pandoc
- name: Install development dependencies
run: pip install --user .[dev]
# - name: Generate a MO file from strings extracted from the project
# run: python manage.py compilemessages
- name: Install gettext (required to compile messages)
run: |
sudo apt-get update
sudo apt-get install -y gettext pandoc
- name: Generate a MO file from strings extracted from the project
run: python manage.py compilemessages
- name: Run tests
run: ~/.local/bin/pytest -n 2
# - name: Run tests
# run: ~/.local/bin/pytest -n 2
+3 -55
View File
@@ -9,54 +9,6 @@ and this project adheres to
## [Unreleased]
## Changed
- ♻️(frontend) More multi theme friendly #325
- ♻️ Bootstrap frontend #257
## Fixed
🐛(frontend) invalidate queries after removing user #336
## [1.5.1] - 2024-10-10
## Fixed
- 🐛(db) fix users duplicate #316
## [1.5.0] - 2024-10-09
## Added
- ✨(backend) add name fields to the user synchronized with OIDC #301
- ✨(ci) add security scan #291
- ♻️(frontend) Add versions #277
- ✨(frontend) one-click document creation #275
- ✨(frontend) edit title inline #275
- 📱(frontend) mobile responsive #304
- 🌐(frontend) Update translation #308
## Changed
- 💄(frontend) error alert closeable on editor #284
- ♻️(backend) Change email content #283
- 🛂(frontend) viewers and editors can access share modal #302
- ♻️(frontend) remove footer on doc editor #313
## Fixed
- 🛂(frontend) match email if no existing user matches the sub
- 🐛(backend) gitlab oicd userinfo endpoint #232
- 🛂(frontend) redirect to the OIDC when private doc and unauthentified #292
- ♻️(backend) getting list of document versions available for a user #258
- 🔧(backend) fix configuration to avoid different ssl warning #297
- 🐛(frontend) fix editor break line not working #302
## [1.4.0] - 2024-09-17
## Added
- ✨Add link public/authenticated/restricted access with read/editor roles #234
@@ -65,14 +17,13 @@ and this project adheres to
## Changed
- ♻️(backend) Allow null titles on documents for easier creation #234
- ♻️ Allow null titles on documents for easier creation #234
- 🛂(backend) stop to list public doc to everyone #234
- 🚚(frontend) change visibility in share modal #235
- ⚡️(frontend) Improve summary #244
## Fixed
- 🐛(backend) Fix forcing ID when creating a document via API endpoint #234
- 🐛 Fix forcing ID when creating a document via API endpoint #234
- 🐛 Rebuild frontend dev container from makefile #248
@@ -198,10 +149,7 @@ and this project adheres to
- 🚀 Impress, project to manage your documents easily and collaboratively.
[unreleased]: https://github.com/numerique-gouv/impress/compare/v1.5.1...main
[1.5.1]: https://github.com/numerique-gouv/impress/releases/v1.5.1
[1.5.0]: https://github.com/numerique-gouv/impress/releases/v1.5.0
[1.4.0]: https://github.com/numerique-gouv/impress/releases/v1.4.0
[unreleased]: https://github.com/numerique-gouv/impress/compare/v1.3.0...main
[1.3.0]: https://github.com/numerique-gouv/impress/releases/v1.3.0
[1.2.1]: https://github.com/numerique-gouv/impress/releases/v1.2.1
[1.2.0]: https://github.com/numerique-gouv/impress/releases/v1.2.0
+32 -28
View File
@@ -1,20 +1,18 @@
# Django impress
# ---- base image to inherit from ----
FROM python:3.12.6-alpine3.20 AS base
FROM python:3.10-slim-bullseye as base
# Upgrade pip to its latest release to speed up dependencies installation
RUN python -m pip install --upgrade pip setuptools
RUN python -m pip install --upgrade pip
# Upgrade system packages to install security updates
RUN apk update && \
apk upgrade
RUN apt-get update && \
apt-get -y upgrade && \
rm -rf /var/lib/apt/lists/*
# ---- Back-end builder image ----
FROM base AS back-builder
RUN apk add \
cargo
FROM base as back-builder
WORKDIR /builder
@@ -26,7 +24,7 @@ RUN mkdir /install && \
# ---- mails ----
FROM node:20 AS mail-builder
FROM node:20 as mail-builder
COPY ./src/mail /mail/app
@@ -37,13 +35,15 @@ RUN yarn install --frozen-lockfile && \
# ---- static link collector ----
FROM base AS link-collector
FROM base as link-collector
ARG IMPRESS_STATIC_ROOT=/data/static
# Install pango & rdfind
RUN apk add \
pango \
rdfind
# Install libpangocairo & rdfind
RUN apt-get update && \
apt-get install -y \
libpangocairo-1.0-0 \
rdfind && \
rm -rf /var/lib/apt/lists/*
# Copy installed python dependencies
COPY --from=back-builder /install /usr/local
@@ -62,21 +62,23 @@ RUN DJANGO_CONFIGURATION=Build DJANGO_JWT_PRIVATE_SIGNING_KEY=Dummy \
RUN rdfind -makesymlinks true -followsymlinks true -makeresultsfile false ${IMPRESS_STATIC_ROOT}
# ---- Core application image ----
FROM base AS core
FROM base as core
ENV PYTHONUNBUFFERED=1
# Install required system libs
RUN apk add \
gettext \
cairo \
libffi-dev \
gdk-pixbuf \
pango \
pandoc \
font-noto-emoji \
font-noto \
shared-mime-info
RUN apt-get update && \
apt-get install -y \
gettext \
libcairo2 \
libffi-dev \
libgdk-pixbuf2.0-0 \
libpango-1.0-0 \
libpangocairo-1.0-0 \
pandoc \
fonts-noto-color-emoji \
shared-mime-info && \
rm -rf /var/lib/apt/lists/*
# Copy entrypoint
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
@@ -100,13 +102,15 @@ WORKDIR /app
ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
# ---- Development image ----
FROM core AS backend-development
FROM core as backend-development
# Switch back to the root user to install development dependencies
USER root:root
# Install psql
RUN apk add postgresql-client
RUN apt-get update && \
apt-get install -y postgresql-client && \
rm -rf /var/lib/apt/lists/*
# Uninstall impress and re-install it in editable mode along with development
# dependencies
@@ -126,7 +130,7 @@ ENV DB_HOST=postgresql \
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
# ---- Production image ----
FROM core AS backend-production
FROM core as backend-production
ARG IMPRESS_STATIC_ROOT=/data/static
+9 -36
View File
@@ -81,7 +81,7 @@ bootstrap: \
data/static \
create-env-files \
build \
run-with-frontend \
run-frontend-dev \
migrate \
demo \
back-i18n-compile \
@@ -90,28 +90,11 @@ bootstrap: \
.PHONY: bootstrap
# -- Docker/compose
build: cache ?= --no-cache
build: ## build the project containers
@$(MAKE) build-backend cache=$(cache)
@$(MAKE) build-yjs-provider cache=$(cache)
@$(MAKE) build-frontend cache=$(cache)
build: ## build the app-dev container
@$(COMPOSE) build app-dev --no-cache
@$(COMPOSE) build frontend-dev --no-cache
.PHONY: build
build-backend: cache ?=
build-backend: ## build the app-dev container
@$(COMPOSE) build app-dev $(cache)
.PHONY: build-backend
build-yjs-provider: cache ?=
build-yjs-provider: ## build the y-provider container
@$(COMPOSE) build y-provider $(cache)
.PHONY: build-yjs-provider
build-frontend: cache ?=
build-frontend: ## build the frontend container
@$(COMPOSE) build frontend-dev $(cache)
.PHONY: build-frontend
down: ## stop and remove containers, networks, images, and volumes
@$(COMPOSE) down
.PHONY: down
@@ -127,11 +110,6 @@ run: ## start the wsgi (production) and development server
@$(WAIT_DB)
.PHONY: run
run-with-frontend: ## Start all the containers needed (backend to frontend)
@$(MAKE) run
@$(COMPOSE) up --force-recreate -d frontend-dev
.PHONY: run-with-frontend
status: ## an alias for "docker compose ps"
@$(COMPOSE) ps
.PHONY: status
@@ -308,15 +286,10 @@ help:
@grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(firstword $(MAKEFILE_LIST)) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "$(GREEN)%-30s$(RESET) %s\n", $$1, $$2}'
.PHONY: help
# Front
frontend-install: ## install the frontend locally
cd $(PATH_FRONT_IMPRESS) && yarn
.PHONY: frontend-install
run-frontend-development: ## Run the frontend in development mode
@$(COMPOSE) stop frontend-dev
cd $(PATH_FRONT_IMPRESS) && yarn dev
.PHONY: run-frontend-development
# Front
run-frontend-dev: ## Install and run the frontend dev
@$(COMPOSE) up --force-recreate -d frontend-dev
.PHONY: run-frontend-dev
frontend-i18n-extract: ## Extract the frontend translation inside a json to be used for crowdin
cd $(PATH_FRONT) && yarn i18n:extract
@@ -341,7 +314,7 @@ start-tilt: ## start the kubernetes cluster using kind
tilt up -f ./bin/Tiltfile
.PHONY: build-k8s-cluster
bump-packages-version: VERSION_TYPE ?= minor
VERSION_TYPE ?= minor
bump-packages-version: ## bump the version of the project - VERSION_TYPE can be "major", "minor", "patch"
cd ./src/mail && yarn version --no-git-tag-version --$(VERSION_TYPE)
cd ./src/frontend/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
+13 -40
View File
@@ -1,15 +1,9 @@
# Impress
Impress is a web application for real-time collaborative text editing with user and role based access rights.
Features include :
- User authentication through OIDC
- BlocNote.js text editing experience (markdown support, dynamic conversion, block structure, slash commands for block creation)
- Document export to pdf and docx from predefined templates
- Granular document permissions
- Public link sharing
- Offline mode
Impress prints your markdown to pdf from predefined templates with user and role based access rights.
Impress is built on top of [Django Rest Framework](https://www.django-rest-framework.org/), [Next.js](https://nextjs.org/) and [BlocNote.js](https://www.blocknotejs.org/)
Impress is built on top of [Django Rest
Framework](https://www.django-rest-framework.org/) and [Next.js](https://nextjs.org/).
## Getting started
@@ -37,6 +31,14 @@ The easiest way to start working on the project is to use GNU Make:
$ make bootstrap FLUSH_ARGS='--no-input'
```
Then you can access to the project in development mode by going to http://localhost:3000.
You will be prompted to log in, the default credentials are:
```bash
username: impress
password: impress
```
---
This command builds the `app` container, installs dependencies, performs
database migrations and compile translations. It's a good idea to use this
command each time you are pulling code from the project repository to avoid
@@ -44,41 +46,12 @@ dependency-releated or migration-releated issues.
Your Docker services should now be up and running 🎉
You can access to the project by going to http://localhost:3000.
You will be prompted to log in, the default credentials are:
```bash
username: impress
password: impress
```
📝 Note that if you need to run them afterwards, you can use the eponym Make rule:
Note that if you need to run them afterwards, you can use the eponym Make rule:
```bash
$ make run-with-frontend
$ make run-frontend-dev
```
---
⚠️ For the frontend developper, it is often better to run the frontend in development mode locally.
To do so, install the frontend dependencies with the following command:
```bash
$ make frontend-install
```
And run the frontend locally in development mode with the following command:
```bash
$ make run-frontend-development
```
To start all the services, except the frontend container, you can use the following command:
```bash
$ make run
```
---
### Adding content
You can create a basic demo site by running:
+20 -12
View File
@@ -119,20 +119,13 @@ services:
depends_on:
- keycloak
frontend-dev:
user: "${DOCKER_USER:-1000}"
build:
context: .
dockerfile: ./src/frontend/Dockerfile
target: frontend-production
args:
API_ORIGIN: "http://localhost:8071"
Y_PROVIDER_URL: "ws://localhost:4444"
MEDIA_URL: "http://localhost:8083"
SW_DEACTIVATED: "true"
image: impress:frontend-development
nginx-front:
image: nginx:1.25
ports:
- "3000:3000"
volumes:
- ./src/frontend/apps/impress/conf/default.conf:/etc/nginx/conf.d/default.conf
- ./src/frontend/apps/impress/out:/usr/share/nginx/html
dockerize:
image: jwilder/dockerize
@@ -168,6 +161,21 @@ services:
- /home/frontend/servers/y-provider/node_modules/
- /home/frontend/servers/y-provider/dist/
frontend-dev:
user: "${DOCKER_USER:-1000}"
build:
context: .
dockerfile: ./src/frontend/Dockerfile
target: impress-dev
ports:
- "3000:3000"
volumes:
- ./src/frontend/apps/impress:/home/frontend/apps/impress
- /home/frontend/node_modules/
depends_on:
- y-provider
- celery-dev
kc_postgresql:
image: postgres:14.3
ports:
+1 -1
Submodule secrets updated: 38594182e8...2643697e5f
+2 -2
View File
@@ -447,10 +447,10 @@ max-bool-expr=5
max-branches=12
# Maximum number of locals for function / method body
max-locals=20
max-locals=15
# Maximum number of parents for a class (see R0901).
max-parents=10
max-parents=7
# Maximum number of public methods for a class (see R0904).
max-public-methods=20
+4 -32
View File
@@ -29,19 +29,7 @@ class UserAdmin(auth_admin.UserAdmin):
)
},
),
(
_("Personal info"),
{
"fields": (
"sub",
"email",
"full_name",
"short_name",
"language",
"timezone",
)
},
),
(_("Personal info"), {"fields": ("sub", "email", "language", "timezone")}),
(
_("Permissions"),
{
@@ -70,7 +58,6 @@ class UserAdmin(auth_admin.UserAdmin):
list_display = (
"id",
"sub",
"full_name",
"admin_email",
"email",
"is_active",
@@ -81,24 +68,9 @@ class UserAdmin(auth_admin.UserAdmin):
"updated_at",
)
list_filter = ("is_staff", "is_superuser", "is_device", "is_active")
ordering = (
"is_active",
"-is_superuser",
"-is_staff",
"-is_device",
"-updated_at",
"full_name",
)
readonly_fields = (
"id",
"sub",
"email",
"full_name",
"short_name",
"created_at",
"updated_at",
)
search_fields = ("id", "sub", "admin_email", "email", "full_name")
ordering = ("is_active", "-is_superuser", "-is_staff", "-is_device", "-updated_at")
readonly_fields = ("id", "sub", "email", "created_at", "updated_at")
search_fields = ("id", "sub", "admin_email", "email")
@admin.register(models.Template)
+9 -8
View File
@@ -16,8 +16,8 @@ class UserSerializer(serializers.ModelSerializer):
class Meta:
model = models.User
fields = ["id", "email", "full_name", "short_name"]
read_only_fields = ["id", "email", "full_name", "short_name"]
fields = ["id", "sub", "email"]
read_only_fields = ["id", "sub", "email"]
class BaseAccessSerializer(serializers.ModelSerializer):
@@ -148,6 +148,7 @@ class DocumentSerializer(BaseResourceSerializer):
"title",
"accesses",
"abilities",
"is_e2ee",
"link_role",
"link_reach",
"created_at",
@@ -343,10 +344,10 @@ class InvitationSerializer(serializers.ModelSerializer):
return attrs
class VersionFilterSerializer(serializers.Serializer):
"""Validate version filters applied to the list endpoint."""
class DocumentVersionSerializer(serializers.Serializer):
"""Serialize Versions."""
version_id = serializers.CharField(required=False, allow_blank=True)
page_size = serializers.IntegerField(
required=False, min_value=1, max_value=50, default=20
)
etag = serializers.CharField()
is_latest = serializers.BooleanField()
last_modified = serializers.DateTimeField()
version_id = serializers.CharField()
+22 -33
View File
@@ -10,7 +10,6 @@ from django.contrib.postgres.aggregates import ArrayAgg
from django.core.exceptions import ValidationError
from django.core.files.storage import default_storage
from django.db.models import (
Min,
OuterRef,
Q,
Subquery,
@@ -32,6 +31,7 @@ from rest_framework import (
)
from core import models
from core.utils import email_invitation
from . import permissions, serializers, utils
@@ -374,36 +374,28 @@ class DocumentViewSet(
Return the document's versions but only those created after the user got access
to the document
"""
user = request.user
if not user.is_authenticated:
if not request.user.is_authenticated:
raise exceptions.PermissionDenied("Authentication required.")
# Validate query parameters using dedicated serializer
serializer = serializers.VersionFilterSerializer(data=request.query_params)
serializer.is_valid(raise_exception=True)
document = self.get_object()
# Users should not see version history dating from before they gained access to the
# document. Filter to get the minimum access date for the logged-in user
access_queryset = document.accesses.filter(
Q(user=user) | Q(team__in=user.teams)
).aggregate(min_date=Min("created_at"))
# Handle the case where the user has no accesses
min_datetime = access_queryset["min_date"]
if not min_datetime:
return exceptions.PermissionDenied(
"Only users with specific access can see version history"
user = request.user
from_datetime = min(
access.created_at
for access in document.accesses.filter(
Q(user=user) | Q(team__in=user.teams),
)
versions_data = document.get_versions_slice(
from_version_id=serializer.validated_data.get("version_id"),
min_datetime=min_datetime,
page_size=serializer.validated_data.get("page_size"),
)
return drf_response.Response(versions_data)
versions_data = document.get_versions_slice(from_datetime=from_datetime)[
"versions"
]
paginator = pagination.PageNumberPagination()
paginated_versions = paginator.paginate_queryset(versions_data, request)
serialized_versions = serializers.DocumentVersionSerializer(
paginated_versions, many=True
)
return paginator.get_paginated_response(serialized_versions.data)
@decorators.action(
detail=True,
@@ -423,13 +415,13 @@ class DocumentViewSet(
# Don't let users access versions that were created before they were given access
# to the document
user = request.user
min_datetime = min(
from_datetime = min(
access.created_at
for access in document.accesses.filter(
Q(user=user) | Q(team__in=user.teams),
)
)
if response["LastModified"] < min_datetime:
if response["LastModified"] < from_datetime:
raise Http404
if request.method == "DELETE":
@@ -575,10 +567,9 @@ class DocumentAccessViewSet(
def perform_create(self, serializer):
"""Add a new access to the document and send an email to the new added user."""
access = serializer.save()
language = self.request.headers.get("Content-Language", "en-us")
access.document.email_invitation(
language, access.user.email, access.role, self.request.user.email
)
email_invitation(language, access.user.email, access.document.id)
class TemplateViewSet(
@@ -778,6 +769,4 @@ class InvitationViewset(
invitation = serializer.save()
language = self.request.headers.get("Content-Language", "en-us")
invitation.document.email_invitation(
language, invitation.email, invitation.role, self.request.user.email
)
email_invitation(language, invitation.email, invitation.document.id)
+37 -57
View File
@@ -1,6 +1,5 @@
"""Authentication Backends for the Impress core app."""
from django.conf import settings
from django.core.exceptions import SuspiciousOperation
from django.utils.translation import gettext_lazy as _
@@ -46,75 +45,56 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
proxies=self.get_settings("OIDC_PROXY", None),
)
user_response.raise_for_status()
try:
userinfo = user_response.json()
except ValueError:
try:
userinfo = self.verify_token(user_response.text)
except Exception as e:
raise SuspiciousOperation(
_("Invalid response format or token verification failed")
) from e
userinfo = self.verify_token(user_response.text)
return userinfo
def get_or_create_user(self, access_token, id_token, payload):
"""Return a User based on userinfo. Create a new user if no match is found."""
"""Return a User based on userinfo. Get or create a new user if no user matches the Sub.
Parameters:
- access_token (str): The access token.
- id_token (str): The ID token.
- payload (dict): The user payload.
Returns:
- User: An existing or newly created User instance.
Raises:
- Exception: Raised when user creation is not allowed and no existing user is found.
"""
user_info = self.get_userinfo(access_token, id_token, payload)
email = user_info.get("email")
# Get user's full name from OIDC fields defined in settings
full_name = self.compute_full_name(user_info)
short_name = user_info.get(settings.USER_OIDC_FIELD_TO_SHORTNAME)
claims = {
"email": email,
"full_name": full_name,
"short_name": short_name,
}
sub = user_info.get("sub")
if not sub:
if sub is None:
raise SuspiciousOperation(
_("User info contained no recognizable user identification")
)
user = self.get_existing_user(sub, email)
if user:
self.update_user_if_needed(user, claims)
elif self.get_settings("OIDC_CREATE_USER", True):
user = User.objects.create(sub=sub, password="!", **claims) # noqa: S106
try:
user = User.objects.get(sub=sub)
except User.DoesNotExist:
if self.get_settings("OIDC_CREATE_USER", True):
user = self.create_user(user_info)
else:
user = None
return user
def compute_full_name(self, user_info):
"""Compute user's full name based on OIDC fields in settings."""
name_fields = settings.USER_OIDC_FIELDS_TO_FULLNAME
full_name = " ".join(
user_info[field] for field in name_fields if user_info.get(field)
)
return full_name or None
def create_user(self, claims):
"""Return a newly created User instance."""
def get_existing_user(self, sub, email):
"""Fetch existing user by sub or email."""
try:
return User.objects.get(sub=sub, is_active=True)
except User.DoesNotExist:
if email and settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION:
try:
return User.objects.get(email=email, is_active=True)
except User.DoesNotExist:
pass
return None
sub = claims.get("sub")
def update_user_if_needed(self, user, claims):
"""Update user claims if they have changed."""
has_changed = any(
value and value != getattr(user, key) for key, value in claims.items()
if sub is None:
raise SuspiciousOperation(
_("Claims contained no recognizable user identification")
)
user = User.objects.create(
sub=sub,
email=claims.get("email"),
password="!", # noqa: S106
)
if has_changed:
updated_claims = {key: value for key, value in claims.items() if value}
self.UserModel.objects.filter(sub=user.sub).update(**updated_claims)
return user
-2
View File
@@ -22,8 +22,6 @@ class UserFactory(factory.django.DjangoModelFactory):
sub = factory.Sequence(lambda n: f"user{n!s}")
email = factory.Faker("email")
full_name = factory.Faker("name")
short_name = factory.Faker("first_name")
language = factory.fuzzy.FuzzyChoice([lang[0] for lang in settings.LANGUAGES])
password = make_password("password")
@@ -1,28 +0,0 @@
# Generated by Django 5.1.1 on 2024-09-29 03:47
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0005_remove_document_is_public_alter_document_link_reach_and_more'),
]
operations = [
migrations.AddField(
model_name='user',
name='full_name',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='full name'),
),
migrations.AddField(
model_name='user',
name='short_name',
field=models.CharField(blank=True, max_length=20, null=True, verbose_name='short name'),
),
migrations.AlterField(
model_name='user',
name='language',
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.1 on 2024-09-12 13:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0005_remove_document_is_public_alter_document_link_reach_and_more'),
]
operations = [
migrations.AddField(
model_name='document',
name='is_e2ee',
field=models.BooleanField(default=False),
),
]
@@ -1,128 +0,0 @@
# Generated by Django 5.1.1 on 2024-10-10 11:45
from django.db import migrations
procedure = """
DO $$
DECLARE
user_email TEXT;
BEGIN
-- Step 1: Create a temporary table (without the unique constraint)
-- impress_document_access
DROP TABLE IF EXISTS impress_document_access_tmp;
CREATE TEMP TABLE impress_document_access_tmp AS
SELECT * FROM impress_document_access;
-- impress_link_trace
DROP TABLE IF EXISTS impress_link_trace_tmp;
CREATE TEMP TABLE impress_link_trace_tmp AS
SELECT * FROM impress_link_trace;
-- Step 2: Loop through each email that appears more than once
FOR user_email IN
SELECT email
FROM impress_user
GROUP BY email
HAVING COUNT(email) > 1
LOOP
-- Step 3: Update user_id in the temporary table based on email
-- For impress_document_access
UPDATE impress_document_access_tmp
SET user_id = (
SELECT id
FROM impress_user
WHERE email = user_email
LIMIT 1
)
WHERE user_id IN (
SELECT id
FROM impress_user
WHERE email = user_email
);
-- For impress_link_trace
UPDATE impress_link_trace_tmp
SET user_id = (
SELECT id
FROM impress_user
WHERE email = user_email
LIMIT 1
)
WHERE user_id IN (
SELECT id
FROM impress_user
WHERE email = user_email
);
-- update impress_invitation
UPDATE impress_invitation
SET issuer_id = (
SELECT id
FROM impress_user
WHERE email = user_email
LIMIT 1
)
WHERE issuer_id IN (
SELECT id
FROM impress_user
WHERE email = user_email
);
DELETE FROM impress_user
WHERE id IN (
SELECT id
FROM impress_user
WHERE email = user_email
)
AND id != (
SELECT id
FROM impress_user
WHERE email = user_email
LIMIT 1
);
RAISE NOTICE 'Processed updates for email: %', user_email;
END LOOP;
-- Step 4: Remove duplicate rows from the temporary table, keeping only one row per (document_id, user_id)
-- For impress_document_access
DELETE FROM impress_document_access_tmp a
USING impress_document_access_tmp b
WHERE a.ctid < b.ctid -- Keep one row
AND a.document_id = b.document_id
AND a.user_id = b.user_id;
-- Step 5: Replace the original table with the cleaned-up temporary table
TRUNCATE TABLE impress_document_access;
-- Insert cleaned-up data back into the original table
INSERT INTO impress_document_access
SELECT * FROM impress_document_access_tmp;
-- For impress_link_trace
DELETE FROM impress_link_trace_tmp a
USING impress_link_trace_tmp b
WHERE a.ctid < b.ctid -- Keep one row
AND a.document_id = b.document_id
AND a.user_id = b.user_id;
-- Step 5: Replace the original table with the cleaned-up temporary table
TRUNCATE TABLE impress_link_trace;
-- Insert cleaned-up data back into the original table
INSERT INTO impress_link_trace
SELECT * FROM impress_link_trace_tmp;
RAISE NOTICE 'Update and deduplication process completed.';
END $$;
"""
class Migration(migrations.Migration):
dependencies = [
('core', '0006_add_user_full_name_and_short_name'),
]
operations = [
migrations.RunSQL(procedure),
]
+54 -84
View File
@@ -3,7 +3,6 @@ Declare and configure the models for the impress core application
"""
import hashlib
import smtplib
import tempfile
import textwrap
import uuid
@@ -14,20 +13,16 @@ from logging import getLogger
from django.conf import settings
from django.contrib.auth import models as auth_models
from django.contrib.auth.base_user import AbstractBaseUser
from django.contrib.sites.models import Site
from django.core import exceptions, mail, validators
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
from django.core.mail import send_mail
from django.db import models
from django.http import FileResponse
from django.template.base import Template as DjangoTemplate
from django.template.context import Context
from django.template.loader import render_to_string
from django.utils import html, timezone
from django.utils.functional import cached_property, lazy
from django.utils.translation import gettext_lazy as _
from django.utils.translation import override
import frontmatter
import markdown
@@ -145,10 +140,6 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
blank=True,
null=True,
)
full_name = models.CharField(_("full name"), max_length=100, null=True, blank=True)
short_name = models.CharField(_("short name"), max_length=20, null=True, blank=True)
email = models.EmailField(_("identity email address"), blank=True, null=True)
# Unlike the "email" field which stores the email coming from the OIDC token, this field
@@ -338,6 +329,7 @@ class Document(BaseModel):
link_role = models.CharField(
max_length=20, choices=LinkRoleChoices.choices, default=LinkRoleChoices.READER
)
is_e2ee = models.BooleanField(default=False)
_content = None
@@ -420,62 +412,73 @@ class Document(BaseModel):
Bucket=default_storage.bucket_name, Key=self.file_key, VersionId=version_id
)
def get_versions_slice(self, from_version_id="", min_datetime=None, page_size=None):
def get_versions_slice(
self, from_version_id="", from_datetime=None, page_size=None
):
"""Get document versions from object storage with pagination and starting conditions"""
# /!\ Trick here /!\
# The "KeyMarker" and "VersionIdMarker" fields must either be both set or both not set.
# The error we get otherwise is not helpful at all.
markers = {}
token = {}
if from_version_id:
markers.update(
token.update(
{"KeyMarker": self.file_key, "VersionIdMarker": from_version_id}
)
real_page_size = (
min(page_size, settings.DOCUMENT_VERSIONS_PAGE_SIZE)
if page_size
else settings.DOCUMENT_VERSIONS_PAGE_SIZE
)
if from_datetime:
response = default_storage.connection.meta.client.list_object_versions(
Bucket=default_storage.bucket_name,
Prefix=self.file_key,
MaxKeys=settings.DOCUMENT_VERSIONS_PAGE_SIZE,
**token,
)
# Find the first version after the given datetime
version = None
for version in response.get("Versions", []):
if version["LastModified"] >= from_datetime:
token = {
"KeyMarker": self.file_key,
"VersionIdMarker": version["VersionId"],
}
break
else:
if version is None or version["LastModified"] < from_datetime:
if response["NextVersionIdMarker"]:
return self.get_versions_slice(
from_version_id=response["NextVersionIdMarker"],
page_size=settings.DOCUMENT_VERSIONS_PAGE_SIZE,
from_datetime=from_datetime,
)
return {
"next_version_id_marker": "",
"is_truncated": False,
"versions": [],
}
response = default_storage.connection.meta.client.list_object_versions(
Bucket=default_storage.bucket_name,
Prefix=self.file_key,
# compensate the latest version that we exclude below and get one more to
# know if there are more pages
MaxKeys=real_page_size + 2,
**markers,
MaxKeys=min(page_size, settings.DOCUMENT_VERSIONS_PAGE_SIZE)
if page_size
else settings.DOCUMENT_VERSIONS_PAGE_SIZE,
**token,
)
min_last_modified = min_datetime or self.created_at
versions = [
{
key_snake: version[key_camel]
for key_snake, key_camel in [
("etag", "ETag"),
("is_latest", "IsLatest"),
("last_modified", "LastModified"),
("version_id", "VersionId"),
]
}
for version in response.get("Versions", [])
if version["LastModified"] >= min_last_modified
and version["IsLatest"] is False
]
results = versions[:real_page_size]
count = len(results)
if count == len(versions):
is_truncated = False
next_version_id_marker = ""
else:
is_truncated = True
next_version_id_marker = versions[count - 1]["version_id"]
return {
"next_version_id_marker": next_version_id_marker,
"is_truncated": is_truncated,
"versions": results,
"count": count,
"next_version_id_marker": response["NextVersionIdMarker"],
"is_truncated": response["IsTruncated"],
"versions": [
{
key_snake: version[key_camel]
for key_camel, key_snake in [
("ETag", "etag"),
("IsLatest", "is_latest"),
("LastModified", "last_modified"),
("VersionId", "version_id"),
]
}
for version in response.get("Versions", [])
],
}
def delete_version(self, version_id):
@@ -520,39 +523,6 @@ class Document(BaseModel):
"versions_retrieve": can_get_versions,
}
def email_invitation(self, language, email, role, username_sender):
"""Send email invitation."""
domain = Site.objects.get_current().domain
try:
with override(language):
title = _("%(username)s shared a document with you: %(document)s") % {
"username": username_sender,
"document": self.title,
}
template_vars = {
"title": title,
"domain": domain,
"document": self,
"link": f"{domain}/docs/{self.id}/",
"username": username_sender,
"role": RoleChoices(role).label.lower(),
}
msg_html = render_to_string("mail/html/invitation.html", template_vars)
msg_plain = render_to_string("mail/text/invitation.txt", template_vars)
send_mail(
title,
msg_plain,
settings.EMAIL_FROM,
[email],
html_message=msg_html,
fail_silently=False,
)
except smtplib.SMTPException as exception:
logger.error("invitation to %s was not sent: %s", email, exception)
class LinkTrace(BaseModel):
"""
@@ -1,12 +1,8 @@
"""Unit tests for the Authentication Backends."""
import re
from django.core.exceptions import SuspiciousOperation
from django.test.utils import override_settings
import pytest
import responses
from core import models
from core.authentication.backends import OIDCAuthenticationBackend
@@ -38,130 +34,6 @@ def test_authentication_getter_existing_user_no_email(
assert user == db_user
def test_authentication_getter_existing_user_via_email(
django_assert_num_queries, monkeypatch
):
"""
If an existing user doesn't match the sub but matches the email,
the user should be returned.
"""
klass = OIDCAuthenticationBackend()
db_user = UserFactory()
def get_userinfo_mocked(*args):
return {"sub": "123", "email": db_user.email}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with django_assert_num_queries(2):
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user == db_user
def test_authentication_getter_existing_user_no_fallback_to_email(
settings, monkeypatch
):
"""
When the "OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION" setting is set to False,
the system should not match users by email, even if the email matches.
"""
klass = OIDCAuthenticationBackend()
db_user = UserFactory()
# Set the setting to False
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = False
def get_userinfo_mocked(*args):
return {"sub": "123", "email": db_user.email}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
# Since the sub doesn't match, it should create a new user
assert models.User.objects.count() == 2
assert user != db_user
assert user.sub == "123"
def test_authentication_getter_existing_user_with_email(
django_assert_num_queries, monkeypatch
):
"""
When the user's info contains an email and targets an existing user,
"""
klass = OIDCAuthenticationBackend()
user = UserFactory(full_name="John Doe", short_name="John")
def get_userinfo_mocked(*args):
return {
"sub": user.sub,
"email": user.email,
"first_name": "John",
"last_name": "Doe",
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
# Only 1 query because email and names have not changed
with django_assert_num_queries(1):
authenticated_user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user == authenticated_user
@pytest.mark.parametrize(
"first_name, last_name, email",
[
("Jack", "Doe", "john.doe@example.com"),
("John", "Duy", "john.doe@example.com"),
("John", "Doe", "jack.duy@example.com"),
("Jack", "Duy", "jack.duy@example.com"),
],
)
def test_authentication_getter_existing_user_change_fields(
first_name, last_name, email, django_assert_num_queries, monkeypatch
):
"""
It should update the email or name fields on the user when they change.
"""
klass = OIDCAuthenticationBackend()
user = UserFactory(
full_name="John Doe", short_name="John", email="john.doe@example.com"
)
def get_userinfo_mocked(*args):
return {
"sub": user.sub,
"email": email,
"first_name": first_name,
"last_name": last_name,
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
# One and only one additional update query when a field has changed
with django_assert_num_queries(2):
authenticated_user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user == authenticated_user
user.refresh_from_db()
assert user.email == email
assert user.full_name == f"{first_name:s} {last_name:s}"
assert user.short_name == first_name
def test_authentication_getter_new_user_no_email(monkeypatch):
"""
If no user matches the user's info sub, a user should be created.
@@ -180,8 +52,6 @@ def test_authentication_getter_new_user_no_email(monkeypatch):
assert user.sub == "123"
assert user.email is None
assert user.full_name is None
assert user.short_name is None
assert user.password == "!"
assert models.User.objects.count() == 1
@@ -207,13 +77,11 @@ def test_authentication_getter_new_user_with_email(monkeypatch):
assert user.sub == "123"
assert user.email == email
assert user.full_name == "John Doe"
assert user.short_name == "John"
assert user.password == "!"
assert models.User.objects.count() == 1
def test_authentication_getter_invalid_token(django_assert_num_queries, monkeypatch):
def test_models_oidc_user_getter_invalid_token(django_assert_num_queries, monkeypatch):
"""The user's info doesn't contain a sub."""
klass = OIDCAuthenticationBackend()
@@ -234,74 +102,3 @@ def test_authentication_getter_invalid_token(django_assert_num_queries, monkeypa
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
assert models.User.objects.exists() is False
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
@responses.activate
def test_authentication_get_userinfo_json_response():
"""Test get_userinfo method with a JSON response."""
responses.add(
responses.GET,
re.compile(r".*/userinfo"),
json={
"first_name": "John",
"last_name": "Doe",
"email": "john.doe@example.com",
},
status=200,
)
oidc_backend = OIDCAuthenticationBackend()
result = oidc_backend.get_userinfo("fake_access_token", None, None)
assert result["first_name"] == "John"
assert result["last_name"] == "Doe"
assert result["email"] == "john.doe@example.com"
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
@responses.activate
def test_authentication_get_userinfo_token_response(monkeypatch):
"""Test get_userinfo method with a token response."""
responses.add(
responses.GET, re.compile(r".*/userinfo"), body="fake.jwt.token", status=200
)
def mock_verify_token(self, token): # pylint: disable=unused-argument
return {
"first_name": "Jane",
"last_name": "Doe",
"email": "jane.doe@example.com",
}
monkeypatch.setattr(OIDCAuthenticationBackend, "verify_token", mock_verify_token)
oidc_backend = OIDCAuthenticationBackend()
result = oidc_backend.get_userinfo("fake_access_token", None, None)
assert result["first_name"] == "Jane"
assert result["last_name"] == "Doe"
assert result["email"] == "jane.doe@example.com"
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
@responses.activate
def test_authentication_get_userinfo_invalid_response():
"""
Test get_userinfo method with an invalid JWT response that
causes verify_token to raise an error.
"""
responses.add(
responses.GET, re.compile(r".*/userinfo"), body="fake.jwt.token", status=200
)
oidc_backend = OIDCAuthenticationBackend()
with pytest.raises(
SuspiciousOperation,
match="Invalid response format or token verification failed",
):
oidc_backend.get_userinfo("fake_access_token", None, None)
@@ -171,7 +171,7 @@ 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.email} shared a document with you: {document.title}" in email_content
assert "Invitation to join Docs!" in email_content
assert "docs/" + str(document.id) + "/" in email_content
@@ -225,5 +225,5 @@ 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.email} shared a document with you: {document.title}" in email_content
assert "Invitation to join Docs!" in email_content
assert "docs/" + str(document.id) + "/" in email_content
@@ -118,10 +118,7 @@ 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.email} shared a document with you: {document.title}"
in email_content
)
assert "Invitation to join Docs!" in email_content
else:
assert response.status_code == status.HTTP_403_FORBIDDEN
assert models.Invitation.objects.exists() is False
@@ -161,10 +158,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.email} a partagé un document avec vous: {document.title}"
in email_content
)
assert "Invitation à rejoindre Docs !" in email_content
def test_api_document_invitations__create__email_from_content_language_not_supported():
@@ -202,7 +196,7 @@ def test_api_document_invitations__create__email_from_content_language_not_suppo
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 "Invitation to join Docs!" in email_content
def test_api_document_invitations__create__issuer_and_document_override():
@@ -60,7 +60,7 @@ def test_api_document_versions_list_authenticated_unrelated(reach):
@pytest.mark.parametrize("via", VIA)
def test_api_document_versions_list_authenticated_related_success(via, mock_user_teams):
def test_api_document_versions_list_authenticated_related(via, mock_user_teams):
"""
Authenticated users should be able to list document versions for a document
to which they are directly related, whatever their role in the document.
@@ -95,12 +95,12 @@ def test_api_document_versions_list_authenticated_related_success(via, mock_user
assert response.status_code == 200
content = response.json()
assert len(content["results"]) == 0
assert content["count"] == 0
# Add a new version to the document
for i in range(3):
document.content = f"new content {i:d}"
document.save()
document.content = "new content"
document.save()
response = client.get(
f"/api/v1.0/documents/{document.id!s}/versions/",
@@ -108,100 +108,8 @@ def test_api_document_versions_list_authenticated_related_success(via, mock_user
assert response.status_code == 200
content = response.json()
# The current version is not listed
assert content["count"] == 2
@pytest.mark.parametrize("via", VIA)
def test_api_document_versions_list_authenticated_related_pagination(
via, mock_user_teams
):
"""
The list of versions should be paginated and exclude versions that were created prior to the
user gaining access to the document.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
for i in range(3):
document.content = f"before {i:d}"
document.save()
if via == USER:
models.DocumentAccess.objects.create(
document=document,
user=user,
role=random.choice(models.RoleChoices.choices)[0],
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
models.DocumentAccess.objects.create(
document=document,
team="lasuite",
role=random.choice(models.RoleChoices.choices)[0],
)
for i in range(4):
document.content = f"after {i:d}"
document.save()
response = client.get(
f"/api/v1.0/documents/{document.id!s}/versions/",
)
content = response.json()
assert content["is_truncated"] is False
# The current version is not listed
assert content["count"] == 3
assert content["next_version_id_marker"] == ""
all_version_ids = [version["version_id"] for version in content["versions"]]
# - set page size
response = client.get(
f"/api/v1.0/documents/{document.id!s}/versions/?page_size=2",
)
content = response.json()
assert content["count"] == 2
assert content["is_truncated"] is True
marker = content["next_version_id_marker"]
assert marker == all_version_ids[1]
assert [
version["version_id"] for version in content["versions"]
] == all_version_ids[:2]
# - get page 2
response = client.get(
f"/api/v1.0/documents/{document.id!s}/versions/?page_size=2&version_id={marker:s}",
)
content = response.json()
assert len(content["results"]) == 1
assert content["count"] == 1
assert content["is_truncated"] is False
assert content["next_version_id_marker"] == ""
assert content["versions"][0]["version_id"] == all_version_ids[2]
def test_api_document_versions_list_exceeds_max_page_size():
"""Page size should not exceed the limit set on the serializer"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[user])
document.content = "version 2"
document.save()
response = client.get(f"/api/v1.0/documents/{document.id!s}/versions/?page_size=51")
assert response.status_code == 400
assert response.json() == {
"page_size": ["Ensure this value is less than or equal to 50."]
}
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
@@ -211,9 +119,6 @@ def test_api_document_versions_retrieve_anonymous(reach):
restricted or authenticated link reach.
"""
document = factories.DocumentFactory(link_reach=reach)
document.content = "new content"
document.save()
version_id = document.get_versions_slice()["versions"][0]["version_id"]
url = f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/"
@@ -237,9 +142,6 @@ def test_api_document_versions_retrieve_authenticated_unrelated(reach):
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach)
document.content = "new content"
document.save()
version_id = document.get_versions_slice()["versions"][0]["version_id"]
response = client.get(
@@ -255,7 +157,7 @@ def test_api_document_versions_retrieve_authenticated_unrelated(reach):
def test_api_document_versions_retrieve_authenticated_related(via, mock_user_teams):
"""
A user who is related to a document should be allowed to retrieve the
associated document versions.
associated document user accesses.
"""
user = factories.UserFactory()
@@ -263,10 +165,6 @@ def test_api_document_versions_retrieve_authenticated_related(via, mock_user_tea
client.force_login(user)
document = factories.DocumentFactory()
document.content = "new content"
document.save()
assert len(document.get_versions_slice()["versions"]) == 1
version_id = document.get_versions_slice()["versions"][0]["version_id"]
if via == USER:
@@ -275,8 +173,6 @@ def test_api_document_versions_retrieve_authenticated_related(via, mock_user_tea
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(document=document, team="lasuite")
time.sleep(1) # minio stores datetimes with the precision of a second
# Versions created before the document was shared should not be seen by the user
response = client.get(
f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/",
@@ -284,26 +180,11 @@ def test_api_document_versions_retrieve_authenticated_related(via, mock_user_tea
assert response.status_code == 404
# Create a new version should not make it available to the user because
# only the current version is available to the user but it is excluded
# from the list
document.content = "new content 1"
# Create a new version should make it available to the user
time.sleep(1) # minio stores datetimes with the precision of a second
document.content = "new content"
document.save()
assert len(document.get_versions_slice()["versions"]) == 2
version_id = document.get_versions_slice()["versions"][0]["version_id"]
response = client.get(
f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/",
)
assert response.status_code == 404
# Adding one more version should make the previous version available to the user
document.content = "new content 2"
document.save()
assert len(document.get_versions_slice()["versions"]) == 3
version_id = document.get_versions_slice()["versions"][0]["version_id"]
response = client.get(
@@ -311,7 +192,7 @@ def test_api_document_versions_retrieve_authenticated_related(via, mock_user_tea
)
assert response.status_code == 200
assert response.json()["content"] == "new content 1"
assert response.json()["content"] == "new content"
def test_api_document_versions_create_anonymous():
@@ -379,15 +260,10 @@ def test_api_document_versions_create_authenticated_related(via, mock_user_teams
def test_api_document_versions_update_anonymous():
"""Anonymous users should not be allowed to update a document version."""
access = factories.UserDocumentAccessFactory()
document = access.document
document.content = "new content"
document.save()
assert len(document.get_versions_slice()["versions"]) == 1
version_id = document.get_versions_slice()["versions"][0]["version_id"]
version_id = access.document.get_versions_slice()["versions"][0]["version_id"]
response = APIClient().put(
f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/",
f"/api/v1.0/documents/{access.document_id!s}/versions/{version_id:s}/",
{"foo": "bar"},
format="json",
)
@@ -405,12 +281,7 @@ def test_api_document_versions_update_authenticated_unrelated():
client.force_login(user)
access = factories.UserDocumentAccessFactory()
document = access.document
document.content = "new content"
document.save()
assert len(document.get_versions_slice()["versions"]) == 1
version_id = document.get_versions_slice()["versions"][0]["version_id"]
version_id = access.document.get_versions_slice()["versions"][0]["version_id"]
response = client.put(
f"/api/v1.0/documents/{access.document_id!s}/versions/{version_id:s}/",
@@ -432,6 +303,7 @@ def test_api_document_versions_update_authenticated_related(via, mock_user_teams
client.force_login(user)
document = factories.DocumentFactory()
version_id = document.get_versions_slice()["versions"][0]["version_id"]
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user)
@@ -439,14 +311,6 @@ def test_api_document_versions_update_authenticated_related(via, mock_user_teams
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(document=document, team="lasuite")
time.sleep(1) # minio stores datetimes with the precision of a second
document.content = "new content"
document.save()
assert len(document.get_versions_slice()["versions"]) == 1
version_id = document.get_versions_slice()["versions"][0]["version_id"]
response = client.put(
f"/api/v1.0/documents/{document.id!s}/versions/{version_id!s}/",
{"foo": "bar"},
@@ -481,9 +345,6 @@ def test_api_document_versions_delete_authenticated(reach):
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach)
document.content = "new content"
document.save()
version_id = document.get_versions_slice()["versions"][0]["version_id"]
response = client.delete(
@@ -520,7 +381,13 @@ def test_api_document_versions_delete_reader_or_editor(via, role, mock_user_team
document.save()
versions = document.get_versions_slice()["versions"]
assert len(versions) == 1
assert len(versions) == 2
version_id = versions[1]["version_id"]
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/",
)
assert response.status_code == 403
version_id = versions[0]["version_id"]
response = client.delete(
@@ -529,7 +396,7 @@ def test_api_document_versions_delete_reader_or_editor(via, role, mock_user_team
assert response.status_code == 403
versions = document.get_versions_slice()["versions"]
assert len(versions) == 1
assert len(versions) == 2
@pytest.mark.parametrize("via", VIA)
@@ -554,25 +421,19 @@ def test_api_document_versions_delete_administrator_or_owner(via, mock_user_team
# Create a new version should make it available to the user
time.sleep(1) # minio stores datetimes with the precision of a second
document.content = "new content 1"
document.content = "new content"
document.save()
versions = document.get_versions_slice()["versions"]
assert len(versions) == 1
assert len(versions) == 2
version_id = versions[0]["version_id"]
version_id = versions[1]["version_id"]
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/",
)
# 404 because the version was created before the user was given access to the document
assert response.status_code == 404
document.content = "new content 2"
document.save()
versions = document.get_versions_slice()["versions"]
assert len(versions) == 2
version_id = versions[0]["version_id"]
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/",
@@ -33,6 +33,7 @@ def test_api_documents_retrieve_anonymous_public():
"versions_retrieve": False,
},
"accesses": [],
"is_e2ee": False,
"link_reach": "public",
"link_role": document.link_role,
"title": document.title,
@@ -87,6 +88,7 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
"versions_retrieve": False,
},
"accesses": [],
"is_e2ee": False,
"link_reach": reach,
"link_role": document.link_role,
"title": document.title,
@@ -194,6 +196,7 @@ def test_api_documents_retrieve_authenticated_related_direct():
"title": document.title,
"content": document.content,
"abilities": document.get_abilities(user),
"is_e2ee": False,
"link_reach": document.link_reach,
"link_role": document.link_role,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
@@ -332,6 +335,7 @@ def test_api_documents_retrieve_authenticated_related_team_members(
"title": document.title,
"content": document.content,
"abilities": document.get_abilities(user),
"is_e2ee": False,
"link_reach": "restricted",
"link_role": document.link_role,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
@@ -452,6 +456,7 @@ def test_api_documents_retrieve_authenticated_related_team_administrators(
"title": document.title,
"content": document.content,
"abilities": document.get_abilities(user),
"is_e2ee": False,
"link_reach": "restricted",
"link_role": document.link_role,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
@@ -576,6 +581,7 @@ def test_api_documents_retrieve_authenticated_related_team_owners(
"title": document.title,
"content": document.content,
"abilities": document.get_abilities(user),
"is_e2ee": False,
"link_reach": "restricted",
"link_role": document.link_role,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
-2
View File
@@ -120,8 +120,6 @@ def test_api_users_retrieve_me_authenticated():
assert response.json() == {
"id": str(user.id),
"email": user.email,
"full_name": user.full_name,
"short_name": user.short_name,
}
+3 -124
View File
@@ -2,15 +2,9 @@
Unit tests for the Document model
"""
import smtplib
from logging import Logger
from unittest import mock
from django.contrib.auth.models import AnonymousUser
from django.core import mail
from django.core.exceptions import ValidationError
from django.core.files.storage import default_storage
from django.utils import timezone
import pytest
@@ -261,7 +255,7 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
}
def test_models_documents_get_versions_slice_pagination(settings):
def test_models_documents_get_versions_slice(settings):
"""
The "get_versions_slice" method should allow navigating all versions of
the document with pagination.
@@ -274,7 +268,7 @@ def test_models_documents_get_versions_slice_pagination(settings):
document.content = f"bar{i:d}"
document.save()
# Add a document version not related to the first document
# Add a version not related to the first document
factories.DocumentFactory()
# - Get default max versions
@@ -292,7 +286,7 @@ def test_models_documents_get_versions_slice_pagination(settings):
from_version_id=response["next_version_id_marker"]
)
assert response["is_truncated"] is False
assert len(response["versions"]) == 2
assert len(response["versions"]) == 3
assert response["next_version_id_marker"] == ""
# - Get custom max versions
@@ -302,30 +296,6 @@ def test_models_documents_get_versions_slice_pagination(settings):
assert response["next_version_id_marker"] != ""
def test_models_documents_get_versions_slice_min_datetime():
"""
The "get_versions_slice" method should filter out versions anterior to
the from_datetime passed in argument and the current version.
"""
document = factories.DocumentFactory()
from_dt = []
for i in range(6):
from_dt.append(timezone.now())
document.content = f"bar{i:d}"
document.save()
response = document.get_versions_slice(min_datetime=from_dt[2])
assert len(response["versions"]) == 3
for version in response["versions"]:
assert version["last_modified"] > from_dt[2]
response = document.get_versions_slice(min_datetime=from_dt[4])
assert len(response["versions"]) == 1
assert response["versions"][0]["last_modified"] > from_dt[4]
def test_models_documents_version_duplicate():
"""A new version should be created in object storage only if the content has changed."""
document = factories.DocumentFactory()
@@ -352,94 +322,3 @@ def test_models_documents_version_duplicate():
Bucket=default_storage.bucket_name, Prefix=file_key
)
assert len(response["Versions"]) == 2
def test_models_documents__email_invitation__success():
"""
The email invitation is sent successfully.
"""
document = factories.DocumentFactory()
# pylint: disable-next=no-member
assert len(mail.outbox) == 0
document.email_invitation(
"en", "guest@example.com", models.RoleChoices.EDITOR, "sender@example.com"
)
# pylint: disable-next=no-member
assert len(mail.outbox) == 1
# pylint: disable-next=no-member
email = mail.outbox[0]
assert email.to == ["guest@example.com"]
email_content = " ".join(email.body.split())
assert (
f"sender@example.com invited you as an editor on the following document : {document.title}"
in email_content
)
assert f"docs/{document.id}/" in email_content
def test_models_documents__email_invitation__success_fr():
"""
The email invitation is sent successfully in french.
"""
document = factories.DocumentFactory()
# pylint: disable-next=no-member
assert len(mail.outbox) == 0
document.email_invitation(
"fr-fr", "guest2@example.com", models.RoleChoices.OWNER, "sender2@example.com"
)
# pylint: disable-next=no-member
assert len(mail.outbox) == 1
# pylint: disable-next=no-member
email = mail.outbox[0]
assert email.to == ["guest2@example.com"]
email_content = " ".join(email.body.split())
assert (
f"sender2@example.com vous a invité en tant que propriétaire "
f"sur le document suivant : {document.title}" in email_content
)
assert f"docs/{document.id}/" in email_content
@mock.patch(
"core.models.send_mail",
side_effect=smtplib.SMTPException("Error SMTPException"),
)
@mock.patch.object(Logger, "error")
def test_models_documents__email_invitation__failed(mock_logger, _mock_send_mail):
"""Check mail behavior when an SMTP error occurs when sent an email invitation."""
document = factories.DocumentFactory()
# pylint: disable-next=no-member
assert len(mail.outbox) == 0
document.email_invitation(
"en", "guest3@example.com", models.RoleChoices.ADMIN, "sender3@example.com"
)
# No email has been sent
# pylint: disable-next=no-member
assert len(mail.outbox) == 0
# Logger should be called
mock_logger.assert_called_once()
(
_,
email,
exception,
) = mock_logger.call_args.args
assert email == "guest3@example.com"
assert isinstance(exception, smtplib.SMTPException)
@@ -3,7 +3,6 @@ Unit tests for the Template model
"""
import os
import time
from unittest import mock
from django.contrib.auth.models import AnonymousUser
@@ -204,7 +203,7 @@ def test_models_templates__generate_word():
"pypandoc.convert_text",
side_effect=RuntimeError("Conversion failed"),
)
def test_models_templates__generate_word__raise_error(_mock_pypandoc):
def test_models_templates__generate_word__raise_error(_mock_send_mail):
"""
Generate word document and assert no tmp files are left in /tmp folder
even when the conversion fails.
@@ -215,5 +214,4 @@ def test_models_templates__generate_word__raise_error(_mock_pypandoc):
template.generate_word("<p>Test body</p>", {})
except RuntimeError as e:
assert str(e) == "Conversion failed"
time.sleep(0.5)
assert len([f for f in os.listdir("/tmp") if f.startswith("docx_")]) == 0
+87
View File
@@ -0,0 +1,87 @@
"""
Unit tests for the Invitation model
"""
import smtplib
from logging import Logger
from unittest import mock
from django.core import mail
import pytest
from core.utils import email_invitation
pytestmark = pytest.mark.django_db
def test_utils__email_invitation_success():
"""
The email invitation is sent successfully.
"""
# pylint: disable-next=no-member
assert len(mail.outbox) == 0
email_invitation("en", "guest@example.com", "123-456-789")
# pylint: disable-next=no-member
assert len(mail.outbox) == 1
# pylint: disable-next=no-member
email = mail.outbox[0]
assert email.to == ["guest@example.com"]
email_content = " ".join(email.body.split())
assert "Invitation to join Docs!" in email_content
assert "docs/123-456-789/" in email_content
def test_utils__email_invitation_success_fr():
"""
The email invitation is sent successfully in french.
"""
# pylint: disable-next=no-member
assert len(mail.outbox) == 0
email_invitation("fr-fr", "guest@example.com", "123-456-789")
# pylint: disable-next=no-member
assert len(mail.outbox) == 1
# pylint: disable-next=no-member
email = mail.outbox[0]
assert email.to == ["guest@example.com"]
email_content = " ".join(email.body.split())
assert "Invitation à rejoindre Docs !" in email_content
assert "docs/123-456-789/" in email_content
@mock.patch(
"core.utils.send_mail",
side_effect=smtplib.SMTPException("Error SMTPException"),
)
@mock.patch.object(Logger, "error")
def test_utils__email_invitation_failed(mock_logger, _mock_send_mail):
"""Check mail behavior when an SMTP error occurs when sent an email invitation."""
# pylint: disable-next=no-member
assert len(mail.outbox) == 0
email_invitation("en", "guest@example.com", "123-456-789")
# No email has been sent
# pylint: disable-next=no-member
assert len(mail.outbox) == 0
# Logger should be called
mock_logger.assert_called_once()
(
_,
email,
exception,
) = mock_logger.call_args.args
assert email == "guest@example.com"
assert isinstance(exception, smtplib.SMTPException)
+40
View File
@@ -0,0 +1,40 @@
"""
Utilities for the core app.
"""
import smtplib
from logging import getLogger
from django.conf import settings
from django.contrib.sites.models import Site
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.utils.translation import gettext_lazy as _
from django.utils.translation import override
logger = getLogger(__name__)
def email_invitation(language, email, document_id):
"""Send email invitation."""
try:
with override(language):
title = _("Invitation to join Docs!")
template_vars = {
"title": title,
"site": Site.objects.get_current(),
"document_id": document_id,
}
msg_html = render_to_string("mail/html/invitation.html", template_vars)
msg_plain = render_to_string("mail/text/invitation.txt", template_vars)
send_mail(
title,
msg_plain,
settings.EMAIL_FROM,
[email],
html_message=msg_html,
fail_silently=False,
)
except smtplib.SMTPException as exception:
logger.error("invitation to %s was not sent: %s", email, exception)
@@ -2,7 +2,6 @@
"""create_demo management command"""
import logging
import math
import random
import time
from collections import defaultdict
@@ -112,11 +111,7 @@ def create_demo(stdout):
queue = BulkQueue(stdout)
with Timeit(stdout, "Creating users"):
name_size = int(math.sqrt(defaults.NB_OBJECTS["users"]))
first_names = [fake.first_name() for _ in range(name_size)]
last_names = [fake.last_name() for _ in range(name_size)]
for i in range(defaults.NB_OBJECTS["users"]):
first_name = random.choice(first_names)
queue.push(
models.User(
admin_email=f"user{i:d}@example.com",
@@ -125,8 +120,6 @@ def create_demo(stdout):
is_superuser=False,
is_active=True,
is_staff=False,
short_name=first_name,
full_name=f"{first_name:s} {random.choice(last_names):s}",
language=random.choice(settings.LANGUAGES)[0],
)
)
-25
View File
@@ -384,27 +384,10 @@ class Base(Configuration):
OIDC_STORE_ID_TOKEN = values.BooleanValue(
default=True, environ_name="OIDC_STORE_ID_TOKEN", environ_prefix=None
)
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = values.BooleanValue(
default=True,
environ_name="OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION",
environ_prefix=None,
)
ALLOW_LOGOUT_GET_METHOD = values.BooleanValue(
default=True, environ_name="ALLOW_LOGOUT_GET_METHOD", environ_prefix=None
)
USER_OIDC_FIELDS_TO_FULLNAME = values.ListValue(
default=["first_name", "last_name"],
environ_name="USER_OIDC_FIELDS_TO_FULLNAME",
environ_prefix=None,
)
USER_OIDC_FIELD_TO_SHORTNAME = values.Value(
default="first_name",
environ_name="USER_OIDC_FIELD_TO_SHORTNAME",
environ_prefix=None,
)
# pylint: disable=invalid-name
@property
def ENVIRONMENT(self):
@@ -563,14 +546,6 @@ class Production(Base):
# In other cases, you should comment the following line to avoid security issues.
# SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SECURE_HSTS_SECONDS = 60
SECURE_HSTS_PRELOAD = True
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_SSL_REDIRECT = True
SECURE_REDIRECT_EXEMPT = [
"^__lbheartbeat__",
"^__heartbeat__",
]
# Modern browsers require to have the `secure` attribute on cookies with `Samesite=none`
CSRF_COOKIE_SECURE = True
Binary file not shown.
+384 -167
View File
@@ -2,8 +2,8 @@ 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:17\n"
"POT-Creation-Date: 2024-08-14 12:43+0000\n"
"PO-Revision-Date: 2024-08-14 12:48\n"
"Last-Translator: \n"
"Language-Team: English\n"
"Language: en_US\n"
@@ -17,330 +17,547 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 8\n"
#: core/admin.py:32
#: build/lib/build/lib/build/lib/build/lib/core/admin.py:31
#: build/lib/build/lib/build/lib/core/admin.py:31
#: build/lib/build/lib/core/admin.py:31 build/lib/core/admin.py:31
#: core/admin.py:31
msgid "Personal info"
msgstr ""
#: core/admin.py:34
#: build/lib/build/lib/build/lib/build/lib/core/admin.py:33
#: build/lib/build/lib/build/lib/core/admin.py:33
#: build/lib/build/lib/core/admin.py:33 build/lib/core/admin.py:33
#: core/admin.py:33
msgid "Permissions"
msgstr ""
#: core/admin.py:46
#: build/lib/build/lib/build/lib/build/lib/core/admin.py:45
#: build/lib/build/lib/build/lib/core/admin.py:45
#: build/lib/build/lib/core/admin.py:45 build/lib/core/admin.py:45
#: core/admin.py:45
msgid "Important dates"
msgstr ""
#: core/api/serializers.py:253
#: build/lib/build/lib/build/lib/build/lib/core/api/serializers.py:176
#: build/lib/build/lib/build/lib/core/api/serializers.py:176
#: build/lib/build/lib/core/api/serializers.py:176
#: build/lib/core/api/serializers.py:176 core/api/serializers.py:176
msgid "Body"
msgstr ""
#: core/api/serializers.py:256
#: build/lib/build/lib/build/lib/build/lib/core/api/serializers.py:179
#: build/lib/build/lib/build/lib/core/api/serializers.py:179
#: build/lib/build/lib/core/api/serializers.py:179
#: build/lib/core/api/serializers.py:179 core/api/serializers.py:179
msgid "Body type"
msgstr ""
#: core/api/serializers.py:262
msgid "Format"
msgstr ""
#: core/authentication/backends.py:56
msgid "Invalid response format or token verification failed"
msgstr ""
#: core/authentication/backends.py:81
#: build/lib/build/lib/build/lib/build/lib/core/authentication/backends.py:71
#: build/lib/build/lib/build/lib/core/authentication/backends.py:71
#: build/lib/build/lib/core/authentication/backends.py:71
#: build/lib/core/authentication/backends.py:71
#: core/authentication/backends.py:71
msgid "User info contained no recognizable user identification"
msgstr ""
#: core/authentication/backends.py:101
#: build/lib/build/lib/build/lib/build/lib/core/authentication/backends.py:91
#: build/lib/build/lib/build/lib/core/authentication/backends.py:91
#: build/lib/build/lib/core/authentication/backends.py:91
#: build/lib/core/authentication/backends.py:91
#: core/authentication/backends.py:91
msgid "Claims contained no recognizable user identification"
msgstr ""
#: core/models.py:62 core/models.py:69
#: build/lib/build/lib/build/lib/build/lib/core/models.py:60
#: build/lib/build/lib/build/lib/core/models.py:60
#: build/lib/build/lib/core/models.py:60 build/lib/core/models.py:60
#: core/models.py:61
msgid "Reader"
msgstr ""
#: core/models.py:63 core/models.py:70
#: build/lib/build/lib/build/lib/build/lib/core/models.py:61
#: build/lib/build/lib/build/lib/core/models.py:61
#: build/lib/build/lib/core/models.py:61 build/lib/core/models.py:61
#: core/models.py:62
msgid "Editor"
msgstr ""
#: core/models.py:71
#: build/lib/build/lib/build/lib/build/lib/core/models.py:62
#: build/lib/build/lib/build/lib/core/models.py:62
#: build/lib/build/lib/core/models.py:62 build/lib/core/models.py:62
#: core/models.py:63
msgid "Administrator"
msgstr ""
#: core/models.py:72
#: build/lib/build/lib/build/lib/build/lib/core/models.py:63
#: build/lib/build/lib/build/lib/core/models.py:63
#: build/lib/build/lib/core/models.py:63 build/lib/core/models.py:63
#: core/models.py:64
msgid "Owner"
msgstr ""
#: core/models.py:80
msgid "Restricted"
msgstr ""
#: core/models.py:84
msgid "Authenticated"
msgstr ""
#: core/models.py:86
msgid "Public"
msgstr ""
#: core/models.py:98
#: build/lib/build/lib/build/lib/build/lib/core/models.py:75
#: build/lib/build/lib/build/lib/core/models.py:75
#: build/lib/build/lib/core/models.py:75 build/lib/core/models.py:75
#: core/models.py:76
msgid "id"
msgstr ""
#: core/models.py:99
#: build/lib/build/lib/build/lib/build/lib/core/models.py:76
#: build/lib/build/lib/build/lib/core/models.py:76
#: build/lib/build/lib/core/models.py:76 build/lib/core/models.py:76
#: core/models.py:77
msgid "primary key for the record as UUID"
msgstr ""
#: core/models.py:105
#: build/lib/build/lib/build/lib/build/lib/core/models.py:82
#: build/lib/build/lib/build/lib/core/models.py:82
#: build/lib/build/lib/core/models.py:82 build/lib/core/models.py:82
#: core/models.py:83
msgid "created on"
msgstr ""
#: core/models.py:106
#: build/lib/build/lib/build/lib/build/lib/core/models.py:83
#: build/lib/build/lib/build/lib/core/models.py:83
#: build/lib/build/lib/core/models.py:83 build/lib/core/models.py:83
#: core/models.py:84
msgid "date and time at which a record was created"
msgstr ""
#: core/models.py:111
#: build/lib/build/lib/build/lib/build/lib/core/models.py:88
#: build/lib/build/lib/build/lib/core/models.py:88
#: build/lib/build/lib/core/models.py:88 build/lib/core/models.py:88
#: core/models.py:89
msgid "updated on"
msgstr ""
#: core/models.py:112
#: build/lib/build/lib/build/lib/build/lib/core/models.py:89
#: build/lib/build/lib/build/lib/core/models.py:89
#: build/lib/build/lib/core/models.py:89 build/lib/core/models.py:89
#: core/models.py:90
msgid "date and time at which a record was last updated"
msgstr ""
#: core/models.py:132
#: build/lib/build/lib/build/lib/build/lib/core/models.py:109
#: build/lib/build/lib/build/lib/core/models.py:109
#: build/lib/build/lib/core/models.py:109 build/lib/core/models.py:109
#: core/models.py:110
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters."
msgstr ""
#: core/models.py:138
#: build/lib/build/lib/build/lib/build/lib/core/models.py:115
#: build/lib/build/lib/build/lib/core/models.py:115
#: build/lib/build/lib/core/models.py:115 build/lib/core/models.py:115
#: core/models.py:116
msgid "sub"
msgstr ""
#: core/models.py:140
#: build/lib/build/lib/build/lib/build/lib/core/models.py:117
#: build/lib/build/lib/build/lib/core/models.py:117
#: build/lib/build/lib/core/models.py:117 build/lib/core/models.py:117
#: core/models.py:118
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
msgstr ""
#: core/models.py:148
#: build/lib/build/lib/build/lib/build/lib/core/models.py:125
#: build/lib/build/lib/build/lib/core/models.py:125
#: build/lib/build/lib/core/models.py:125 build/lib/core/models.py:125
#: core/models.py:126
msgid "identity email address"
msgstr ""
#: core/models.py:153
#: build/lib/build/lib/build/lib/build/lib/core/models.py:130
#: build/lib/build/lib/build/lib/core/models.py:130
#: build/lib/build/lib/core/models.py:130 build/lib/core/models.py:130
#: core/models.py:131
msgid "admin email address"
msgstr ""
#: core/models.py:160
#: build/lib/build/lib/build/lib/build/lib/core/models.py:137
#: build/lib/build/lib/build/lib/core/models.py:137
#: build/lib/build/lib/core/models.py:137 build/lib/core/models.py:137
#: core/models.py:138
msgid "language"
msgstr ""
#: core/models.py:161
#: build/lib/build/lib/build/lib/build/lib/core/models.py:138
#: build/lib/build/lib/build/lib/core/models.py:138
#: build/lib/build/lib/core/models.py:138 build/lib/core/models.py:138
#: core/models.py:139
msgid "The language in which the user wants to see the interface."
msgstr ""
#: core/models.py:167
#: build/lib/build/lib/build/lib/build/lib/core/models.py:144
#: build/lib/build/lib/build/lib/core/models.py:144
#: build/lib/build/lib/core/models.py:144 build/lib/core/models.py:144
#: core/models.py:145
msgid "The timezone in which the user wants to see times."
msgstr ""
#: core/models.py:170
#: build/lib/build/lib/build/lib/build/lib/core/models.py:147
#: build/lib/build/lib/build/lib/core/models.py:147
#: build/lib/build/lib/core/models.py:147 build/lib/core/models.py:147
#: core/models.py:148
msgid "device"
msgstr ""
#: core/models.py:172
#: build/lib/build/lib/build/lib/build/lib/core/models.py:149
#: build/lib/build/lib/build/lib/core/models.py:149
#: build/lib/build/lib/core/models.py:149 build/lib/core/models.py:149
#: core/models.py:150
msgid "Whether the user is a device or a real user."
msgstr ""
#: core/models.py:175
#: build/lib/build/lib/build/lib/build/lib/core/models.py:152
#: build/lib/build/lib/build/lib/core/models.py:152
#: build/lib/build/lib/core/models.py:152 build/lib/core/models.py:152
#: core/models.py:153
msgid "staff status"
msgstr ""
#: core/models.py:177
#: build/lib/build/lib/build/lib/build/lib/core/models.py:154
#: build/lib/build/lib/build/lib/core/models.py:154
#: build/lib/build/lib/core/models.py:154 build/lib/core/models.py:154
#: core/models.py:155
msgid "Whether the user can log into this admin site."
msgstr ""
#: core/models.py:180
#: build/lib/build/lib/build/lib/build/lib/core/models.py:157
#: build/lib/build/lib/build/lib/core/models.py:157
#: build/lib/build/lib/core/models.py:157 build/lib/core/models.py:157
#: core/models.py:158
msgid "active"
msgstr ""
#: core/models.py:183
#: build/lib/build/lib/build/lib/build/lib/core/models.py:160
#: build/lib/build/lib/build/lib/core/models.py:160
#: build/lib/build/lib/core/models.py:160 build/lib/core/models.py:160
#: core/models.py:161
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: core/models.py:195
#: build/lib/build/lib/build/lib/build/lib/core/models.py:172
#: build/lib/build/lib/build/lib/core/models.py:172
#: build/lib/build/lib/core/models.py:172 build/lib/core/models.py:172
#: core/models.py:173
msgid "user"
msgstr ""
#: core/models.py:196
#: build/lib/build/lib/build/lib/build/lib/core/models.py:173
#: build/lib/build/lib/build/lib/core/models.py:173
#: build/lib/build/lib/core/models.py:173 build/lib/core/models.py:173
#: core/models.py:174
msgid "users"
msgstr ""
#: core/models.py:328 core/models.py:644
#: build/lib/build/lib/build/lib/build/lib/core/models.py:304
#: build/lib/build/lib/build/lib/build/lib/core/models.py:531
#: build/lib/build/lib/build/lib/core/models.py:304
#: build/lib/build/lib/build/lib/core/models.py:531
#: build/lib/build/lib/core/models.py:304
#: build/lib/build/lib/core/models.py:531 build/lib/core/models.py:304
#: build/lib/core/models.py:531 core/models.py:305 core/models.py:532
msgid "title"
msgstr ""
#: core/models.py:343
msgid "Document"
msgstr ""
#: core/models.py:344
msgid "Documents"
msgstr ""
#: core/models.py:347
msgid "Untitled Document"
msgstr ""
#: core/models.py:537
#, python-format
msgid "%(username)s shared a document with you: %(document)s"
msgstr ""
#: core/models.py:580
msgid "Document/user link trace"
msgstr ""
#: core/models.py:581
msgid "Document/user link traces"
msgstr ""
#: core/models.py:587
msgid "A link trace already exists for this document/user."
msgstr ""
#: core/models.py:608
msgid "Document/user relation"
msgstr ""
#: core/models.py:609
msgid "Document/user relations"
msgstr ""
#: core/models.py:615
msgid "This user is already in this document."
msgstr ""
#: core/models.py:621
msgid "This team is already in this document."
msgstr ""
#: core/models.py:627 core/models.py:816
msgid "Either user or team must be set, not both."
msgstr ""
#: core/models.py:645
msgid "description"
msgstr ""
#: core/models.py:646
msgid "code"
msgstr ""
#: core/models.py:647
msgid "css"
msgstr ""
#: core/models.py:649
#: build/lib/build/lib/build/lib/build/lib/core/models.py:306
#: build/lib/build/lib/build/lib/build/lib/core/models.py:536
#: build/lib/build/lib/build/lib/core/models.py:306
#: build/lib/build/lib/build/lib/core/models.py:536
#: build/lib/build/lib/core/models.py:306
#: build/lib/build/lib/core/models.py:536 build/lib/core/models.py:306
#: build/lib/core/models.py:536 core/models.py:307 core/models.py:537
msgid "public"
msgstr ""
#: core/models.py:651
#: build/lib/build/lib/build/lib/build/lib/core/models.py:308
#: build/lib/build/lib/build/lib/core/models.py:308
#: build/lib/build/lib/core/models.py:308 build/lib/core/models.py:308
#: core/models.py:309
msgid "Whether this document is public for anyone to use."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:316
#: build/lib/build/lib/build/lib/core/models.py:316
#: build/lib/build/lib/core/models.py:316 build/lib/core/models.py:316
#: core/models.py:317
msgid "Document"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:317
#: build/lib/build/lib/build/lib/core/models.py:317
#: build/lib/build/lib/core/models.py:317 build/lib/core/models.py:317
#: core/models.py:318
msgid "Documents"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:495
#: build/lib/build/lib/build/lib/core/models.py:495
#: build/lib/build/lib/core/models.py:495 build/lib/core/models.py:495
#: core/models.py:496
msgid "Document/user relation"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:496
#: build/lib/build/lib/build/lib/core/models.py:496
#: build/lib/build/lib/core/models.py:496 build/lib/core/models.py:496
#: core/models.py:497
msgid "Document/user relations"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:502
#: build/lib/build/lib/build/lib/core/models.py:502
#: build/lib/build/lib/core/models.py:502 build/lib/core/models.py:502
#: core/models.py:503
msgid "This user is already in this document."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:508
#: build/lib/build/lib/build/lib/core/models.py:508
#: build/lib/build/lib/core/models.py:508 build/lib/core/models.py:508
#: core/models.py:509
msgid "This team is already in this document."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:514
#: build/lib/build/lib/build/lib/build/lib/core/models.py:691
#: build/lib/build/lib/build/lib/core/models.py:514
#: build/lib/build/lib/build/lib/core/models.py:691
#: build/lib/build/lib/core/models.py:514
#: build/lib/build/lib/core/models.py:691 build/lib/core/models.py:514
#: build/lib/core/models.py:691 core/models.py:515 core/models.py:704
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:532
#: build/lib/build/lib/build/lib/core/models.py:532
#: build/lib/build/lib/core/models.py:532 build/lib/core/models.py:532
#: core/models.py:533
msgid "description"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:533
#: build/lib/build/lib/build/lib/core/models.py:533
#: build/lib/build/lib/core/models.py:533 build/lib/core/models.py:533
#: core/models.py:534
msgid "code"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:534
#: build/lib/build/lib/build/lib/core/models.py:534
#: build/lib/build/lib/core/models.py:534 build/lib/core/models.py:534
#: core/models.py:535
msgid "css"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:538
#: build/lib/build/lib/build/lib/core/models.py:538
#: build/lib/build/lib/core/models.py:538 build/lib/core/models.py:538
#: core/models.py:539
msgid "Whether this template is public for anyone to use."
msgstr ""
#: core/models.py:657
#: build/lib/build/lib/build/lib/build/lib/core/models.py:544
#: build/lib/build/lib/build/lib/core/models.py:544
#: build/lib/build/lib/core/models.py:544 build/lib/core/models.py:544
#: core/models.py:545
msgid "Template"
msgstr ""
#: core/models.py:658
#: build/lib/build/lib/build/lib/build/lib/core/models.py:545
#: build/lib/build/lib/build/lib/core/models.py:545
#: build/lib/build/lib/core/models.py:545 build/lib/core/models.py:545
#: core/models.py:546
msgid "Templates"
msgstr ""
#: core/models.py:797
#: build/lib/build/lib/build/lib/build/lib/core/models.py:672
#: build/lib/build/lib/build/lib/core/models.py:672
#: build/lib/build/lib/core/models.py:672 build/lib/core/models.py:672
#: core/models.py:685
msgid "Template/user relation"
msgstr ""
#: core/models.py:798
#: build/lib/build/lib/build/lib/build/lib/core/models.py:673
#: build/lib/build/lib/build/lib/core/models.py:673
#: build/lib/build/lib/core/models.py:673 build/lib/core/models.py:673
#: core/models.py:686
msgid "Template/user relations"
msgstr ""
#: core/models.py:804
#: build/lib/build/lib/build/lib/build/lib/core/models.py:679
#: build/lib/build/lib/build/lib/core/models.py:679
#: build/lib/build/lib/core/models.py:679 build/lib/core/models.py:679
#: core/models.py:692
msgid "This user is already in this template."
msgstr ""
#: core/models.py:810
#: build/lib/build/lib/build/lib/build/lib/core/models.py:685
#: build/lib/build/lib/build/lib/core/models.py:685
#: build/lib/build/lib/core/models.py:685 build/lib/core/models.py:685
#: core/models.py:698
msgid "This team is already in this template."
msgstr ""
#: core/models.py:833
#: build/lib/build/lib/build/lib/build/lib/core/models.py:708
#: build/lib/build/lib/build/lib/core/models.py:708
#: build/lib/build/lib/core/models.py:708 build/lib/core/models.py:708
#: core/models.py:721
msgid "email address"
msgstr ""
#: core/models.py:850
#: build/lib/build/lib/build/lib/build/lib/core/models.py:725
#: build/lib/build/lib/build/lib/core/models.py:725
#: build/lib/build/lib/core/models.py:725 build/lib/core/models.py:725
#: core/models.py:738
msgid "Document invitation"
msgstr ""
#: core/models.py:851
#: build/lib/build/lib/build/lib/build/lib/core/models.py:726
#: build/lib/build/lib/build/lib/core/models.py:726
#: build/lib/build/lib/core/models.py:726 build/lib/core/models.py:726
#: core/models.py:739
msgid "Document invitations"
msgstr ""
#: core/models.py:868
#: build/lib/build/lib/build/lib/build/lib/core/models.py:751
#: build/lib/build/lib/build/lib/core/models.py:751
#: build/lib/build/lib/core/models.py:751 build/lib/core/models.py:751
#: core/models.py:764
msgid "This email is already associated to a registered user."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:795
#: build/lib/build/lib/build/lib/core/models.py:795
#: build/lib/build/lib/core/models.py:795 build/lib/core/models.py:795
msgid "Invitation to join Impress!"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/impress/settings.py:158
#: build/lib/build/lib/build/lib/impress/settings.py:158
#: build/lib/build/lib/impress/settings.py:158
#: build/lib/impress/settings.py:158 impress/settings.py:158
msgid "English"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/impress/settings.py:159
#: build/lib/build/lib/build/lib/impress/settings.py:159
#: build/lib/build/lib/impress/settings.py:159
#: build/lib/impress/settings.py:159 impress/settings.py:159
msgid "French"
msgstr ""
#: build/lib/build/lib/core/api/serializers.py:185
#: build/lib/core/api/serializers.py:185 core/api/serializers.py:185
msgid "Format"
msgstr ""
#: core/models.py:808
msgid "Invitation to join Docs!"
msgstr ""
#: core/templates/mail/html/hello.html:159 core/templates/mail/text/hello.txt:3
msgid "Company logo"
msgstr ""
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
#, python-format
msgid "Hello %(name)s"
msgstr ""
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
msgid "Hello"
msgstr ""
#: core/templates/mail/html/hello.html:189 core/templates/mail/text/hello.txt:6
msgid "Thank you very much for your visit!"
msgstr ""
#: core/templates/mail/html/hello.html:221
#, python-format
msgid "This mail has been sent to %(email)s by <a href=\"%(href)s\">%(name)s</a>"
msgstr ""
#: core/templates/mail/html/invitation.html:160
#: core/templates/mail/html/invitation2.html:160
#: core/templates/mail/text/invitation.txt:3
#: core/templates/mail/text/invitation2.txt:3
msgid "La Suite Numérique"
msgstr ""
#: core/templates/mail/html/invitation.html:190
#: core/templates/mail/text/invitation.txt:6
#, python-format
msgid " %(username)s shared a document with you ! "
#: core/templates/mail/text/invitation.txt:5
msgid "Invitation to join a document !"
msgstr ""
#: core/templates/mail/html/invitation.html:197
#: core/templates/mail/text/invitation.txt:8
#, python-format
msgid " %(username)s invited you as an %(role)s on the following document : "
#: core/templates/mail/html/invitation.html:198
msgid "Welcome to <strong>Docs!</strong>"
msgstr ""
#: core/templates/mail/html/invitation.html:206
#: core/templates/mail/html/invitation2.html:211
#: core/templates/mail/text/invitation.txt:10
#: core/templates/mail/text/invitation2.txt:11
msgid "Open"
#: core/templates/mail/html/invitation.html:213
#: core/templates/mail/text/invitation.txt:12
msgid "We are delighted to welcome you to our community on Docs, your new companion to collaborate on documents efficiently, intuitively, and securely."
msgstr ""
#: core/templates/mail/html/invitation.html:218
#: core/templates/mail/text/invitation.txt:13
msgid "Our application is designed to help you organize, collaborate, and manage permissions."
msgstr ""
#: core/templates/mail/html/invitation.html:223
#: core/templates/mail/text/invitation.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborate on your documents as a team. "
msgid "With Docs, you will be able to:"
msgstr ""
#: core/templates/mail/html/invitation.html:230
#: core/templates/mail/html/invitation2.html:235
#: core/templates/mail/html/invitation.html:224
#: core/templates/mail/text/invitation.txt:15
msgid "Create documents."
msgstr ""
#: core/templates/mail/html/invitation.html:225
#: core/templates/mail/text/invitation.txt:16
#: core/templates/mail/text/invitation2.txt:17
msgid "Brought to you by La Suite Numérique"
msgid "Work offline."
msgstr ""
#: core/templates/mail/html/invitation2.html:190
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:17
msgid "Invite members of your community to your document in just a few clicks."
msgstr ""
#: core/templates/mail/html/invitation.html:237
#: core/templates/mail/text/invitation.txt:19
msgid "Visit Docs"
msgstr ""
#: core/templates/mail/html/invitation.html:246
#: core/templates/mail/text/invitation.txt:21
msgid "We are confident that Docs will help you increase efficiency and productivity while strengthening the bond among members."
msgstr ""
#: core/templates/mail/html/invitation.html:251
#: core/templates/mail/text/invitation.txt:22
msgid "Feel free to explore all the features of the application and share your feedback and suggestions with us. Your feedback is valuable to us and will enable us to continually improve our service."
msgstr ""
#: core/templates/mail/html/invitation.html:256
#: core/templates/mail/text/invitation.txt:23
msgid "Once again, welcome aboard! We are eager to accompany you on your collaboration adventure."
msgstr ""
#: core/templates/mail/html/invitation.html:263
#: core/templates/mail/text/invitation.txt:25
msgid "Sincerely,"
msgstr ""
#: core/templates/mail/html/invitation.html:264
#: core/templates/mail/text/invitation.txt:27
msgid "The La Suite Numérique Team"
msgstr ""
#: core/templates/mail/text/hello.txt:8
#, python-format
msgid "%(username)s shared a document with you"
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
msgstr ""
#: core/templates/mail/html/invitation2.html:197
#: core/templates/mail/text/invitation2.txt:8
#, python-format
msgid "%(username)s invited you as an %(role)s on the following document :"
msgstr ""
#: 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 ""
#: impress/settings.py:176
msgid "English"
msgstr ""
#: impress/settings.py:177
msgid "French"
#: core/templates/mail/text/invitation.txt:8
msgid "Welcome to Docs!"
msgstr ""
Binary file not shown.
+394 -177
View File
@@ -2,8 +2,8 @@ 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-08-14 12:43+0000\n"
"PO-Revision-Date: 2024-08-14 12:48\n"
"Last-Translator: \n"
"Language-Team: French\n"
"Language: fr_FR\n"
@@ -17,330 +17,547 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 8\n"
#: core/admin.py:32
#: build/lib/build/lib/build/lib/build/lib/core/admin.py:31
#: build/lib/build/lib/build/lib/core/admin.py:31
#: build/lib/build/lib/core/admin.py:31 build/lib/core/admin.py:31
#: core/admin.py:31
msgid "Personal info"
msgstr "Infos Personnelles"
#: core/admin.py:34
#: build/lib/build/lib/build/lib/build/lib/core/admin.py:33
#: build/lib/build/lib/build/lib/core/admin.py:33
#: build/lib/build/lib/core/admin.py:33 build/lib/core/admin.py:33
#: core/admin.py:33
msgid "Permissions"
msgstr "Permissions"
#: core/admin.py:46
#: build/lib/build/lib/build/lib/build/lib/core/admin.py:45
#: build/lib/build/lib/build/lib/core/admin.py:45
#: build/lib/build/lib/core/admin.py:45 build/lib/core/admin.py:45
#: core/admin.py:45
msgid "Important dates"
msgstr "Dates importantes"
#: core/api/serializers.py:253
#: build/lib/build/lib/build/lib/build/lib/core/api/serializers.py:176
#: build/lib/build/lib/build/lib/core/api/serializers.py:176
#: build/lib/build/lib/core/api/serializers.py:176
#: build/lib/core/api/serializers.py:176 core/api/serializers.py:176
msgid "Body"
msgstr ""
#: core/api/serializers.py:256
#: build/lib/build/lib/build/lib/build/lib/core/api/serializers.py:179
#: build/lib/build/lib/build/lib/core/api/serializers.py:179
#: build/lib/build/lib/core/api/serializers.py:179
#: build/lib/core/api/serializers.py:179 core/api/serializers.py:179
msgid "Body type"
msgstr ""
#: core/api/serializers.py:262
msgid "Format"
msgstr ""
#: core/authentication/backends.py:56
msgid "Invalid response format or token verification failed"
msgstr ""
#: core/authentication/backends.py:81
#: build/lib/build/lib/build/lib/build/lib/core/authentication/backends.py:71
#: build/lib/build/lib/build/lib/core/authentication/backends.py:71
#: build/lib/build/lib/core/authentication/backends.py:71
#: build/lib/core/authentication/backends.py:71
#: core/authentication/backends.py:71
msgid "User info contained no recognizable user identification"
msgstr ""
#: core/authentication/backends.py:101
#: build/lib/build/lib/build/lib/build/lib/core/authentication/backends.py:91
#: build/lib/build/lib/build/lib/core/authentication/backends.py:91
#: build/lib/build/lib/core/authentication/backends.py:91
#: build/lib/core/authentication/backends.py:91
#: core/authentication/backends.py:91
msgid "Claims contained no recognizable user identification"
msgstr ""
#: core/models.py:62 core/models.py:69
#: build/lib/build/lib/build/lib/build/lib/core/models.py:60
#: build/lib/build/lib/build/lib/core/models.py:60
#: build/lib/build/lib/core/models.py:60 build/lib/core/models.py:60
#: core/models.py:61
msgid "Reader"
msgstr "Lecteur"
msgstr ""
#: core/models.py:63 core/models.py:70
#: build/lib/build/lib/build/lib/build/lib/core/models.py:61
#: build/lib/build/lib/build/lib/core/models.py:61
#: build/lib/build/lib/core/models.py:61 build/lib/core/models.py:61
#: core/models.py:62
msgid "Editor"
msgstr "Éditeur"
msgstr ""
#: core/models.py:71
#: build/lib/build/lib/build/lib/build/lib/core/models.py:62
#: build/lib/build/lib/build/lib/core/models.py:62
#: build/lib/build/lib/core/models.py:62 build/lib/core/models.py:62
#: core/models.py:63
msgid "Administrator"
msgstr "Administrateur"
msgstr ""
#: core/models.py:72
#: build/lib/build/lib/build/lib/build/lib/core/models.py:63
#: build/lib/build/lib/build/lib/core/models.py:63
#: build/lib/build/lib/core/models.py:63 build/lib/core/models.py:63
#: core/models.py:64
msgid "Owner"
msgstr "Propriétaire"
msgstr ""
#: core/models.py:80
msgid "Restricted"
msgstr "Restreint"
#: core/models.py:84
msgid "Authenticated"
msgstr "Authentifié"
#: core/models.py:86
msgid "Public"
msgstr "Public"
#: core/models.py:98
#: build/lib/build/lib/build/lib/build/lib/core/models.py:75
#: build/lib/build/lib/build/lib/core/models.py:75
#: build/lib/build/lib/core/models.py:75 build/lib/core/models.py:75
#: core/models.py:76
msgid "id"
msgstr ""
#: core/models.py:99
#: build/lib/build/lib/build/lib/build/lib/core/models.py:76
#: build/lib/build/lib/build/lib/core/models.py:76
#: build/lib/build/lib/core/models.py:76 build/lib/core/models.py:76
#: core/models.py:77
msgid "primary key for the record as UUID"
msgstr ""
#: core/models.py:105
#: build/lib/build/lib/build/lib/build/lib/core/models.py:82
#: build/lib/build/lib/build/lib/core/models.py:82
#: build/lib/build/lib/core/models.py:82 build/lib/core/models.py:82
#: core/models.py:83
msgid "created on"
msgstr ""
#: core/models.py:106
#: build/lib/build/lib/build/lib/build/lib/core/models.py:83
#: build/lib/build/lib/build/lib/core/models.py:83
#: build/lib/build/lib/core/models.py:83 build/lib/core/models.py:83
#: core/models.py:84
msgid "date and time at which a record was created"
msgstr ""
#: core/models.py:111
#: build/lib/build/lib/build/lib/build/lib/core/models.py:88
#: build/lib/build/lib/build/lib/core/models.py:88
#: build/lib/build/lib/core/models.py:88 build/lib/core/models.py:88
#: core/models.py:89
msgid "updated on"
msgstr ""
#: core/models.py:112
#: build/lib/build/lib/build/lib/build/lib/core/models.py:89
#: build/lib/build/lib/build/lib/core/models.py:89
#: build/lib/build/lib/core/models.py:89 build/lib/core/models.py:89
#: core/models.py:90
msgid "date and time at which a record was last updated"
msgstr ""
#: core/models.py:132
#: build/lib/build/lib/build/lib/build/lib/core/models.py:109
#: build/lib/build/lib/build/lib/core/models.py:109
#: build/lib/build/lib/core/models.py:109 build/lib/core/models.py:109
#: core/models.py:110
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters."
msgstr ""
#: core/models.py:138
#: build/lib/build/lib/build/lib/build/lib/core/models.py:115
#: build/lib/build/lib/build/lib/core/models.py:115
#: build/lib/build/lib/core/models.py:115 build/lib/core/models.py:115
#: core/models.py:116
msgid "sub"
msgstr ""
#: core/models.py:140
#: build/lib/build/lib/build/lib/build/lib/core/models.py:117
#: build/lib/build/lib/build/lib/core/models.py:117
#: build/lib/build/lib/core/models.py:117 build/lib/core/models.py:117
#: core/models.py:118
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
msgstr ""
#: core/models.py:148
#: build/lib/build/lib/build/lib/build/lib/core/models.py:125
#: build/lib/build/lib/build/lib/core/models.py:125
#: build/lib/build/lib/core/models.py:125 build/lib/core/models.py:125
#: core/models.py:126
msgid "identity email address"
msgstr ""
#: core/models.py:153
#: build/lib/build/lib/build/lib/build/lib/core/models.py:130
#: build/lib/build/lib/build/lib/core/models.py:130
#: build/lib/build/lib/core/models.py:130 build/lib/core/models.py:130
#: core/models.py:131
msgid "admin email address"
msgstr ""
#: core/models.py:160
#: build/lib/build/lib/build/lib/build/lib/core/models.py:137
#: build/lib/build/lib/build/lib/core/models.py:137
#: build/lib/build/lib/core/models.py:137 build/lib/core/models.py:137
#: core/models.py:138
msgid "language"
msgstr ""
#: core/models.py:161
#: build/lib/build/lib/build/lib/build/lib/core/models.py:138
#: build/lib/build/lib/build/lib/core/models.py:138
#: build/lib/build/lib/core/models.py:138 build/lib/core/models.py:138
#: core/models.py:139
msgid "The language in which the user wants to see the interface."
msgstr ""
#: core/models.py:167
#: build/lib/build/lib/build/lib/build/lib/core/models.py:144
#: build/lib/build/lib/build/lib/core/models.py:144
#: build/lib/build/lib/core/models.py:144 build/lib/core/models.py:144
#: core/models.py:145
msgid "The timezone in which the user wants to see times."
msgstr ""
#: core/models.py:170
#: build/lib/build/lib/build/lib/build/lib/core/models.py:147
#: build/lib/build/lib/build/lib/core/models.py:147
#: build/lib/build/lib/core/models.py:147 build/lib/core/models.py:147
#: core/models.py:148
msgid "device"
msgstr ""
#: core/models.py:172
#: build/lib/build/lib/build/lib/build/lib/core/models.py:149
#: build/lib/build/lib/build/lib/core/models.py:149
#: build/lib/build/lib/core/models.py:149 build/lib/core/models.py:149
#: core/models.py:150
msgid "Whether the user is a device or a real user."
msgstr ""
#: core/models.py:175
#: build/lib/build/lib/build/lib/build/lib/core/models.py:152
#: build/lib/build/lib/build/lib/core/models.py:152
#: build/lib/build/lib/core/models.py:152 build/lib/core/models.py:152
#: core/models.py:153
msgid "staff status"
msgstr ""
#: core/models.py:177
#: build/lib/build/lib/build/lib/build/lib/core/models.py:154
#: build/lib/build/lib/build/lib/core/models.py:154
#: build/lib/build/lib/core/models.py:154 build/lib/core/models.py:154
#: core/models.py:155
msgid "Whether the user can log into this admin site."
msgstr ""
#: core/models.py:180
#: build/lib/build/lib/build/lib/build/lib/core/models.py:157
#: build/lib/build/lib/build/lib/core/models.py:157
#: build/lib/build/lib/core/models.py:157 build/lib/core/models.py:157
#: core/models.py:158
msgid "active"
msgstr ""
#: core/models.py:183
#: build/lib/build/lib/build/lib/build/lib/core/models.py:160
#: build/lib/build/lib/build/lib/core/models.py:160
#: build/lib/build/lib/core/models.py:160 build/lib/core/models.py:160
#: core/models.py:161
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: core/models.py:195
#: build/lib/build/lib/build/lib/build/lib/core/models.py:172
#: build/lib/build/lib/build/lib/core/models.py:172
#: build/lib/build/lib/core/models.py:172 build/lib/core/models.py:172
#: core/models.py:173
msgid "user"
msgstr ""
#: core/models.py:196
#: build/lib/build/lib/build/lib/build/lib/core/models.py:173
#: build/lib/build/lib/build/lib/core/models.py:173
#: build/lib/build/lib/core/models.py:173 build/lib/core/models.py:173
#: core/models.py:174
msgid "users"
msgstr ""
#: core/models.py:328 core/models.py:644
#: build/lib/build/lib/build/lib/build/lib/core/models.py:304
#: build/lib/build/lib/build/lib/build/lib/core/models.py:531
#: build/lib/build/lib/build/lib/core/models.py:304
#: build/lib/build/lib/build/lib/core/models.py:531
#: build/lib/build/lib/core/models.py:304
#: build/lib/build/lib/core/models.py:531 build/lib/core/models.py:304
#: build/lib/core/models.py:531 core/models.py:305 core/models.py:532
msgid "title"
msgstr ""
#: core/models.py:343
msgid "Document"
msgstr ""
#: core/models.py:344
msgid "Documents"
msgstr ""
#: core/models.py:347
msgid "Untitled Document"
msgstr ""
#: core/models.py:537
#, python-format
msgid "%(username)s shared a document with you: %(document)s"
msgstr "%(username)s a partagé un document avec vous: %(document)s"
#: core/models.py:580
msgid "Document/user link trace"
msgstr ""
#: core/models.py:581
msgid "Document/user link traces"
msgstr ""
#: core/models.py:587
msgid "A link trace already exists for this document/user."
msgstr ""
#: core/models.py:608
msgid "Document/user relation"
msgstr ""
#: core/models.py:609
msgid "Document/user relations"
msgstr ""
#: core/models.py:615
msgid "This user is already in this document."
msgstr ""
#: core/models.py:621
msgid "This team is already in this document."
msgstr ""
#: core/models.py:627 core/models.py:816
msgid "Either user or team must be set, not both."
msgstr ""
#: core/models.py:645
msgid "description"
msgstr ""
#: core/models.py:646
msgid "code"
msgstr ""
#: core/models.py:647
msgid "css"
msgstr ""
#: core/models.py:649
#: build/lib/build/lib/build/lib/build/lib/core/models.py:306
#: build/lib/build/lib/build/lib/build/lib/core/models.py:536
#: build/lib/build/lib/build/lib/core/models.py:306
#: build/lib/build/lib/build/lib/core/models.py:536
#: build/lib/build/lib/core/models.py:306
#: build/lib/build/lib/core/models.py:536 build/lib/core/models.py:306
#: build/lib/core/models.py:536 core/models.py:307 core/models.py:537
msgid "public"
msgstr ""
#: core/models.py:651
#: build/lib/build/lib/build/lib/build/lib/core/models.py:308
#: build/lib/build/lib/build/lib/core/models.py:308
#: build/lib/build/lib/core/models.py:308 build/lib/core/models.py:308
#: core/models.py:309
msgid "Whether this document is public for anyone to use."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:316
#: build/lib/build/lib/build/lib/core/models.py:316
#: build/lib/build/lib/core/models.py:316 build/lib/core/models.py:316
#: core/models.py:317
msgid "Document"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:317
#: build/lib/build/lib/build/lib/core/models.py:317
#: build/lib/build/lib/core/models.py:317 build/lib/core/models.py:317
#: core/models.py:318
msgid "Documents"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:495
#: build/lib/build/lib/build/lib/core/models.py:495
#: build/lib/build/lib/core/models.py:495 build/lib/core/models.py:495
#: core/models.py:496
msgid "Document/user relation"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:496
#: build/lib/build/lib/build/lib/core/models.py:496
#: build/lib/build/lib/core/models.py:496 build/lib/core/models.py:496
#: core/models.py:497
msgid "Document/user relations"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:502
#: build/lib/build/lib/build/lib/core/models.py:502
#: build/lib/build/lib/core/models.py:502 build/lib/core/models.py:502
#: core/models.py:503
msgid "This user is already in this document."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:508
#: build/lib/build/lib/build/lib/core/models.py:508
#: build/lib/build/lib/core/models.py:508 build/lib/core/models.py:508
#: core/models.py:509
msgid "This team is already in this document."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:514
#: build/lib/build/lib/build/lib/build/lib/core/models.py:691
#: build/lib/build/lib/build/lib/core/models.py:514
#: build/lib/build/lib/build/lib/core/models.py:691
#: build/lib/build/lib/core/models.py:514
#: build/lib/build/lib/core/models.py:691 build/lib/core/models.py:514
#: build/lib/core/models.py:691 core/models.py:515 core/models.py:704
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:532
#: build/lib/build/lib/build/lib/core/models.py:532
#: build/lib/build/lib/core/models.py:532 build/lib/core/models.py:532
#: core/models.py:533
msgid "description"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:533
#: build/lib/build/lib/build/lib/core/models.py:533
#: build/lib/build/lib/core/models.py:533 build/lib/core/models.py:533
#: core/models.py:534
msgid "code"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:534
#: build/lib/build/lib/build/lib/core/models.py:534
#: build/lib/build/lib/core/models.py:534 build/lib/core/models.py:534
#: core/models.py:535
msgid "css"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:538
#: build/lib/build/lib/build/lib/core/models.py:538
#: build/lib/build/lib/core/models.py:538 build/lib/core/models.py:538
#: core/models.py:539
msgid "Whether this template is public for anyone to use."
msgstr ""
#: core/models.py:657
#: build/lib/build/lib/build/lib/build/lib/core/models.py:544
#: build/lib/build/lib/build/lib/core/models.py:544
#: build/lib/build/lib/core/models.py:544 build/lib/core/models.py:544
#: core/models.py:545
msgid "Template"
msgstr ""
#: core/models.py:658
#: build/lib/build/lib/build/lib/build/lib/core/models.py:545
#: build/lib/build/lib/build/lib/core/models.py:545
#: build/lib/build/lib/core/models.py:545 build/lib/core/models.py:545
#: core/models.py:546
msgid "Templates"
msgstr ""
#: core/models.py:797
#: build/lib/build/lib/build/lib/build/lib/core/models.py:672
#: build/lib/build/lib/build/lib/core/models.py:672
#: build/lib/build/lib/core/models.py:672 build/lib/core/models.py:672
#: core/models.py:685
msgid "Template/user relation"
msgstr ""
#: core/models.py:798
#: build/lib/build/lib/build/lib/build/lib/core/models.py:673
#: build/lib/build/lib/build/lib/core/models.py:673
#: build/lib/build/lib/core/models.py:673 build/lib/core/models.py:673
#: core/models.py:686
msgid "Template/user relations"
msgstr ""
#: core/models.py:804
#: build/lib/build/lib/build/lib/build/lib/core/models.py:679
#: build/lib/build/lib/build/lib/core/models.py:679
#: build/lib/build/lib/core/models.py:679 build/lib/core/models.py:679
#: core/models.py:692
msgid "This user is already in this template."
msgstr ""
#: core/models.py:810
#: build/lib/build/lib/build/lib/build/lib/core/models.py:685
#: build/lib/build/lib/build/lib/core/models.py:685
#: build/lib/build/lib/core/models.py:685 build/lib/core/models.py:685
#: core/models.py:698
msgid "This team is already in this template."
msgstr ""
#: core/models.py:833
#: build/lib/build/lib/build/lib/build/lib/core/models.py:708
#: build/lib/build/lib/build/lib/core/models.py:708
#: build/lib/build/lib/core/models.py:708 build/lib/core/models.py:708
#: core/models.py:721
msgid "email address"
msgstr ""
#: core/models.py:850
#: build/lib/build/lib/build/lib/build/lib/core/models.py:725
#: build/lib/build/lib/build/lib/core/models.py:725
#: build/lib/build/lib/core/models.py:725 build/lib/core/models.py:725
#: core/models.py:738
msgid "Document invitation"
msgstr ""
#: core/models.py:851
#: build/lib/build/lib/build/lib/build/lib/core/models.py:726
#: build/lib/build/lib/build/lib/core/models.py:726
#: build/lib/build/lib/core/models.py:726 build/lib/core/models.py:726
#: core/models.py:739
msgid "Document invitations"
msgstr ""
#: core/models.py:868
#: build/lib/build/lib/build/lib/build/lib/core/models.py:751
#: build/lib/build/lib/build/lib/core/models.py:751
#: build/lib/build/lib/core/models.py:751 build/lib/core/models.py:751
#: core/models.py:764
msgid "This email is already associated to a registered user."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:795
#: build/lib/build/lib/build/lib/core/models.py:795
#: build/lib/build/lib/core/models.py:795 build/lib/core/models.py:795
msgid "Invitation to join Impress!"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/impress/settings.py:158
#: build/lib/build/lib/build/lib/impress/settings.py:158
#: build/lib/build/lib/impress/settings.py:158
#: build/lib/impress/settings.py:158 impress/settings.py:158
msgid "English"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/impress/settings.py:159
#: build/lib/build/lib/build/lib/impress/settings.py:159
#: build/lib/build/lib/impress/settings.py:159
#: build/lib/impress/settings.py:159 impress/settings.py:159
msgid "French"
msgstr ""
#: build/lib/build/lib/core/api/serializers.py:185
#: build/lib/core/api/serializers.py:185 core/api/serializers.py:185
msgid "Format"
msgstr ""
#: core/models.py:808
msgid "Invitation to join Docs!"
msgstr "Invitation à rejoindre Docs !"
#: core/templates/mail/html/hello.html:159 core/templates/mail/text/hello.txt:3
msgid "Company logo"
msgstr ""
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
#, python-format
msgid "Hello %(name)s"
msgstr ""
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
msgid "Hello"
msgstr ""
#: core/templates/mail/html/hello.html:189 core/templates/mail/text/hello.txt:6
msgid "Thank you very much for your visit!"
msgstr ""
#: core/templates/mail/html/hello.html:221
#, python-format
msgid "This mail has been sent to %(email)s by <a href=\"%(href)s\">%(name)s</a>"
msgstr ""
#: core/templates/mail/html/invitation.html:160
#: core/templates/mail/html/invitation2.html:160
#: core/templates/mail/text/invitation.txt:3
#: core/templates/mail/text/invitation2.txt:3
msgid "La Suite Numérique"
msgstr ""
#: core/templates/mail/html/invitation.html:190
#: core/templates/mail/text/invitation.txt:6
#, python-format
msgid " %(username)s shared a document with you ! "
msgstr " %(username)s a partagé un document avec vous ! "
#: core/templates/mail/text/invitation.txt:5
msgid "Invitation to join a document !"
msgstr "Invitation à rejoindre un document !"
#: core/templates/mail/html/invitation.html:197
#: core/templates/mail/text/invitation.txt:8
#, python-format
msgid " %(username)s invited you as an %(role)s on the following document : "
msgstr " %(username)s vous a invité en tant que %(role)s sur le document suivant : "
#: core/templates/mail/html/invitation.html:198
msgid "Welcome to <strong>Docs!</strong>"
msgstr "Bienvenue sur <strong>Docs !</strong>"
#: core/templates/mail/html/invitation.html:206
#: core/templates/mail/html/invitation2.html:211
#: core/templates/mail/text/invitation.txt:10
#: core/templates/mail/text/invitation2.txt:11
msgid "Open"
msgstr "Ouvrir"
#: core/templates/mail/html/invitation.html:213
#: core/templates/mail/text/invitation.txt:12
msgid "We are delighted to welcome you to our community on Docs, your new companion to collaborate on documents efficiently, intuitively, and securely."
msgstr "Nous sommes heureux de vous accueillir dans notre communauté sur Docs, votre nouveau compagnon pour collaborer sur des documents efficacement, intuitivement, et en toute sécurité."
#: core/templates/mail/html/invitation.html:218
#: core/templates/mail/text/invitation.txt:13
msgid "Our application is designed to help you organize, collaborate, and manage permissions."
msgstr "Notre application est conçue pour vous aider à organiser, collaborer et gérer vos permissions."
#: core/templates/mail/html/invitation.html:223
#: core/templates/mail/text/invitation.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborate on your documents as a team. "
msgstr " Docs, votre nouvel outil incontournable pour organiser, partager et collaborer sur vos documents en équipe. "
msgid "With Docs, you will be able to:"
msgstr "Avec Docs, vous serez capable de :"
#: core/templates/mail/html/invitation.html:230
#: core/templates/mail/html/invitation2.html:235
#: core/templates/mail/html/invitation.html:224
#: core/templates/mail/text/invitation.txt:15
msgid "Create documents."
msgstr "Créez des documents."
#: core/templates/mail/html/invitation.html:225
#: core/templates/mail/text/invitation.txt:16
#: core/templates/mail/text/invitation2.txt:17
msgid "Brought to you by La Suite Numérique"
msgstr "Proposé par La Suite Numérique"
msgid "Work offline."
msgstr "Travailler hors ligne."
#: core/templates/mail/html/invitation2.html:190
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:17
msgid "Invite members of your community to your document in just a few clicks."
msgstr "Invitez des membres de votre communauté sur votre document en quelques clics."
#: core/templates/mail/html/invitation.html:237
#: core/templates/mail/text/invitation.txt:19
msgid "Visit Docs"
msgstr "Visitez Docs"
#: core/templates/mail/html/invitation.html:246
#: core/templates/mail/text/invitation.txt:21
msgid "We are confident that Docs will help you increase efficiency and productivity while strengthening the bond among members."
msgstr "Nous sommes persuadés que Docs vous aidera à améliorer votre efficacité et votre productivité tout en renforçant les liens entre vos membres."
#: core/templates/mail/html/invitation.html:251
#: core/templates/mail/text/invitation.txt:22
msgid "Feel free to explore all the features of the application and share your feedback and suggestions with us. Your feedback is valuable to us and will enable us to continually improve our service."
msgstr "N'hésitez pas à explorer toutes les fonctionnalités de l'application et à nous faire part de vos commentaires et suggestions. Vos commentaires nous sont précieux et nous permettront d'améliorer continuellement notre service."
#: core/templates/mail/html/invitation.html:256
#: core/templates/mail/text/invitation.txt:23
msgid "Once again, welcome aboard! We are eager to accompany you on your collaboration adventure."
msgstr "Encore une fois, bienvenue à bord ! Nous sommes impatients de vous accompagner dans votre aventure collaborative."
#: core/templates/mail/html/invitation.html:263
#: core/templates/mail/text/invitation.txt:25
msgid "Sincerely,"
msgstr "Sincèrement,"
#: core/templates/mail/html/invitation.html:264
#: core/templates/mail/text/invitation.txt:27
msgid "The La Suite Numérique Team"
msgstr "L'équipe La Suite Numérique"
#: core/templates/mail/text/hello.txt:8
#, python-format
msgid "%(username)s shared a document with you"
msgstr "%(username)s a partagé un document avec vous"
#: core/templates/mail/html/invitation2.html:197
#: core/templates/mail/text/invitation2.txt:8
#, python-format
msgid "%(username)s invited you as an %(role)s on the following document :"
msgstr "%(username)s vous a invité en tant que %(role)s sur le document suivant :"
#: 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, votre nouvel outil essentiel pour organiser, partager et collaborer sur votre document en équipe."
#: impress/settings.py:176
msgid "English"
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
msgstr ""
#: impress/settings.py:177
msgid "French"
msgstr ""
#: core/templates/mail/text/invitation.txt:8
msgid "Welcome to Docs!"
msgstr "Bienvenue sur Docs !"
+12 -13
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "impress"
version = "1.5.1"
version = "1.3.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -25,39 +25,38 @@ license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"boto3==1.35.34",
"boto3==1.35.10",
"Brotli==1.1.0",
"celery[redis]==5.4.0",
"django-configurations==2.5.1",
"django-cors-headers==4.4.0",
"django-countries==7.6.1",
"django-parler==2.3",
"redis==5.1.1",
"redis==5.0.8",
"django-redis==5.4.0",
"django-storages[s3]==1.14.4",
"django-timezone-field>=5.1",
"django==5.1.1",
"django==5.1",
"djangorestframework==3.15.2",
"drf_spectacular==0.27.2",
"dockerflow==2024.4.2",
"easy_thumbnails==2.10",
"easy_thumbnails==2.9",
"factory_boy==3.3.1",
"freezegun==1.5.1",
"gunicorn==23.0.0",
"jsonschema==4.23.0",
"markdown==3.7",
"nested-multipart-parser==1.5.0",
"psycopg[binary]==3.2.3",
"psycopg[binary]==3.2.1",
"PyJWT==2.9.0",
"pypandoc==1.13",
"python-frontmatter==1.1.0",
"requests==2.32.3",
"sentry-sdk==2.15.0",
"sentry-sdk==2.13.0",
"url-normalize==1.4.3",
"WeasyPrint>=60.2",
"whitenoise==6.7.0",
"mozilla-django-oidc==4.0.1",
"y-py==0.6.2"
]
[project.urls]
@@ -71,18 +70,18 @@ dev = [
"django-extensions==3.2.3",
"drf-spectacular-sidecar==2024.7.1",
"ipdb==0.13.13",
"ipython==8.28.0",
"ipython==8.27.0",
"pyfakefs==5.6.0",
"pylint-django==2.5.5",
"pylint==3.3.1",
"pylint==3.2.7",
"pytest-cov==5.0.0",
"pytest-django==4.9.0",
"pytest==8.3.3",
"pytest==8.3.2",
"pytest-icdiff==0.9",
"pytest-xdist==3.6.1",
"responses==0.25.3",
"ruff==0.6.9",
"types-requests==2.32.0.20240914",
"ruff==0.6.3",
"types-requests==2.32.0.20240712",
]
[tool.setuptools]
+7 -13
View File
@@ -1,4 +1,4 @@
FROM node:20-alpine AS frontend-deps-y-provider
FROM node:20-alpine as frontend-deps-y-provider
WORKDIR /home/frontend/
@@ -15,7 +15,7 @@ COPY ./src/frontend/ .
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
# ---- y-provider ----
FROM frontend-deps-y-provider AS y-provider
FROM frontend-deps-y-provider as y-provider
WORKDIR /home/frontend/servers/y-provider
RUN yarn build
@@ -28,7 +28,7 @@ ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
CMD ["yarn", "start"]
FROM node:20-alpine AS frontend-deps
FROM node:20-alpine as frontend-deps
WORKDIR /home/frontend/
@@ -43,11 +43,11 @@ COPY .dockerignore ./.dockerignore
COPY ./src/frontend/ .
### ---- Front-end builder image ----
FROM frontend-deps AS impress
FROM frontend-deps as impress
WORKDIR /home/frontend/apps/impress
FROM frontend-deps AS impress-dev
FROM frontend-deps as impress-dev
WORKDIR /home/frontend/apps/impress
@@ -57,7 +57,7 @@ CMD [ "yarn", "dev"]
# Tilt will rebuild impress target so, we dissociate impress and impress-builder
# to avoid rebuilding the app at every changes.
FROM impress AS impress-builder
FROM impress as impress-builder
WORKDIR /home/frontend/apps/impress
@@ -70,16 +70,10 @@ ENV NEXT_PUBLIC_Y_PROVIDER_URL=${Y_PROVIDER_URL}
ARG API_ORIGIN
ENV NEXT_PUBLIC_API_ORIGIN=${API_ORIGIN}
ARG MEDIA_URL
ENV NEXT_PUBLIC_MEDIA_URL=${MEDIA_URL}
ARG SW_DEACTIVATED
ENV NEXT_PUBLIC_SW_DEACTIVATED=${SW_DEACTIVATED}
RUN yarn build
# ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:1.26-alpine AS frontend-production
FROM nginxinc/nginx-unprivileged:1.25 as frontend-production
# Un-privileged user running the application
ARG DOCKER_USER
@@ -1,6 +1,10 @@
import { Page, expect } from '@playwright/test';
export const keyCloakSignIn = async (page: Page, browserName: string) => {
const title = await page.locator('h1').first().textContent({
timeout: 5000,
});
const login = `user-e2e-${browserName}`;
const password = `password-e2e-${browserName}`;
@@ -8,7 +12,7 @@ export const keyCloakSignIn = async (page: Page, browserName: string) => {
await page.getByRole('textbox', { name: 'password' }).fill(password);
await page.click('input[type="submit"]', { force: true });
} else {
} else if (title?.includes('Sign in to your account')) {
await page.getByRole('textbox', { name: 'username' }).fill(login);
await page.getByRole('textbox', { name: 'password' }).fill(password);
@@ -29,21 +33,32 @@ export const createDoc = async (
length: number,
isPublic: boolean = false,
) => {
const buttonCreate = page.getByRole('button', {
name: 'Create the document',
});
const randomDocs = randomName(docName, browserName, length);
for (let i = 0; i < randomDocs.length; i++) {
const header = page.locator('header').first();
await header.locator('h2').getByText('Docs').click();
await page
.getByRole('button', {
name: 'Create a new document',
})
.click();
const buttonCreateHomepage = page.getByRole('button', {
name: 'Create a new document',
});
await buttonCreateHomepage.click();
await page.getByRole('heading', { name: 'Untitled document' }).click();
await page.keyboard.type(randomDocs[i]);
await page.getByText('Created at ').click();
// Fill input
await page
.getByRole('textbox', {
name: 'Document name',
})
.fill(randomDocs[i]);
await expect(buttonCreate).toBeEnabled();
await buttonCreate.click();
await expect(page.locator('h2').getByText(randomDocs[i])).toBeVisible();
if (isPublic) {
await page.getByRole('button', { name: 'Share' }).click();
@@ -113,14 +128,13 @@ export const goToGridDoc = async (
const header = page.locator('header').first();
await header.locator('h2').getByText('Docs').click();
const datagrid = page.getByLabel('Datagrid of the documents page 1');
const datagridTable = datagrid.getByRole('table');
const datagrid = page
.getByLabel('Datagrid of the documents page 1')
.getByRole('table');
await expect(datagrid.getByLabel('Loading data')).toBeHidden({
timeout: 10000,
});
await expect(datagrid.getByLabel('Loading data')).toBeHidden();
const rows = datagridTable.getByRole('row');
const rows = datagrid.getByRole('row');
const row = title
? rows.filter({
hasText: title,
@@ -133,7 +147,7 @@ export const goToGridDoc = async (
expect(docTitle).toBeDefined();
await row.getByRole('link').first().click();
await docTitleCell.click();
return docTitle as string;
};
@@ -141,13 +155,7 @@ export const goToGridDoc = async (
export const mockedDocument = async (page: Page, json: object) => {
await page.route('**/documents/**/', async (route) => {
const request = route.request();
if (
request.method().includes('GET') &&
!request.url().includes('page=') &&
!request.url().includes('versions') &&
!request.url().includes('accesses') &&
!request.url().includes('invitations')
) {
if (request.method().includes('GET') && !request.url().includes('page=')) {
await route.fulfill({
json: {
id: 'mocked-document-id',
@@ -175,82 +183,3 @@ export const mockedDocument = async (page: Page, json: object) => {
}
});
};
export const mockedInvitations = async (page: Page, json?: object) => {
await page.route('**/invitations/**/', async (route) => {
const request = route.request();
if (
request.method().includes('GET') &&
request.url().includes('invitations') &&
request.url().includes('page=')
) {
await route.fulfill({
json: {
count: 1,
next: null,
previous: null,
results: [
{
id: '120ec765-43af-4602-83eb-7f4e1224548a',
abilities: {
destroy: true,
update: true,
partial_update: true,
retrieve: true,
},
created_at: '2024-10-03T12:19:26.107687Z',
email: 'test@invitation.test',
document: '4888c328-8406-4412-9b0b-c0ba5b9e5fb6',
role: 'editor',
issuer: '7380f42f-02eb-4ad5-b8f0-037a0e66066d',
is_expired: false,
...json,
},
],
},
});
} else {
await route.continue();
}
});
};
export const mockedAccesses = async (page: Page, json?: object) => {
await page.route('**/accesses/**/', async (route) => {
const request = route.request();
if (
request.method().includes('GET') &&
request.url().includes('accesses') &&
request.url().includes('page=')
) {
await route.fulfill({
json: {
count: 1,
next: null,
previous: null,
results: [
{
id: 'bc8bbbc5-a635-4f65-9817-fd1e9ec8ef87',
user: {
id: 'b4a21bb3-722e-426c-9f78-9d190eda641c',
email: 'test@accesses.test',
},
team: '',
role: 'reader',
abilities: {
destroy: true,
update: true,
partial_update: true,
retrieve: true,
set_role_to: ['administrator', 'editor'],
},
...json,
},
],
},
});
} else {
await route.continue();
}
});
};
@@ -7,25 +7,59 @@ test.beforeEach(async ({ page }) => {
});
test.describe('Doc Create', () => {
test('checks all the create doc elements are visible', async ({ page }) => {
const buttonCreateHomepage = page.getByRole('button', {
name: 'Create a new document',
});
await buttonCreateHomepage.click();
await expect(buttonCreateHomepage).toBeHidden();
const card = page.getByRole('dialog').first();
await expect(
card.locator('h2').getByText('Create a new document'),
).toBeVisible();
await expect(card.getByLabel('Document name')).toBeVisible();
await expect(
card.getByRole('button', {
name: 'Create the document',
}),
).toBeVisible();
await expect(card.getByLabel('Close the modal')).toBeVisible();
});
test('checks the cancel button interaction', async ({ page }) => {
const buttonCreateHomepage = page.getByRole('button', {
name: 'Create a new document',
});
await buttonCreateHomepage.click();
await expect(buttonCreateHomepage).toBeHidden();
const card = page.getByRole('dialog').first();
await card.getByLabel('Close the modal').click();
await expect(buttonCreateHomepage).toBeVisible();
});
test('it creates a doc', async ({ page, browserName }) => {
const [docTitle] = await createDoc(page, 'My new doc', browserName, 1);
await page.waitForFunction(
() => document.title.match(/My new doc - Docs/),
{ timeout: 5000 },
expect(await page.locator('title').textContent()).toMatch(
/My new doc - Docs/,
);
const header = page.locator('header').first();
await header.locator('h2').getByText('Docs').click();
const datagrid = page.getByLabel('Datagrid of the documents page 1');
const datagridTable = datagrid.getByRole('table');
const datagrid = page
.getByLabel('Datagrid of the documents page 1')
.getByRole('table');
await expect(datagrid.getByLabel('Loading data')).toBeHidden({
timeout: 10000,
});
await expect(datagridTable.getByText(docTitle)).toBeVisible({
timeout: 5000,
});
await expect(datagrid.getByLabel('Loading data')).toBeHidden();
await expect(datagrid.getByText(docTitle)).toBeVisible();
});
});
@@ -40,18 +40,19 @@ test.describe('Doc Editor', () => {
await expect(page.locator('h2').getByText(randomDoc[0])).toBeVisible();
const editor = page.locator('.ProseMirror');
await editor.click();
await editor.fill('[test markdown](http://test-markdown.html)');
await page.locator('.ProseMirror.bn-editor').click();
await page
.locator('.ProseMirror.bn-editor')
.fill('[test markdown](http://test-markdown.html)');
await expect(editor.getByText('[test markdown]')).toBeVisible();
await expect(page.getByText('[test markdown]')).toBeVisible();
await editor.getByText('[test markdown]').dblclick();
await page.getByText('[test markdown]').dblclick();
await page.locator('button[data-test="convertMarkdown"]').click();
await expect(editor.getByText('[test markdown]')).toBeHidden();
await expect(page.getByText('[test markdown]')).toBeHidden();
await expect(
editor.getByRole('link', {
page.getByRole('link', {
name: 'test markdown',
}),
).toHaveAttribute('href', 'http://test-markdown.html');
@@ -63,40 +64,38 @@ test.describe('Doc Editor', () => {
// Check the first doc
const firstDoc = await goToGridDoc(page);
await expect(page.locator('h2').getByText(firstDoc)).toBeVisible();
const editor = page.locator('.ProseMirror');
await editor.click();
await editor.fill('Hello World Doc 1');
await expect(editor.getByText('Hello World Doc 1')).toBeVisible();
await page.locator('.ProseMirror.bn-editor').click();
await page.locator('.ProseMirror.bn-editor').fill('Hello World Doc 1');
await expect(page.getByText('Hello World Doc 1')).toBeVisible();
// Check the second doc
const secondDoc = await goToGridDoc(page, {
nthRow: 2,
});
await expect(page.locator('h2').getByText(secondDoc)).toBeVisible();
await expect(editor.getByText('Hello World Doc 1')).toBeHidden();
await editor.click();
await editor.fill('Hello World Doc 2');
await expect(editor.getByText('Hello World Doc 2')).toBeVisible();
await expect(page.getByText('Hello World Doc 1')).toBeHidden();
await page.locator('.ProseMirror.bn-editor').click();
await page.locator('.ProseMirror.bn-editor').fill('Hello World Doc 2');
await expect(page.getByText('Hello World Doc 2')).toBeVisible();
// Check the first doc again
await goToGridDoc(page, {
title: firstDoc,
});
await expect(page.locator('h2').getByText(firstDoc)).toBeVisible();
await expect(editor.getByText('Hello World Doc 2')).toBeHidden();
await expect(editor.getByText('Hello World Doc 1')).toBeVisible();
await expect(page.getByText('Hello World Doc 2')).toBeHidden();
await expect(page.getByText('Hello World Doc 1')).toBeVisible();
});
test('it saves the doc when we change pages', async ({ page }) => {
// Check the first doc
const doc = await goToGridDoc(page);
await expect(page.locator('h2').getByText(doc)).toBeVisible();
const editor = page.locator('.ProseMirror');
await editor.click();
await editor.fill('Hello World Doc persisted 1');
await expect(editor.getByText('Hello World Doc persisted 1')).toBeVisible();
await page.locator('.ProseMirror.bn-editor').click();
await page
.locator('.ProseMirror.bn-editor')
.fill('Hello World Doc persisted 1');
await expect(page.getByText('Hello World Doc persisted 1')).toBeVisible();
const secondDoc = await goToGridDoc(page, {
nthRow: 2,
@@ -108,7 +107,7 @@ test.describe('Doc Editor', () => {
title: doc,
});
await expect(editor.getByText('Hello World Doc persisted 1')).toBeVisible();
await expect(page.getByText('Hello World Doc persisted 1')).toBeVisible();
});
test('it saves the doc when we quit pages', async ({ page, browserName }) => {
@@ -118,11 +117,11 @@ test.describe('Doc Editor', () => {
// Check the first doc
const doc = await goToGridDoc(page);
await expect(page.locator('h2').getByText(doc)).toBeVisible();
const editor = page.locator('.ProseMirror');
await editor.click();
await editor.fill('Hello World Doc persisted 2');
await expect(editor.getByText('Hello World Doc persisted 2')).toBeVisible();
await page.locator('.ProseMirror.bn-editor').click();
await page
.locator('.ProseMirror.bn-editor')
.fill('Hello World Doc persisted 2');
await expect(page.getByText('Hello World Doc persisted 2')).toBeVisible();
await page.goto('/');
@@ -130,7 +129,7 @@ test.describe('Doc Editor', () => {
title: doc,
});
await expect(editor.getByText('Hello World Doc persisted 2')).toBeVisible();
await expect(page.getByText('Hello World Doc persisted 2')).toBeVisible();
});
test('it cannot edit if viewer', async ({ page }) => {
@@ -85,7 +85,7 @@ test.describe('Doc Export', () => {
page,
browserName,
}) => {
test.setTimeout(60000);
test.slow();
const [randomDoc] = await createDoc(page, 'doc-editor', browserName, 1);
let body = '';
@@ -117,9 +117,7 @@ test.describe('Documents Grid', () => {
.getByRole('cell')
.nth(cellNumber);
await expect(datagrid.getByLabel('Loading data')).toBeHidden({
timeout: 10000,
});
await expect(datagrid.getByLabel('Loading data')).toBeHidden();
// Initial state
await expect(docNameRow1).toHaveText(/.*/);
@@ -136,9 +134,7 @@ test.describe('Documents Grid', () => {
const responseOrderingAsc = await responsePromiseOrderingAsc;
expect(responseOrderingAsc.ok()).toBeTruthy();
await expect(datagrid.getByLabel('Loading data')).toBeHidden({
timeout: 10000,
});
await expect(datagrid.getByLabel('Loading data')).toBeHidden();
await expect(docNameRow1).toHaveText(/.*/);
await expect(docNameRow2).toHaveText(/.*/);
@@ -159,9 +155,7 @@ test.describe('Documents Grid', () => {
const responseOrderingDesc = await responsePromiseOrderingDesc;
expect(responseOrderingDesc.ok()).toBeTruthy();
await expect(datagrid.getByLabel('Loading data')).toBeHidden({
timeout: 10000,
});
await expect(datagrid.getByLabel('Loading data')).toBeHidden();
await expect(docNameRow1).toHaveText(/.*/);
await expect(docNameRow2).toHaveText(/.*/);
@@ -218,6 +212,26 @@ test.describe('Documents Grid', () => {
).toHaveText(/.*/);
});
test('it updates document', async ({ page }) => {
const datagrid = page
.getByLabel('Datagrid of the documents page 1')
.getByRole('table');
const docRow = datagrid.getByRole('row').nth(1).getByRole('cell');
const docName = await docRow.nth(1).textContent();
await docRow.getByLabel('Open the document options').click();
await page.getByText('Update document').click();
await page.getByLabel('Document name').fill(`${docName} updated`);
await page.getByText('Validate the modification').click();
await expect(datagrid.getByText(`${docName} updated`)).toBeVisible();
});
test('it deletes the document', async ({ page }) => {
const datagrid = page
.getByLabel('Datagrid of the documents page 1')
@@ -227,9 +241,11 @@ test.describe('Documents Grid', () => {
const docName = await docRow.nth(1).textContent();
await docRow
await docRow.getByLabel('Open the document options').click();
await page
.getByRole('button', {
name: 'Delete the document',
name: 'Delete document',
})
.click();
@@ -250,87 +266,3 @@ test.describe('Documents Grid', () => {
await expect(datagrid.getByText(docName!)).toBeHidden();
});
});
test.describe('Documents Grid mobile', () => {
test.use({ viewport: { width: 500, height: 1200 } });
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('it checks the grid when mobile', async ({ page }) => {
await page.route('**/documents/**', async (route) => {
const request = route.request();
if (request.method().includes('GET') && request.url().includes('page=')) {
await route.fulfill({
json: {
count: 1,
next: null,
previous: null,
results: [
{
id: 'b7fd9d9b-0642-4b4f-8617-ce50f69519ed',
title: 'My mocked document',
accesses: [
{
id: '8c1e047a-24e7-4a80-942b-8e9c7ab43e1f',
user: {
id: '7380f42f-02eb-4ad5-b8f0-037a0e66066d',
email: 'test@test.test',
full_name: 'John Doe',
short_name: 'John',
},
team: '',
role: 'owner',
abilities: {
destroy: false,
update: false,
partial_update: false,
retrieve: true,
set_role_to: [],
},
},
],
abilities: {
attachment_upload: true,
destroy: true,
link_configuration: true,
manage_accesses: true,
partial_update: true,
retrieve: true,
update: true,
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
},
link_role: 'reader',
link_reach: 'public',
created_at: '2024-10-07T13:02:41.085298Z',
updated_at: '2024-10-07T13:30:21.829690Z',
},
],
},
});
} else {
await route.continue();
}
});
await page.goto('/');
const datagrid = page.getByLabel('Datagrid of the documents page 1');
const tableDatagrid = datagrid.getByRole('table');
await expect(datagrid.getByLabel('Loading data')).toBeHidden({
timeout: 10000,
});
const rows = tableDatagrid.getByRole('row');
const row = rows.filter({
hasText: 'My mocked document',
});
await expect(row.getByRole('cell').nth(0)).toHaveText('My mocked document');
await expect(row.getByRole('cell').nth(1)).toHaveText('Public');
});
});
@@ -1,12 +1,6 @@
import { expect, test } from '@playwright/test';
import {
createDoc,
goToGridDoc,
mockedAccesses,
mockedDocument,
mockedInvitations,
} from './common';
import { createDoc, goToGridDoc, mockedDocument } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -71,66 +65,49 @@ test.describe('Doc Header', () => {
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
});
test('it updates the title doc', async ({ page, browserName }) => {
test('it updates the doc', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-update', browserName, 1);
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await page.getByRole('heading', { name: randomDoc }).fill(' ');
await page.getByText('Created at').click();
await expect(
page.getByRole('heading', { name: 'Untitled document' }),
).toBeVisible();
});
test('it updates the title doc from editor heading', async ({ page }) => {
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
name: 'Create a new document',
name: 'Update document',
})
.click();
const docHeader = page.getByLabel(
'It is the card information about the document.',
);
await expect(
docHeader.getByRole('heading', { name: 'Untitled document', level: 2 }),
page.locator('h2').getByText(`Update document "${randomDoc}"`),
).toBeVisible();
const editor = page.locator('.ProseMirror');
await page.getByText('Document name').fill(`${randomDoc}-updated`);
await editor.locator('h1').click();
await page.keyboard.type('Hello World', { delay: 100 });
await page
.getByRole('button', {
name: 'Validate the modification',
})
.click();
await expect(
docHeader.getByRole('heading', { name: 'Hello World', level: 2 }),
page.getByText('The document has been updated.'),
).toBeVisible();
const docTitle = await goToGridDoc(page, {
title: `${randomDoc}-updated`,
});
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
name: 'Update document',
})
.click();
await expect(
page.getByText('Document title updated successfully'),
).toBeVisible();
await docHeader
.getByRole('heading', { name: 'Hello World', level: 2 })
.fill('Top World');
await editor.locator('h1').fill('Super World');
await expect(
docHeader.getByRole('heading', { name: 'Top World', level: 2 }),
).toBeVisible();
await editor.locator('h1').fill('');
await docHeader
.getByRole('heading', { name: 'Top World', level: 2 })
.fill(' ');
await page.getByText('Created at').click();
await expect(
docHeader.getByRole('heading', { name: 'Untitled document', level: 2 }),
).toBeVisible();
page.getByRole('textbox', { name: 'Document name' }),
).toHaveValue(`${randomDoc}-updated`);
});
test('it deletes the doc', async ({ page, browserName }) => {
@@ -188,55 +165,21 @@ test.describe('Doc Header', () => {
},
});
await mockedInvitations(page);
await mockedAccesses(page);
await goToGridDoc(page);
await expect(
page.locator('h2').getByText('Mocked document'),
).toHaveAttribute('contenteditable');
await expect(page.locator('h2').getByText('Mocked document')).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
await page.getByLabel('Open the document options').click();
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
await expect(
page.getByRole('button', { name: 'Delete document' }),
).toBeHidden();
// Click somewhere else to close the options
await page.click('body', { position: { x: 0, y: 0 } });
await page.getByRole('button', { name: 'Share' }).click();
const shareModal = page.getByLabel('Share modal');
await expect(shareModal.getByLabel('Doc private')).toBeEnabled();
await expect(shareModal.getByText('Search by email')).toBeVisible();
const invitationCard = shareModal.getByLabel('List invitation card');
await expect(
invitationCard.getByText('test@invitation.test'),
page.getByRole('button', { name: 'Update document' }),
).toBeVisible();
await expect(
invitationCard.getByRole('combobox', { name: 'Role' }),
).toBeEnabled();
await expect(
invitationCard.getByRole('button', {
name: 'delete',
}),
).toBeEnabled();
const memberCard = shareModal.getByLabel('List members card');
await expect(memberCard.getByText('test@accesses.test')).toBeVisible();
await expect(
memberCard.getByRole('combobox', { name: 'Role' }),
).toBeEnabled();
await expect(
memberCard.getByRole('button', {
name: 'delete',
}),
).toBeEnabled();
page.getByRole('button', { name: 'Delete document' }),
).toBeHidden();
});
test('it checks the options available if editor', async ({ page }) => {
@@ -254,61 +197,20 @@ test.describe('Doc Header', () => {
},
});
await mockedInvitations(page, {
abilities: {
destroy: false,
update: false,
partial_update: false,
retrieve: true,
},
});
await mockedAccesses(page);
await goToGridDoc(page);
await expect(
page.locator('h2').getByText('Mocked document'),
).toHaveAttribute('contenteditable');
await expect(page.locator('h2').getByText('Mocked document')).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeHidden();
await page.getByLabel('Open the document options').click();
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
await expect(
page.getByRole('button', { name: 'Delete document' }),
).toBeHidden();
// Click somewhere else to close the options
await page.click('body', { position: { x: 0, y: 0 } });
await page.getByRole('button', { name: 'Share' }).click();
const shareModal = page.getByLabel('Share modal');
await expect(shareModal.getByLabel('Doc private')).toBeDisabled();
await expect(shareModal.getByText('Search by email')).toBeHidden();
const invitationCard = shareModal.getByLabel('List invitation card');
await expect(
invitationCard.getByText('test@invitation.test'),
page.getByRole('button', { name: 'Update document' }),
).toBeVisible();
await expect(
invitationCard.getByRole('combobox', { name: 'Role' }),
).toHaveAttribute('disabled');
await expect(
invitationCard.getByRole('button', {
name: 'delete',
}),
).toBeHidden();
const memberCard = shareModal.getByLabel('List members card');
await expect(memberCard.getByText('test@accesses.test')).toBeVisible();
await expect(
memberCard.getByRole('combobox', { name: 'Role' }),
).toHaveAttribute('disabled');
await expect(
memberCard.getByRole('button', {
name: 'delete',
}),
page.getByRole('button', { name: 'Delete document' }),
).toBeHidden();
});
@@ -327,93 +229,21 @@ test.describe('Doc Header', () => {
},
});
await mockedInvitations(page, {
abilities: {
destroy: false,
update: false,
partial_update: false,
retrieve: true,
},
});
await mockedAccesses(page);
await goToGridDoc(page);
await expect(
page.locator('h2').getByText('Mocked document'),
).not.toHaveAttribute('contenteditable');
await expect(page.locator('h2').getByText('Mocked document')).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeHidden();
await page.getByLabel('Open the document options').click();
await expect(page.getByRole('button', { name: 'Share' })).toBeHidden();
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
await expect(
page.getByRole('button', { name: 'Update document' }),
).toBeHidden();
await expect(
page.getByRole('button', { name: 'Delete document' }),
).toBeHidden();
// Click somewhere else to close the options
await page.click('body', { position: { x: 0, y: 0 } });
await page.getByRole('button', { name: 'Share' }).click();
const shareModal = page.getByLabel('Share modal');
await expect(shareModal.getByLabel('Doc private')).toBeDisabled();
await expect(shareModal.getByText('Search by email')).toBeHidden();
const invitationCard = shareModal.getByLabel('List invitation card');
await expect(
invitationCard.getByText('test@invitation.test'),
).toBeVisible();
await expect(
invitationCard.getByRole('combobox', { name: 'Role' }),
).toHaveAttribute('disabled');
await expect(
invitationCard.getByRole('button', {
name: 'delete',
}),
).toBeHidden();
const memberCard = shareModal.getByLabel('List members card');
await expect(memberCard.getByText('test@accesses.test')).toBeVisible();
await expect(
memberCard.getByRole('combobox', { name: 'Role' }),
).toHaveAttribute('disabled');
await expect(
memberCard.getByRole('button', {
name: 'delete',
}),
).toBeHidden();
});
});
test.describe('Documents Header mobile', () => {
test.use({ viewport: { width: 500, height: 1200 } });
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('it checks the close button on Share modal', async ({ page }) => {
await mockedDocument(page, {
abilities: {
destroy: true, // Means owner
link_configuration: true,
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
manage_accesses: true,
update: true,
partial_update: true,
retrieve: true,
},
});
await goToGridDoc(page);
await page.getByRole('button', { name: 'Share' }).click();
await expect(page.getByLabel('Share modal')).toBeVisible();
await page.getByRole('button', { name: 'close' }).click();
await expect(page.getByLabel('Share modal')).toBeHidden();
});
});
@@ -66,61 +66,6 @@ test.describe('Document list members', () => {
).toBeVisible();
});
test('it checks a big list of invitations', async ({ page }) => {
await page.route(
/.*\/documents\/.*\/invitations\/\?page=.*/,
async (route) => {
const request = route.request();
const url = new URL(request.url());
const pageId = url.searchParams.get('page');
const accesses = {
count: 100,
next: 'http://anything/?page=2',
previous: null,
results: Array.from({ length: 20 }, (_, i) => ({
id: `2ff1ec07-86c1-4534-a643-f41824a6c53a-${pageId}-${i}`,
email: `impress@impress.world-page-${pageId}-${i}`,
team: '',
role: 'editor',
abilities: {
destroy: true,
update: true,
partial_update: true,
retrieve: true,
},
})),
};
if (request.method().includes('GET')) {
await route.fulfill({
json: accesses,
});
} else {
await route.continue();
}
},
);
await goToGridDoc(page);
await page.getByRole('button', { name: 'Share' }).click();
const list = page.getByLabel('List invitation card').locator('ul');
await expect(list.locator('li')).toHaveCount(20);
await list.getByText(`impress@impress.world-page-${1}-18`).hover();
await page.mouse.wheel(0, 10);
await waitForElementCount(list.locator('li'), 21, 10000);
expect(await list.locator('li').count()).toBeGreaterThan(20);
await expect(
list.getByText(`impress@impress.world-page-1-16`),
).toBeVisible();
await expect(
list.getByText(`impress@impress.world-page-2-15`),
).toBeVisible();
});
test('it checks the role rules', async ({ page, browserName }) => {
const [docTitle] = await createDoc(page, 'Doc role rules', browserName, 1);
@@ -161,17 +106,15 @@ test.describe('Document list members', () => {
await page.getByRole('option', { name: 'Administrator' }).click();
await expect(page.getByText('The role has been updated')).toBeVisible();
const shareModal = page.getByLabel('Share modal');
// Admin still have the right to share
await expect(shareModal.getByLabel('Doc private')).toBeEnabled();
await expect(page.locator('h3').getByText('Share')).toBeVisible();
await SelectRoleCurrentUser.click();
await page.getByRole('option', { name: 'Reader' }).click();
await expect(page.getByText('The role has been updated')).toBeVisible();
// Reader does not have the right to share
await expect(shareModal.getByLabel('Doc private')).toBeDisabled();
await expect(page.locator('h3').getByText('Share')).toBeHidden();
});
test('it checks the delete members', async ({ page, browserName }) => {
@@ -216,8 +159,6 @@ test.describe('Document list members', () => {
page.getByText('The member has been removed from the document').first(),
).toBeVisible();
await expect(
page.getByRole('heading', { name: 'Share', level: 3 }),
).toBeHidden();
await expect(page.getByText('Share')).toBeHidden();
});
});
@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
import { keyCloakSignIn, mockedDocument } from './common';
import { keyCloakSignIn } from './common';
test.describe('Doc Routing', () => {
test.beforeEach(async ({ page }) => {
@@ -43,12 +43,9 @@ test.describe('Doc Routing: Not loggued', () => {
page,
browserName,
}) => {
await mockedDocument(page, { link_reach: 'public' });
await page.goto('/docs/mocked-document-id/');
await expect(page.locator('h2').getByText('Mocked document')).toBeVisible();
await page.getByRole('button', { name: 'Login' }).click();
await keyCloakSignIn(page, browserName);
await expect(page.locator('h2').getByText('Mocked document')).toBeVisible();
await expect(page).toHaveURL(/\/docs\/mocked-document-id\/$/);
});
test('The homepage redirects to login.', async ({ page }) => {
@@ -0,0 +1,64 @@
import { expect, test } from '@playwright/test';
import { createDoc } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Doc Summary', () => {
test('it checks the doc summary', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-summary', browserName, 1);
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
name: 'Summary',
})
.click();
const panel = page.getByLabel('Document panel');
const editor = page.locator('.ProseMirror');
await editor.locator('.bn-block-outer').last().fill('/');
await page.getByText('Heading 1').click();
await page.keyboard.type('Hello World');
await page.locator('.bn-block-outer').last().click();
// Create space to fill the viewport
for (let i = 0; i < 6; i++) {
await page.keyboard.press('Enter');
}
await editor.locator('.bn-block-outer').last().fill('/');
await page.getByText('Heading 2').click();
await page.keyboard.type('Super World');
await page.locator('.bn-block-outer').last().click();
// Create space to fill the viewport
for (let i = 0; i < 4; i++) {
await page.keyboard.press('Enter');
}
await editor.locator('.bn-block-outer').last().fill('/');
await page.getByText('Heading 3').click();
await page.keyboard.type('Another World');
await expect(panel.getByText('Hello World')).toBeVisible();
await expect(panel.getByText('Super World')).toBeVisible();
await panel.getByText('Another World').click();
await expect(editor.getByText('Hello World')).not.toBeInViewport();
await panel.getByText('Back to top').click();
await expect(editor.getByText('Hello World')).toBeInViewport();
await panel.getByText('Go to bottom').click();
await expect(editor.getByText('Hello World')).not.toBeInViewport();
});
});
@@ -1,139 +0,0 @@
import { expect, test } from '@playwright/test';
import { createDoc, goToGridDoc } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Doc Table Content', () => {
test('it checks the doc table content', async ({ page, browserName }) => {
test.setTimeout(60000);
const [randomDoc] = await createDoc(
page,
'doc-table-content',
browserName,
1,
);
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
name: 'Table of contents',
})
.click();
const panel = page.getByLabel('Document panel');
const editor = page.locator('.ProseMirror');
await editor.locator('.bn-block-outer').last().fill('/');
await page.getByText('Heading 1').click();
await page.keyboard.type('Hello World');
await editor.getByText('Hello').dblclick();
await page.getByRole('button', { name: 'Strike' }).click();
await page.locator('.bn-block-outer').first().click();
await page.locator('.bn-block-outer').last().click();
// Create space to fill the viewport
for (let i = 0; i < 10; i++) {
await page.keyboard.press('Enter');
}
await editor.locator('.bn-block-outer').last().fill('/');
await page.getByText('Heading 2').click();
await page.keyboard.type('Super World', { delay: 100 });
await page.locator('.bn-block-outer').last().click();
// Create space to fill the viewport
for (let i = 0; i < 10; i++) {
await page.keyboard.press('Enter');
}
await editor.locator('.bn-block-outer').last().fill('/');
await page.getByText('Heading 3').click();
await page.keyboard.type('Another World');
const hello = panel.getByText('Hello World');
const superW = panel.getByText('Super World');
const another = panel.getByText('Another World');
await expect(hello).toBeVisible();
await expect(hello).toHaveCSS('font-size', /17/);
await expect(hello).toHaveAttribute('aria-selected', 'true');
await expect(superW).toBeVisible();
await expect(superW).toHaveCSS('font-size', /14/);
await expect(superW).toHaveAttribute('aria-selected', 'false');
await expect(another).toBeVisible();
await expect(another).toHaveCSS('font-size', /12/);
await expect(another).toHaveAttribute('aria-selected', 'false');
await hello.click();
await expect(editor.getByText('Hello World')).toBeInViewport();
await expect(hello).toHaveAttribute('aria-selected', 'true');
await expect(superW).toHaveAttribute('aria-selected', 'false');
await another.click();
await expect(editor.getByText('Hello World')).not.toBeInViewport();
await expect(hello).toHaveAttribute('aria-selected', 'false');
await expect(superW).toHaveAttribute('aria-selected', 'true');
await panel.getByText('Back to top').click();
await expect(editor.getByText('Hello World')).toBeInViewport();
await expect(hello).toHaveAttribute('aria-selected', 'true');
await expect(superW).toHaveAttribute('aria-selected', 'false');
await panel.getByText('Go to bottom').click();
await expect(editor.getByText('Hello World')).not.toBeInViewport();
await expect(superW).toHaveAttribute('aria-selected', 'true');
});
test('it checks that table contents panel is opened automaticaly if more that 2 headings', async ({
page,
browserName,
}) => {
const [randomDoc] = await createDoc(
page,
'doc-table-content',
browserName,
1,
);
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await expect(page.getByLabel('Open the panel')).toBeHidden();
const editor = page.locator('.ProseMirror');
await editor.locator('.bn-block-outer').last().fill('/');
await page.getByText('Heading 1').click();
await page.keyboard.type('Hello World', { delay: 100 });
await page.keyboard.press('Enter');
await editor.locator('.bn-block-outer').last().fill('/');
await page.getByText('Heading 2').click();
await page.keyboard.type('Super World', { delay: 100 });
await goToGridDoc(page, {
title: randomDoc,
});
await expect(page.getByLabel('Close the panel')).toBeVisible();
const panel = page.getByLabel('Document panel');
await expect(panel.getByText('Hello World')).toBeVisible();
await expect(panel.getByText('Super World')).toBeVisible();
await page.getByLabel('Close the panel').click();
await expect(panel).toHaveAttribute('aria-hidden', 'true');
});
});
@@ -1,207 +0,0 @@
import { expect, test } from '@playwright/test';
import { createDoc, goToGridDoc, mockedDocument } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Doc Version', () => {
test('it displays the doc versions', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-version', browserName, 1);
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
name: 'Version history',
})
.click();
const panel = page.getByLabel('Document panel');
await expect(panel.getByText('Current version')).toBeVisible();
expect(await panel.locator('li').count()).toBe(1);
await page.locator('.ProseMirror.bn-editor').click();
await page.locator('.ProseMirror.bn-editor').last().fill('Hello World');
await goToGridDoc(page, {
title: randomDoc,
});
await expect(page.getByText('Hello World')).toBeVisible();
await page
.locator('.ProseMirror .bn-block')
.getByText('Hello World')
.fill('It will create a version');
await goToGridDoc(page, {
title: randomDoc,
});
await expect(page.getByText('Hello World')).toBeHidden();
await expect(page.getByText('It will create a version')).toBeVisible();
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
name: 'Version history',
})
.click();
await expect(panel.getByText('Current version')).toBeVisible();
expect(await panel.locator('li').count()).toBe(2);
await panel.locator('li').nth(1).click();
await expect(
page.getByText('Read only, you cannot edit document versions.'),
).toBeVisible();
await expect(page.getByText('Hello World')).toBeVisible();
await expect(page.getByText('It will create a version')).toBeHidden();
await panel.getByText('Current version').click();
await expect(page.getByText('Hello World')).toBeHidden();
await expect(page.getByText('It will create a version')).toBeVisible();
});
test('it does not display the doc versions if not allowed', async ({
page,
}) => {
await mockedDocument(page, {
abilities: {
versions_list: false,
partial_update: true,
},
});
await goToGridDoc(page);
await expect(page.locator('h2').getByText('Mocked document')).toBeVisible();
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('button', { name: 'Version history' }),
).toBeHidden();
await page.getByRole('button', { name: 'Table of content' }).click();
await expect(
page.getByLabel('Document panel').getByText('Versions'),
).toBeHidden();
});
test('it restores the doc version', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-version', browserName, 1);
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await page.locator('.bn-block-outer').last().click();
await page.locator('.bn-block-outer').last().fill('Hello');
await goToGridDoc(page, {
title: randomDoc,
});
const editor = page.locator('.ProseMirror');
await expect(editor.getByText('Hello')).toBeVisible();
await page.locator('.bn-block-outer').last().click();
await page.keyboard.press('Enter');
await page.locator('.bn-block-outer').last().fill('World');
await goToGridDoc(page, {
title: randomDoc,
});
await expect(page.getByText('World')).toBeVisible();
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
name: 'Version history',
})
.click();
const panel = page.getByLabel('Document panel');
await panel.locator('li').nth(1).click();
await expect(page.getByText('World')).toBeHidden();
await panel.getByLabel('Open the version options').click();
await page.getByText('Restore the version').click();
await expect(page.getByText('Restore this version?')).toBeVisible();
await page
.getByRole('button', {
name: 'Restore',
})
.click();
await expect(panel.locator('li')).toHaveCount(3);
await panel.getByText('Current version').click();
await expect(page.getByText('Hello')).toBeVisible();
await expect(page.getByText('World')).toBeHidden();
});
test('it restores the doc version from button title', async ({
page,
browserName,
}) => {
const [randomDoc] = await createDoc(page, 'doc-version', browserName, 1);
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
const editor = page.locator('.ProseMirror');
await editor.locator('.bn-block-outer').last().click();
await editor.locator('.bn-block-outer').last().fill('Hello');
await goToGridDoc(page, {
title: randomDoc,
});
await expect(editor.getByText('Hello')).toBeVisible();
await editor.locator('.bn-block-outer').last().click();
await page.keyboard.press('Enter');
await editor.locator('.bn-block-outer').last().fill('World');
await goToGridDoc(page, {
title: randomDoc,
});
await expect(editor.getByText('World')).toBeVisible();
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
name: 'Version history',
})
.click();
const panel = page.getByLabel('Document panel');
await panel.locator('li').nth(1).click();
await expect(editor.getByText('World')).toBeHidden();
await page
.getByRole('button', {
name: 'Restore this version',
})
.click();
await expect(page.getByText('Restore this version?')).toBeVisible();
await page
.getByRole('button', {
name: 'Restore',
})
.click();
await expect(panel.locator('li')).toHaveCount(3);
await panel.getByText('Current version').click();
await expect(editor.getByText('Hello')).toBeVisible();
await expect(editor.getByText('World')).toBeHidden();
});
});
@@ -23,9 +23,7 @@ test.describe('Doc Visibility', () => {
.getByLabel('Datagrid of the documents page 1')
.getByRole('table');
await expect(datagrid.getByLabel('Loading data')).toBeHidden({
timeout: 10000,
});
await expect(datagrid.getByLabel('Loading data')).toBeHidden();
await expect(datagrid.getByText(docTitle)).toBeVisible();
@@ -94,33 +92,5 @@ test.describe('Doc Visibility: Not loggued', () => {
await page.goto(urlDoc);
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeHidden();
});
test('A private doc redirect to the OIDC when not authentified.', async ({
page,
browserName,
}) => {
test.slow();
await page.goto('/');
await keyCloakSignIn(page, browserName);
const [docTitle] = await createDoc(page, 'My private doc', browserName, 1);
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
const urlDoc = page.url();
await page
.getByRole('button', {
name: 'Logout',
})
.click();
await expect(page.getByRole('textbox', { name: 'password' })).toBeVisible();
await page.goto(urlDoc);
await expect(page.getByRole('textbox', { name: 'password' })).toBeVisible();
});
});
@@ -1,7 +1,5 @@
import { expect, test } from '@playwright/test';
import { goToGridDoc } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
@@ -47,12 +45,6 @@ test.describe('Footer', () => {
).toBeVisible();
});
test('checks footer is not visible on doc editor', async ({ page }) => {
await expect(page.locator('footer')).toBeVisible();
await goToGridDoc(page);
await expect(page.locator('footer')).toBeHidden();
});
const legalPages = [
{ name: 'Legal Notice', url: '/legal-notice/' },
{ name: 'Personal data and cookies', url: '/personal-data-cookies/' },
@@ -10,6 +10,8 @@ test.describe('Header', () => {
test('checks all the elements are visible', async ({ page }) => {
const header = page.locator('header').first();
await expect(header.getByAltText('Gouvernement Logo')).toBeVisible();
await expect(header.getByAltText('Docs Logo')).toBeVisible();
await expect(header.locator('h2').getByText('Docs')).toHaveCSS(
'color',
@@ -26,7 +28,7 @@ test.describe('Header', () => {
}),
).toBeVisible();
await expect(header.getByText('English')).toBeVisible();
await expect(header.getByAltText('Language Icon')).toBeVisible();
await expect(
header.getByRole('button', {
@@ -65,42 +67,6 @@ test.describe('Header', () => {
});
});
test.describe('Header mobile', () => {
test.use({ viewport: { width: 500, height: 1200 } });
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('it checks the header when mobile', async ({ page }) => {
const header = page.locator('header').first();
await expect(
header.getByRole('button', {
name: 'Les services de La Suite numérique',
}),
).toBeVisible();
await expect(
page.getByRole('button', {
name: 'Logout',
}),
).toBeHidden();
await expect(page.getByText('English')).toBeHidden();
await header.getByLabel('Open the header menu').click();
await expect(
page.getByRole('button', {
name: 'Logout',
}),
).toBeVisible();
await expect(page.getByText('English')).toBeVisible();
});
});
test.describe('Header: Log out', () => {
test.use({ storageState: { cookies: [], origins: [] } });
@@ -13,11 +13,9 @@ test.describe('Language', () => {
).toBeVisible();
const header = page.locator('header').first();
await header.getByRole('combobox').getByText('English').click();
await header.getByRole('option', { name: 'Français' }).click();
await expect(
header.getByRole('combobox').getByText('Français'),
).toBeVisible();
await header.getByRole('combobox').getByText('EN').click();
await header.getByRole('option', { name: 'FR' }).click();
await expect(header.getByRole('combobox').getByText('FR')).toBeVisible();
await expect(
page.getByRole('button', {
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "app-e2e",
"version": "1.5.1",
"version": "1.3.0",
"private": true,
"scripts": {
"lint": "eslint . --ext .ts",
@@ -12,7 +12,7 @@
"test:ui::chromium": "yarn test:ui --project=chromium"
},
"devDependencies": {
"@playwright/test": "1.47.2",
"@playwright/test": "1.47.0",
"@types/node": "*",
"@types/pdf-parse": "1.1.4",
"eslint-config-impress": "*",
@@ -20,7 +20,7 @@
},
"dependencies": {
"convert-stream": "1.0.2",
"jsdom": "25.0.1",
"jsdom": "25.0.0",
"pdf-parse": "1.1.1"
}
}
+1 -2
View File
@@ -1,5 +1,4 @@
NEXT_PUBLIC_API_ORIGIN=
NEXT_PUBLIC_Y_PROVIDER_URL=
NEXT_PUBLIC_MEDIA_URL=
NEXT_PUBLIC_THEME=dsfr
NEXT_PUBLIC_SW_DEACTIVATED=
NEXT_PUBLIC_THEME=dsfr
-8
View File
@@ -189,9 +189,6 @@ const config = {
},
},
},
'la-gauffre': {
activated: false,
},
},
},
dsfr: {
@@ -324,7 +321,6 @@ const config = {
'color-hover': 'var(--c--theme--colors--primary-100)',
},
'color-hover': 'var(--c--theme--colors--primary-text)',
color: 'var(--c--theme--colors--primary-600)',
},
},
datagrid: {
@@ -339,7 +335,6 @@ const config = {
pagination: {
'background-color': 'transparent',
'background-color-active': 'var(--c--theme--colors--primary-300)',
'border-color': 'var(--c--theme--colors--primary-400)',
},
},
'forms-checkbox': {
@@ -389,9 +384,6 @@ const config = {
'forms-textarea': {
'border-radius': '0',
},
'la-gauffre': {
activated: true,
},
},
},
},
+14 -11
View File
@@ -1,6 +1,6 @@
{
"name": "app-impress",
"version": "1.5.1",
"version": "1.3.0",
"private": true,
"scripts": {
"dev": "next dev",
@@ -19,19 +19,22 @@
"@blocknote/mantine": "*",
"@blocknote/react": "*",
"@gouvfr-lasuite/integration": "1.0.2",
"@hocuspocus/provider": "2.13.6",
"@hocuspocus/provider": "2.13.5",
"@openfun/cunningham-react": "2.9.4",
"@tanstack/react-query": "5.56.2",
"@socialgouv/e2esdk-client": "1.0.0-beta.28",
"@socialgouv/e2esdk-devtools": "1.0.0-beta.38",
"@socialgouv/e2esdk-react": "1.0.0-beta.28",
"@tanstack/react-query": "5.55.4",
"i18next": "23.15.1",
"idb": "8.0.0",
"lodash": "4.17.21",
"luxon": "3.5.0",
"next": "14.2.13",
"next": "14.2.9",
"react": "*",
"react-aria-components": "1.3.3",
"react-dom": "*",
"react-i18next": "15.0.2",
"react-select": "5.8.1",
"react-i18next": "15.0.1",
"react-select": "5.8.0",
"styled-components": "6.1.13",
"y-protocols": "1.0.6",
"yjs": "*",
@@ -39,16 +42,16 @@
},
"devDependencies": {
"@svgr/webpack": "8.1.0",
"@tanstack/react-query-devtools": "5.58.0",
"@tanstack/react-query-devtools": "5.55.4",
"@testing-library/dom": "10.4.0",
"@testing-library/jest-dom": "6.5.0",
"@testing-library/react": "16.0.1",
"@testing-library/user-event": "14.5.2",
"@types/jest": "29.5.13",
"@types/lodash": "4.17.9",
"@types/jest": "29.5.12",
"@types/lodash": "4.17.7",
"@types/luxon": "3.4.2",
"@types/node": "*",
"@types/react": "18.3.10",
"@types/react": "18.3.5",
"@types/react-dom": "*",
"cross-env": "*",
"dotenv": "16.4.5",
@@ -62,7 +65,7 @@
"stylelint-config-standard": "36.0.1",
"stylelint-prettier": "5.0.2",
"typescript": "*",
"webpack": "5.95.0",
"webpack": "5.94.0",
"workbox-webpack-plugin": "7.1.0"
}
}
@@ -5,14 +5,6 @@ import { AppWrapper } from '@/tests/utils';
import Page from '../pages';
jest.mock('next/navigation', () => ({
useRouter() {
return {
push: jest.fn(),
};
},
}));
describe('Page', () => {
it('checks Page rendering', () => {
render(<Page />, { wrapper: AppWrapper });
+8 -10
View File
@@ -10,16 +10,14 @@ import { APIError } from './APIError';
import { APIList } from './types';
export type UseQueryOptionsAPI<Q> = UseQueryOptions<Q, APIError, Q>;
export type DefinedInitialDataInfiniteOptionsAPI<
Q,
TPageParam = number,
> = DefinedInitialDataInfiniteOptions<
Q,
APIError,
InfiniteData<Q>,
QueryKey,
TPageParam
>;
export type DefinedInitialDataInfiniteOptionsAPI<Q> =
DefinedInitialDataInfiniteOptions<
Q,
APIError,
InfiniteData<Q>,
QueryKey,
number
>;
/**
* @param param Used for infinite scroll pagination
@@ -1,101 +1,77 @@
@font-face {
font-family: Marianne;
src:
url('Marianne-Thin.woff2') format('woff2'),
url('Marianne-Thin.woff') format('woff');
src: url('Marianne-Thin.woff') format('truetype');
font-weight: 100;
}
@font-face {
font-family: Marianne;
src:
url('Marianne-Thin_Italic.woff2') format('woff2'),
url('Marianne-Thin_Italic.woff') format('woff');
src: url('Marianne-Thin_Italic.woff') format('truetype');
font-weight: 100;
font-style: italic;
}
@font-face {
font-family: Marianne;
src:
url('Marianne-Light.woff2') format('woff2'),
url('Marianne-Light.woff') format('woff');
src: url('Marianne-Light.woff') format('truetype');
font-weight: 300;
}
@font-face {
font-family: Marianne;
src:
url('Marianne-Light_Italic.woff2') format('woff2'),
url('Marianne-Light_Italic.woff') format('woff');
src: url('Marianne-Light_Italic.woff') format('truetype');
font-weight: 300;
font-style: italic;
}
@font-face {
font-family: Marianne;
src:
url('Marianne-Regular.woff2') format('woff2'),
url('Marianne-Regular.woff') format('woff');
src: url('Marianne-Regular.woff') format('truetype');
font-weight: 400;
}
@font-face {
font-family: Marianne;
src:
url('Marianne-Regular_Italic.woff2') format('woff2'),
url('Marianne-Regular_Italic.woff') format('woff');
src: url('Marianne-Regular_Italic.woff') format('truetype');
font-weight: 400;
font-style: italic;
}
@font-face {
font-family: Marianne;
src:
url('Marianne-Medium.woff2') format('woff2'),
url('Marianne-Medium.woff') format('woff');
src: url('Marianne-Medium.woff') format('truetype');
font-weight: 500;
}
@font-face {
font-family: Marianne;
src:
url('Marianne-Medium_Italic.woff2') format('woff2'),
url('Marianne-Medium_Italic.woff') format('woff');
src: url('Marianne-Medium_Italic.woff') format('truetype');
font-weight: 500;
font-style: italic;
}
@font-face {
font-family: Marianne;
src:
url('Marianne-Bold.woff2') format('woff2'),
url('Marianne-Bold.woff') format('woff');
src: url('Marianne-Bold.woff') format('truetype');
font-weight: 700;
}
@font-face {
font-family: Marianne;
src:
url('Marianne-Bold_Italic.woff2') format('woff2'),
url('Marianne-Bold_Italic.woff') format('woff');
src: url('Marianne-Bold_Italic.woff') format('truetype');
font-weight: 700;
font-style: italic;
}
@font-face {
font-family: Marianne;
src:
url('Marianne-ExtraBold.woff2') format('woff2'),
url('Marianne-ExtraBold.woff') format('woff');
src: url('Marianne-ExtraBold.woff') format('truetype');
font-weight: 800;
}
@font-face {
font-family: Marianne;
src:
url('Marianne-ExtraBold_Italic.woff2') format('woff2'),
url('Marianne-ExtraBold_Italic.woff') format('woff');
src: url('Marianne-ExtraBold_Italic.woff') format('truetype');
font-weight: 800;
font-style: italic;
}
@@ -21,7 +21,7 @@ export interface BoxProps {
$effect?: 'show' | 'hide';
$flex?: boolean;
$gap?: CSSProperties['gap'];
$hasTransition?: boolean | 'slow';
$hasTransition?: boolean;
$height?: CSSProperties['height'];
$justify?: CSSProperties['justifyContent'];
$overflow?: CSSProperties['overflow'];
@@ -33,7 +33,6 @@ export interface BoxProps {
$padding?: MarginPadding;
$position?: CSSProperties['position'];
$radius?: CSSProperties['borderRadius'];
$shrink?: CSSProperties['flexShrink'];
$transition?: CSSProperties['transition'];
$width?: CSSProperties['width'];
$wrap?: CSSProperties['flexWrap'];
@@ -54,11 +53,7 @@ export const Box = styled('div')<BoxProps>`
${({ $gap }) => $gap && `gap: ${$gap};`}
${({ $height }) => $height && `height: ${$height};`}
${({ $hasTransition }) =>
$hasTransition && $hasTransition === 'slow'
? `transition: all 0.5s ease-in-out;`
: $hasTransition
? `transition: all 0.3s ease-in-out;`
: ''}
$hasTransition && `transition: all 0.3s ease-in-out;`}
${({ $justify }) => $justify && `justify-content: ${$justify};`}
${({ $margin }) => $margin && stylesMargin($margin)}
${({ $maxHeight }) => $maxHeight && `max-height: ${$maxHeight};`}
@@ -69,7 +64,6 @@ export const Box = styled('div')<BoxProps>`
${({ $padding }) => $padding && stylesPadding($padding)}
${({ $position }) => $position && `position: ${$position};`}
${({ $radius }) => $radius && `border-radius: ${$radius};`}
${({ $shrink }) => $shrink && `flex-shrink: ${$shrink};`}
${({ $transition }) => $transition && `transition: ${$transition};`}
${({ $width }) => $width && `width: ${$width};`}
${({ $wrap }) => $wrap && `flex-wrap: ${$wrap};`}
@@ -24,8 +24,6 @@ const BoxButton = forwardRef<HTMLDivElement, BoxType>(
ref={ref}
as="button"
$background="none"
$margin="none"
$padding="none"
$css={`
cursor: pointer;
border: none;
@@ -13,12 +13,8 @@ export const IconBG = ({ iconName, ...textProps }: IconBGProps) => {
$isMaterialIcon
$size="36px"
$theme="primary"
$variation="600"
$background={colorsTokens()['primary-bg']}
$css={`
border: 1px solid ${colorsTokens()['primary-200']};
user-select: none;
`}
$css={`border: 1px solid ${colorsTokens()['primary-200']}`}
$radius="12px"
$padding="4px"
$margin="auto"
@@ -42,7 +38,6 @@ export const IconOptions = ({ isOpen, ...props }: IconOptionsProps) => {
$css={`
transition: all 0.3s ease-in-out;
transform: rotate(${isOpen ? '90' : '0'}deg);
user-select: none;
`}
>
more_vert
@@ -0,0 +1,101 @@
import React, { PropsWithChildren, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Card, IconBG, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
interface PanelProps {
title: string;
setIsPanelOpen: (isOpen: boolean) => void;
}
export const Panel = ({
children,
title,
setIsPanelOpen,
}: PropsWithChildren<PanelProps>) => {
const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
setIsOpen(true);
}, []);
const closedOverridingStyles = !isOpen && {
$width: '0',
$maxWidth: '0',
$minWidth: '0',
};
const transition = 'all 0.5s ease-in-out';
return (
<Card
$width="100%"
$maxWidth="20rem"
$position="sticky"
$maxHeight="96vh"
$height="100%"
$css={`
top: 2vh;
transition: ${transition};
${
!isOpen &&
`
box-shadow: none;
border: none;
`
}
`}
aria-label={t('Document panel')}
{...closedOverridingStyles}
>
<Box
$overflow="hidden"
$css={`
opacity: ${isOpen ? '1' : '0'};
transition: ${transition};
`}
>
<Box
$padding={{ all: 'small' }}
$direction="row"
$align="center"
$justify="center"
$css={`border-top: 2px solid ${colorsTokens()['primary-600']};`}
>
<IconBG
iconName="menu_open"
aria-label={isOpen ? t('Close the panel') : t('Open the panel')}
$background="transparent"
$size="h2"
$zIndex={1}
$css={`
cursor: pointer;
left: 0rem;
top: 0.1rem;
transition: ${transition};
transform: rotate(180deg);
opacity: ${isOpen ? '1' : '0'};
user-select: none;
`}
$position="absolute"
onClick={() => {
setIsOpen(false);
setTimeout(() => {
setIsPanelOpen(false);
}, 400);
}}
$radius="2px"
/>
<Text $weight="bold" $size="l" $theme="primary">
{title}
</Text>
</Box>
{children}
</Box>
</Card>
);
};
@@ -1,34 +1,25 @@
import { Alert, VariantType } from '@openfun/cunningham-react';
import { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import styled from 'styled-components';
import { Box, Text, TextType } from '@/components';
const AlertStyled = styled(Alert)`
& .c__button--tertiary:hover {
background-color: var(--c--theme--colors--greyscale-200);
}
`;
interface TextErrorsProps extends TextType {
causes?: string[];
defaultMessage?: string;
icon?: ReactNode;
canClose?: boolean;
}
export const TextErrors = ({
causes,
defaultMessage,
icon,
canClose = false,
...textProps
}: TextErrorsProps) => {
const { t } = useTranslation();
return (
<AlertStyled canClose={canClose} type={VariantType.ERROR} icon={icon}>
<Alert canClose={false} type={VariantType.ERROR} icon={icon}>
<Box $direction="column" $gap="0.2rem">
{causes &&
causes.map((cause, i) => (
@@ -48,6 +39,6 @@ export const TextErrors = ({
</Text>
)}
</Box>
</AlertStyled>
</Alert>
);
};
@@ -1,10 +1,10 @@
import { CunninghamProvider } from '@openfun/cunningham-react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useEffect } from 'react';
import { E2ESDKClientProvider } from '@socialgouv/e2esdk-react';
import { e2esdkClient } from './auth/useAuthStore';
import { useCunninghamTheme } from '@/cunningham';
import '@/i18n/initI18n';
import { useResponsiveStore } from '@/stores/';
import { Auth } from './auth/';
@@ -19,7 +19,6 @@ const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 3,
retry: 1,
},
},
});
@@ -27,19 +26,12 @@ const queryClient = new QueryClient({
export function AppProvider({ children }: { children: React.ReactNode }) {
const { theme } = useCunninghamTheme();
const initializeResizeListener = useResponsiveStore(
(state) => state.initializeResizeListener,
);
useEffect(() => {
const cleanupResizeListener = initializeResizeListener();
return cleanupResizeListener;
}, [initializeResizeListener]);
return (
<QueryClientProvider client={queryClient}>
<CunninghamProvider theme={theme}>
<Auth>{children}</Auth>
<E2ESDKClientProvider client={e2esdkClient}>
<Auth>{children}</Auth>
</E2ESDKClientProvider>
</CunninghamProvider>
</QueryClientProvider>
);
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
import { useAuthStore } from '@/core/auth';
export const ButtonLogin = () => {
export const AccountDropdown = () => {
const { t } = useTranslation();
const { logout, authenticated, login } = useAuthStore();
@@ -17,9 +17,8 @@ import { useAuthStore } from './useAuthStore';
const regexpUrlsAuth = [/\/docs\/$/g, /^\/$/g];
export const Auth = ({ children }: PropsWithChildren) => {
const { initAuth, initiated, authenticated, login, getAuthUrl } =
useAuthStore();
const { asPath, replace } = useRouter();
const { initAuth, initiated, authenticated, login } = useAuthStore();
const { asPath } = useRouter();
const [pathAllowed, setPathAllowed] = useState<boolean>(
!regexpUrlsAuth.some((regexp) => !!asPath.match(regexp)),
@@ -42,18 +41,6 @@ export const Auth = ({ children }: PropsWithChildren) => {
login();
}, [authenticated, pathAllowed, login, initiated]);
// Redirect to the path before login
useEffect(() => {
if (!authenticated) {
return;
}
const authUrl = getAuthUrl();
if (authUrl) {
void replace(authUrl);
}
}, [authenticated, getAuthUrl, replace]);
if ((!initiated && pathAllowed) || (!authenticated && !pathAllowed)) {
return (
<Box $height="100vh" $width="100vw" $align="center" $justify="center">
@@ -2,10 +2,12 @@
* Represents user retrieved from the API.
* @interface User
* @property {string} id - The id of the user.
* @property {string} sub - The `sub` field of OIDC
* @property {string} email - The email of the user.
* @property {string} name - The name of the user.
*/
export interface User {
id: string;
sub: string;
email: string;
}
@@ -1,4 +1,4 @@
export * from './AccountDropdown';
export * from './api/types';
export * from './Auth';
export * from './ButtonLogin';
export * from './useAuthStore';
@@ -5,58 +5,96 @@ import { baseApiUrl } from '@/core/conf';
import { User, getMe } from './api';
import { PATH_AUTH_LOCAL_STORAGE } from './conf';
import { Client, PublicUserIdentity } from '@socialgouv/e2esdk-client';
import { identity } from 'lodash';
export const e2esdkClient = new Client({
// Point it to where your server is listening
serverURL: 'https://app-a5a1b445-32e0-4cf4-a478-821a48f86ccf.cleverapps.io',
// Pass the signature public key you configured for the server
serverSignaturePublicKey: 'ayfva9SUh0mfgmifUtxcdLp4HriHJiqefEKnvYgY4qM',
});
interface AuthStore {
initiated: boolean;
authenticated: boolean;
readyForEncryption: boolean;
initAuth: () => void;
logout: () => void;
login: () => void;
setAuthUrl: (url: string) => void;
getAuthUrl: () => string | undefined;
endToEndData?: PublicUserIdentity;
userData?: User;
}
const initialState = {
initiated: false,
authenticated: false,
readyForEncryption: false,
userData: undefined,
};
export const useAuthStore = create<AuthStore>((set, get) => ({
export const useAuthStore = create<AuthStore>((set) => ({
initiated: initialState.initiated,
authenticated: initialState.authenticated,
userData: initialState.userData,
readyForEncryption: initialState.readyForEncryption,
initAuth: () => {
getMe()
.then((data: User) => {
set({ authenticated: true, userData: data });
.then(
(data: User) => {
// If a path is stored in the local storage, we redirect to it
const path_auth = localStorage.getItem(PATH_AUTH_LOCAL_STORAGE);
if (path_auth) {
localStorage.removeItem(PATH_AUTH_LOCAL_STORAGE);
window.location.replace(path_auth);
return;
}
set({ authenticated: true, userData: data });
return e2esdkClient
.signup(data.sub)
.then(() => data)
.catch(() => data);
},
() => {},
)
.then(
(data) => {
set({ readyForEncryption: true });
if (data) {
return e2esdkClient.login(data.sub);
}
},
(e) => {
throw e;
//if (data) {
// return e2esdkClient.login(data.sub);
//}
//fail
},
)
.then((publicIdentity: PublicUserIdentity | null | undefined) => {
if (!publicIdentity) throw Error('exploding');
console.log('publicIdentity', publicIdentity);
set({ endToEndData: publicIdentity });
})
.catch(() => {})
.finally(() => {
console.log('finally');
set({ initiated: true });
});
},
login: () => {
get().setAuthUrl(window.location.pathname);
// If we try to access a specific page and we are not authenticated
// we store the path in the local storage to redirect to it after login
if (window.location.pathname !== '/') {
localStorage.setItem(PATH_AUTH_LOCAL_STORAGE, window.location.pathname);
}
window.location.replace(`${baseApiUrl()}authenticate/`);
},
logout: () => {
window.location.replace(`${baseApiUrl()}logout/`);
},
// If we try to access a specific page and we are not authenticated
// we store the path in the local storage to redirect to it after login
setAuthUrl() {
if (window.location.pathname !== '/') {
localStorage.setItem(PATH_AUTH_LOCAL_STORAGE, window.location.pathname);
}
},
// If a path is stored in the local storage, we return it then remove it
getAuthUrl() {
const path_auth = localStorage.getItem(PATH_AUTH_LOCAL_STORAGE);
if (path_auth) {
localStorage.removeItem(PATH_AUTH_LOCAL_STORAGE);
return path_auth;
}
},
}));
@@ -150,12 +150,6 @@ input:-webkit-autofill:focus {
border-color: var(--c--components--forms-select--border-color-disabled-hover);
}
.c__select--disabled .c__select__wrapper label,
.c__select--disabled .c__select__wrapper input,
.c__select--disabled .c__select__wrapper {
cursor: not-allowed;
}
.c__select__menu__item {
transition: all var(--c--theme--transitions--duration)
var(--c--theme--transitions--ease-out);
@@ -255,7 +249,6 @@ input:-webkit-autofill:focus {
gap: 3px;
border-radius: 4px;
background: var(--c--components--datagrid--pagination--background-color);
border-color: var(--c--components--datagrid--pagination--border-color);
}
.c__pagination__list .c__button--tertiary-text.c__button--active {
@@ -320,14 +313,6 @@ input:-webkit-autofill:focus {
font-size: var(--c--components--forms-checkbox--text--size);
}
.c__checkbox.c__checkbox--disabled .c__field__text {
color: var(--c--theme--colors--greyscale-600);
}
.c__switch.c__checkbox--disabled .c__switch__rail {
cursor: not-allowed;
}
/**
* Button
*/
@@ -453,7 +438,6 @@ input:-webkit-autofill:focus {
.c__button--tertiary-text {
border: none;
color: var(--c--components--button--tertiary-text--color);
}
.c__button--tertiary-text:hover,
@@ -509,23 +493,6 @@ input:-webkit-autofill:focus {
overflow-y: auto;
}
@media screen and (width <= 420px) {
.c__modal__scroller {
padding: 0.7rem;
}
.c__modal__title h2 {
font-size: 1rem;
}
}
@media (width <= 576px) {
.c__modal__footer--sided {
gap: 0.5rem;
flex-direction: column-reverse;
}
}
/**
* Toast
*/
@@ -283,7 +283,6 @@
);
--c--components--button--disabled--color: white;
--c--components--button--disabled--background--color: #b3cef0;
--c--components--la-gauffre--activated: false;
}
.cunningham-theme--dark {
@@ -452,9 +451,6 @@
--c--components--button--tertiary-text--color-hover: var(
--c--theme--colors--primary-text
);
--c--components--button--tertiary-text--color: var(
--c--theme--colors--primary-600
);
--c--components--datagrid--header--color: var(
--c--theme--colors--primary-text
);
@@ -465,9 +461,6 @@
--c--components--datagrid--pagination--background-color-active: var(
--c--theme--colors--primary-300
);
--c--components--datagrid--pagination--border-color: var(
--c--theme--colors--primary-400
);
--c--components--forms-checkbox--border-radius: 0;
--c--components--forms-checkbox--color: var(--c--theme--colors--primary-text);
--c--components--forms-checkbox--text--color: var(
@@ -511,7 +504,6 @@
--c--theme--colors--primary-text
);
--c--components--forms-textarea--border-radius: 0;
--c--components--la-gauffre--activated: true;
}
.clr-secondary-text {
@@ -276,7 +276,6 @@ export const tokens = {
},
disabled: { color: 'white', background: { color: '#b3cef0' } },
},
'la-gauffre': { activated: false },
},
},
dark: {
@@ -451,7 +450,6 @@ export const tokens = {
'color-hover': 'var(--c--theme--colors--primary-100)',
},
'color-hover': 'var(--c--theme--colors--primary-text)',
color: 'var(--c--theme--colors--primary-600)',
},
},
datagrid: {
@@ -466,7 +464,6 @@ export const tokens = {
pagination: {
'background-color': 'transparent',
'background-color-active': 'var(--c--theme--colors--primary-300)',
'border-color': 'var(--c--theme--colors--primary-400)',
},
},
'forms-checkbox': {
@@ -506,7 +503,6 @@ export const tokens = {
'accent-color': 'var(--c--theme--colors--primary-text)',
},
'forms-textarea': { 'border-radius': '0' },
'la-gauffre': { activated: true },
},
},
},
@@ -1,4 +1,4 @@
import { BlockNoteEditor as BlockNoteEditorCore } from '@blocknote/core';
import { Block, BlockNoteEditor as BlockNoteEditorCore, PartialBlock } from '@blocknote/core';
import '@blocknote/core/fonts/inter.css';
import { BlockNoteView } from '@blocknote/mantine';
import '@blocknote/mantine/style.css';
@@ -13,19 +13,16 @@ import { Version } from '@/features/docs/doc-versioning/';
import { useCreateDocAttachment } from '../api/useCreateDocUpload';
import useSaveDoc from '../hook/useSaveDoc';
import { useDocStore, useHeadingStore } from '../stores';
import { useDocStore } from '../stores';
import { randomColor } from '../utils';
import { BlockNoteToolbar } from './BlockNoteToolbar';
import { useE2ESDKClient } from '@socialgouv/e2esdk-react';
const cssEditor = (readonly: boolean) => `
const cssEditor = `
&, & > .bn-container, & .ProseMirror {
height:100%
};
& .bn-editor {
padding-right: 30px;
${readonly && `padding-left: 30px;`}
};
& .collaboration-cursor__caret.ProseMirror-widget{
word-wrap: initial;
}
@@ -34,35 +31,6 @@ const cssEditor = (readonly: boolean) => `
padding: 2px;
border-radius: 4px;
}
@media screen and (width <= 560px) {
& .bn-editor {
padding-left: 40px;
padding-right: 10px;
${readonly && `padding-left: 10px;`}
};
.bn-side-menu[data-block-type=heading][data-level="1"] {
height: 46px;
}
.bn-side-menu[data-block-type=heading][data-level="2"] {
height: 40px;
}
.bn-side-menu[data-block-type=heading][data-level="3"] {
height: 40px;
}
& .bn-editor h1 {
font-size: 1.6rem;
}
& .bn-editor h2 {
font-size: 1.35rem;
}
& .bn-editor h3 {
font-size: 1.2rem;
}
.bn-block-content[data-is-empty-and-focused][data-content-type="paragraph"]
.bn-inline-content:has(> .ProseMirror-trailingBreak:only-child)::before {
font-size: 14px;
}
}
`;
interface BlockNoteEditorProps {
@@ -103,16 +71,15 @@ export const BlockNoteContent = ({
const isVersion = doc.id !== storeId;
const { userData } = useAuthStore();
const { setStore, docsStore } = useDocStore();
const canSave = doc.abilities.partial_update && !isVersion;
const readOnly = !doc.abilities.partial_update || isVersion;
useSaveDoc(doc.id, provider.document, !readOnly);
const e2eClient = useE2ESDKClient();
const storedEditor = docsStore?.[storeId]?.editor;
const {
mutateAsync: createDocAttachment,
isError: isErrorAttachment,
error: errorAttachment,
} = useCreateDocAttachment();
const { setHeadings, resetHeadings } = useHeadingStore();
const uploadFile = useCallback(
async (file: File) => {
@@ -134,51 +101,63 @@ export const BlockNoteContent = ({
return storedEditor;
}
// TODO decrypt doc.content
//localStorage.getItem('KEY');
const docId = doc.id;
const purpose = `docs:${docId}`;
const key = e2eClient.findKeyByPurpose(purpose);
console.log('purpose', purpose, 'key', key);
let plaintextContent: Array<PartialBlock> | undefined;
if (!key) {
alert('probleme de key');
return;
} else {
if (doc.content) {
plaintextContent = JSON.parse(e2eClient.decrypt(
doc.content,
key.keychainFingerprint,
) as string) as Array<PartialBlock>;
console.log('decryptedMessage', plaintextContent);
} else {
plaintextContent = undefined;
}
}
return BlockNoteEditorCore.create({
collaboration: {
provider,
fragment: provider.document.getXmlFragment('document-store'),
user: {
name: userData?.email || 'Anonymous',
color: randomColor(),
},
},
// collaboration: {
// provider,
// fragment: provider.document.getXmlFragment('document-store'),
// user: {
// name: userData?.email || 'Anonymous',
// color: randomColor(),
// },
// },
uploadFile,
initialContent: plaintextContent,
});
}, [provider, storedEditor, uploadFile, userData?.email]);
}, [doc.content, storedEditor, uploadFile]);
console.log("useSaveDoc", doc.id, provider.document, canSave, editor);
useSaveDoc(doc.id, provider.document, canSave, editor);
useEffect(() => {
setStore(storeId, { editor });
}, [setStore, storeId, editor]);
useEffect(() => {
setHeadings(editor);
editor?.onEditorContentChange(() => {
setHeadings(editor);
});
return () => {
resetHeadings();
};
}, [editor, resetHeadings, setHeadings]);
return (
<Box $css={cssEditor(readOnly)}>
<Box $css={cssEditor}>
{isErrorAttachment && (
<Box $margin={{ bottom: 'big' }}>
<TextErrors
causes={errorAttachment.cause}
canClose
$textAlign="left"
/>
<TextErrors causes={errorAttachment.cause} />
</Box>
)}
<BlockNoteView
editor={editor}
formattingToolbar={false}
editable={!readOnly}
editable={doc.abilities.partial_update && !isVersion}
theme="light"
>
<BlockNoteToolbar />
@@ -9,10 +9,12 @@ import {
NestBlockButton,
TextAlignButton,
UnnestBlockButton,
useBlockNoteEditor,
useComponentsContext,
useSelectedBlocks,
} from '@blocknote/react';
import React from 'react';
import { MarkdownButton } from './MarkdownButton';
import { forEach, isArray } from 'lodash';
import React, { useMemo } from 'react';
export const BlockNoteToolbar = () => {
return (
@@ -55,3 +57,79 @@ export const BlockNoteToolbar = () => {
/>
);
};
type Block = {
type: string;
text: string;
content: Block[];
};
function isBlock(block: Block): block is Block {
return (
block.content &&
isArray(block.content) &&
block.content.length > 0 &&
typeof block.type !== 'undefined'
);
}
const recursiveContent = (content: Block[], base: string = '') => {
let fullContent = base;
for (const innerContent of content) {
if (innerContent.type === 'text') {
fullContent += innerContent.text;
} else if (isBlock(innerContent)) {
fullContent = recursiveContent(innerContent.content, fullContent);
}
}
return fullContent;
};
/**
* Custom Formatting Toolbar Button to convert markdown to json.
*/
export function MarkdownButton() {
const editor = useBlockNoteEditor();
const Components = useComponentsContext();
const selectedBlocks = useSelectedBlocks(editor);
const handleConvertMarkdown = () => {
const blocks = editor.getSelection()?.blocks;
forEach(blocks, async (block) => {
if (!isBlock(block as unknown as Block)) {
return;
}
try {
const fullContent = recursiveContent(
block.content as unknown as Block[],
);
const blockMarkdown =
await editor.tryParseMarkdownToBlocks(fullContent);
editor.replaceBlocks([block.id], blockMarkdown);
} catch (error) {
console.error('Error parsing Markdown:', error);
}
});
};
const show = useMemo(() => {
return !!selectedBlocks.find((block) => block.content !== undefined);
}, [selectedBlocks]);
if (!show || !editor.isEditable || !Components) {
return null;
}
return (
<Components.FormattingToolbar.Button
mainTooltip="Convert Markdown"
onClick={handleConvertMarkdown}
>
M
</Components.FormattingToolbar.Button>
);
}
@@ -5,16 +5,19 @@ import React from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Card, Text, TextErrors } from '@/components';
import { Panel } from '@/components/Panel';
import { useCunninghamTheme } from '@/cunningham';
import { DocHeader } from '@/features/docs/doc-header';
import { Doc } from '@/features/docs/doc-management';
import { Versions, useDocVersion } from '@/features/docs/doc-versioning/';
import { useResponsiveStore } from '@/stores';
import { useHeadingStore } from '../stores';
import { Summary, useDocSummaryStore } from '@/features/docs/doc-summary';
import {
VersionList,
Versions,
useDocVersion,
useDocVersionStore,
} from '@/features/docs/doc-versioning/';
import { BlockNoteEditor } from './BlockNoteEditor';
import { IconOpenPanelEditor, PanelEditor } from './PanelEditor';
interface DocEditorProps {
doc: Doc;
@@ -24,9 +27,10 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
const {
query: { versionId },
} = useRouter();
const { isPanelVersionOpen, setIsPanelVersionOpen } = useDocVersionStore();
const { isPanelSummaryOpen, setIsPanelSummaryOpen } = useDocSummaryStore();
const { t } = useTranslation();
const { headings } = useHeadingStore();
const { isMobile } = useResponsiveStore();
const isVersion = versionId && typeof versionId === 'string';
@@ -53,24 +57,26 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
$background={colorsTokens()['primary-bg']}
$height="100%"
$direction="row"
$margin={{ all: isMobile ? 'tiny' : 'small', top: 'none' }}
$css="overflow-x: clip;"
$position="relative"
$margin={{ all: 'small', top: 'none' }}
$gap="1rem"
>
<Card
$padding={isMobile ? 'small' : 'big'}
$css="flex:1;"
$overflow="auto"
$position="relative"
>
<Card $padding="big" $css="flex:1;" $overflow="auto">
{isVersion ? (
<DocVersionEditor doc={doc} versionId={versionId} />
) : (
<BlockNoteEditor doc={doc} />
)}
{!isMobile && <IconOpenPanelEditor headings={headings} />}
</Card>
<PanelEditor doc={doc} headings={headings} />
{doc.abilities.versions_list && isPanelVersionOpen && (
<Panel title={t('VERSIONS')} setIsPanelOpen={setIsPanelVersionOpen}>
<VersionList doc={doc} />
</Panel>
)}
{isPanelSummaryOpen && (
<Panel title={t('SUMMARY')} setIsPanelOpen={setIsPanelSummaryOpen}>
<Summary doc={doc} />
</Panel>
)}
</Box>
</>
);
@@ -1,84 +0,0 @@
import '@blocknote/mantine/style.css';
import {
useBlockNoteEditor,
useComponentsContext,
useSelectedBlocks,
} from '@blocknote/react';
import { forEach, isArray } from 'lodash';
import React, { useMemo } from 'react';
type Block = {
type: string;
text: string;
content: Block[];
};
function isBlock(block: Block): block is Block {
return (
block.content &&
isArray(block.content) &&
block.content.length > 0 &&
typeof block.type !== 'undefined'
);
}
const recursiveContent = (content: Block[], base: string = '') => {
let fullContent = base;
for (const innerContent of content) {
if (innerContent.type === 'text') {
fullContent += innerContent.text;
} else if (isBlock(innerContent)) {
fullContent = recursiveContent(innerContent.content, fullContent);
}
}
return fullContent;
};
/**
* Custom Formatting Toolbar Button to convert markdown to json.
*/
export function MarkdownButton() {
const editor = useBlockNoteEditor();
const Components = useComponentsContext();
const selectedBlocks = useSelectedBlocks(editor);
const handleConvertMarkdown = () => {
const blocks = editor.getSelection()?.blocks;
forEach(blocks, async (block) => {
if (!isBlock(block as unknown as Block)) {
return;
}
try {
const fullContent = recursiveContent(
block.content as unknown as Block[],
);
const blockMarkdown =
await editor.tryParseMarkdownToBlocks(fullContent);
editor.replaceBlocks([block.id], blockMarkdown);
} catch (error) {
console.error('Error parsing Markdown:', error);
}
});
};
const show = useMemo(() => {
return !!selectedBlocks.find((block) => block.content !== undefined);
}, [selectedBlocks]);
if (!show || !editor.isEditable || !Components) {
return null;
}
return (
<Components.FormattingToolbar.Button
mainTooltip="Convert Markdown"
onClick={handleConvertMarkdown}
>
M
</Components.FormattingToolbar.Button>
);
}
@@ -1,207 +0,0 @@
import React, { PropsWithChildren, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, BoxButton, Card, IconBG, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Doc } from '@/features/docs/doc-management';
import { TableContent } from '@/features/docs/doc-table-content';
import { VersionList } from '@/features/docs/doc-versioning';
import { useResponsiveStore } from '@/stores';
import { usePanelEditorStore } from '../stores';
import { HeadingBlock } from '../types';
interface PanelProps {
doc: Doc;
headings: HeadingBlock[];
}
export const PanelEditor = ({
doc,
headings,
}: PropsWithChildren<PanelProps>) => {
const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
const { isMobile } = useResponsiveStore();
const { isPanelTableContentOpen, setIsPanelTableContentOpen, isPanelOpen } =
usePanelEditorStore();
return (
<Card
$width="100%"
$maxWidth="20rem"
$position={isMobile ? 'absolute' : 'sticky'}
$height="100%"
$hasTransition="slow"
$css={`
top: 0vh;
right: 0;
transform: translateX(0%);
flex: 1;
margin-left: 1rem;
${
!isPanelOpen &&
`
transform: translateX(200%);
opacity: 0;
flex: 0;
margin-left: 0rem;
max-width: 0rem;
`
}
`}
aria-label={t('Document panel')}
aria-hidden={!isPanelOpen}
>
<Box
$overflow="inherit"
$position="sticky"
$hasTransition="slow"
$css={`
top: 0;
opacity: ${isPanelOpen ? '1' : '0'};
`}
$maxHeight="99vh"
>
{isMobile && <IconOpenPanelEditor headings={headings} />}
<Box
$direction="row"
$justify="space-between"
$align="center"
$position="relative"
$background={colorsTokens()['primary-400']}
$margin={{ bottom: 'tiny' }}
$radius="4px 4px 0 0"
>
<Box
$background="white"
$position="absolute"
$height="100%"
$width={doc.abilities.versions_list ? '50%' : '100%'}
$hasTransition="slow"
$css={`
border-top: 2px solid ${colorsTokens()['primary-600']};
border-radius: 0 4px 0 0;
${
isPanelTableContentOpen
? `
transform: translateX(0);
border-radius: 4px 0 0 0;
`
: `transform: translateX(100%);`
}
`}
/>
<BoxButton
$minWidth={doc.abilities.versions_list ? '50%' : '100%'}
onClick={() => setIsPanelTableContentOpen(true)}
$zIndex={1}
>
<Text
$width="100%"
$weight="bold"
$size="m"
$theme="primary"
$variation="600"
$padding={{ vertical: 'small', horizontal: 'small' }}
>
{t('Table of content')}
</Text>
</BoxButton>
{doc.abilities.versions_list && (
<BoxButton
$minWidth="50%"
onClick={() => setIsPanelTableContentOpen(false)}
$zIndex={1}
>
<Text
$width="100%"
$weight="bold"
$size="m"
$theme="primary"
$variation="600"
$padding={{ vertical: 'small', horizontal: 'small' }}
>
{t('Versions')}
</Text>
</BoxButton>
)}
</Box>
{isPanelTableContentOpen && (
<TableContent doc={doc} headings={headings} />
)}
{!isPanelTableContentOpen && doc.abilities.versions_list && (
<VersionList doc={doc} />
)}
</Box>
</Card>
);
};
interface IconOpenPanelEditorProps {
headings: HeadingBlock[];
}
export const IconOpenPanelEditor = ({ headings }: IconOpenPanelEditorProps) => {
const { t } = useTranslation();
const { setIsPanelOpen, isPanelOpen, setIsPanelTableContentOpen } =
usePanelEditorStore();
const [hasBeenOpen, setHasBeenOpen] = useState(isPanelOpen);
const { isMobile } = useResponsiveStore();
const setClosePanel = () => {
setHasBeenOpen(true);
setIsPanelOpen(!isPanelOpen);
};
// Open the panel if there are more than 1 heading
useEffect(() => {
if (headings?.length && headings.length > 1 && !hasBeenOpen && !isMobile) {
setIsPanelTableContentOpen(true);
setIsPanelOpen(true);
setHasBeenOpen(true);
}
}, [
headings,
setIsPanelTableContentOpen,
setIsPanelOpen,
hasBeenOpen,
isMobile,
]);
// If open from the doc header we set the state as well
useEffect(() => {
if (isPanelOpen && !hasBeenOpen) {
setHasBeenOpen(true);
}
}, [hasBeenOpen, isPanelOpen]);
// Close the panel unmount
useEffect(() => {
return () => {
setIsPanelOpen(false);
};
}, [setIsPanelOpen]);
return (
<IconBG
iconName="menu_open"
aria-label={isPanelOpen ? t('Close the panel') : t('Open the panel')}
$background="transparent"
$size="h2"
$zIndex={10}
$hasTransition="slow"
$css={`
cursor: pointer;
right: 0rem;
top: 0.1rem;
transform: rotate(${isPanelOpen ? '180deg' : '0deg'});
user-select: none;
${hasBeenOpen ? 'display:flex;' : 'display: none;'}
`}
$position="absolute"
onClick={setClosePanel}
$radius="2px"
/>
);
};
@@ -1,23 +1,30 @@
import { BlockNoteEditor } from '@blocknote/core';
import { useRouter } from 'next/router';
import { useCallback, useEffect, useRef, useState } from 'react';
import * as Y from 'yjs';
import { useUpdateDoc } from '@/features/docs/doc-management/';
import { KEY_LIST_DOC_VERSIONS } from '@/features/docs/doc-versioning';
import { isFirefox } from '@/utils/userAgent';
import { toBase64 } from '../utils';
import { useE2ESDKClient } from '@socialgouv/e2esdk-react';
const useSaveDoc = (docId: string, doc: Y.Doc, canSave: boolean) => {
const useSaveDoc = (
docId: string,
doc: Y.Doc,
canSave: boolean,
editor: BlockNoteEditor,
) => {
const { mutate: updateDoc } = useUpdateDoc({
listInvalideQueries: [KEY_LIST_DOC_VERSIONS],
});
const e2eClient = useE2ESDKClient();
const [initialDoc, setInitialDoc] = useState<string>(
toBase64(Y.encodeStateAsUpdate(doc)),
JSON.stringify(editor.document)
);
useEffect(() => {
setInitialDoc(toBase64(Y.encodeStateAsUpdate(doc)));
setInitialDoc(JSON.stringify(editor.document));
}, [doc]);
/**
@@ -33,7 +40,7 @@ const useSaveDoc = (docId: string, doc: Y.Doc, canSave: boolean) => {
transaction: Y.Transaction,
) => {
if (!transaction.local) {
setInitialDoc(toBase64(Y.encodeStateAsUpdate(updatedDoc)));
setInitialDoc(JSON.stringify(editor.document));
}
};
@@ -48,23 +55,41 @@ const useSaveDoc = (docId: string, doc: Y.Doc, canSave: boolean) => {
* Check if the doc has been updated and can be saved.
*/
const hasChanged = useCallback(() => {
const newDoc = toBase64(Y.encodeStateAsUpdate(doc));
return initialDoc !== newDoc;
return initialDoc !== JSON.stringify(editor.document);
}, [doc, initialDoc]);
const shouldSave = useCallback(() => {
console.log('hasChanged', hasChanged(), 'canSave', canSave);
return hasChanged() && canSave;
}, [canSave, hasChanged]);
const saveDoc = useCallback(() => {
const newDoc = toBase64(Y.encodeStateAsUpdate(doc));
const newDoc = JSON.stringify(editor.document);
//const newDoc = toBase64(Y.encodeStateAsUpdate(doc));
// TODO encode the content
const purpose = `docs:${docId}`;
const key = e2eClient.findKeyByPurpose(purpose);
console.log("purpose", purpose, "key", key);
if (!key) {
alert('probleme de key');
return;
}
const encrypted = e2eClient.encrypt(newDoc, key.keychainFingerprint);
console.log('encrypted', encrypted);
// todo
setInitialDoc(newDoc);
updateDoc({
id: docId,
content: newDoc,
content: encrypted,
});
}, [doc, docId, updateDoc]);
}, [docId, editor?.document, updateDoc]);
const timeout = useRef<NodeJS.Timeout>();
const router = useRouter();
@@ -75,10 +100,12 @@ const useSaveDoc = (docId: string, doc: Y.Doc, canSave: boolean) => {
}
const onSave = (e?: Event) => {
console.log('entered onSave');
if (!shouldSave()) {
return;
}
console.log('will save');
saveDoc();
/**
@@ -88,13 +115,16 @@ const useSaveDoc = (docId: string, doc: Y.Doc, canSave: boolean) => {
* if he wants to leave the page, by adding the popup, we let the time to the
* request to be sent, and intercepted by the service worker (for the offline part).
*/
if (typeof e !== 'undefined' && e.preventDefault && isFirefox()) {
const isFirefox =
navigator.userAgent.toLowerCase().indexOf('firefox') > -1;
if (typeof e !== 'undefined' && e.preventDefault && isFirefox) {
e.preventDefault();
}
};
// Save every minute
timeout.current = setInterval(onSave, 60000);
timeout.current = setInterval(onSave, 1000);
// Save when the user leaves the page
addEventListener('beforeunload', onSave);
// Save when the user navigates to another page
@@ -1,4 +1,3 @@
export * from './components';
export * from './stores';
export * from './types';
export * from './utils';
@@ -1,3 +1 @@
export * from './useDocStore';
export * from './useHeadingStore';
export * from './usePanelEditorStore';
@@ -6,8 +6,6 @@ import { create } from 'zustand';
import { providerUrl } from '@/core';
import { Base64 } from '@/features/docs/doc-management';
import { blocksToYDoc } from '../utils';
interface DocStore {
provider: HocuspocusProvider;
editor?: BlockNoteEditor;
@@ -28,18 +26,9 @@ export const useDocStore = create<UseDocStore>((set, get) => ({
guid: storeId,
});
if (initialDoc) {
Y.applyUpdate(doc, Buffer.from(initialDoc, 'base64'));
} else {
const initialDocContent = [
{
type: 'heading',
content: '',
},
];
blocksToYDoc(initialDocContent, doc);
}
// if (initialDoc) {
// Y.applyUpdate(doc, Buffer.from(initialDoc, 'base64'));
// }
const provider = new HocuspocusProvider({
url: providerUrl(storeId),
@@ -1,43 +0,0 @@
import { BlockNoteEditor } from '@blocknote/core';
import { create } from 'zustand';
import { HeadingBlock } from '../types';
const recursiveTextContent = (content: HeadingBlock['content']): string => {
if (!content) {
return '';
}
return content.reduce((acc, content) => {
if (content.type === 'text') {
return acc + content.text;
} else if (content.type === 'link') {
return acc + recursiveTextContent(content.content);
}
return acc;
}, '');
};
export interface UseHeadingStore {
headings: HeadingBlock[];
setHeadings: (editor: BlockNoteEditor) => void;
resetHeadings: () => void;
}
export const useHeadingStore = create<UseHeadingStore>((set) => ({
headings: [],
setHeadings: (editor) => {
const headingBlocks = editor?.document
.filter((block) => block.type === 'heading')
.map((block) => ({
...block,
contentText: recursiveTextContent(
block.content as unknown as HeadingBlock['content'],
),
})) as unknown as HeadingBlock[];
set(() => ({ headings: headingBlocks }));
},
resetHeadings: () => set(() => ({ headings: [] })),
}));
@@ -1,19 +0,0 @@
import { create } from 'zustand';
export interface UsePanelEditorStore {
isPanelOpen: boolean;
setIsPanelOpen: (isOpen: boolean) => void;
isPanelTableContentOpen: boolean;
setIsPanelTableContentOpen: (isOpen: boolean) => void;
}
export const usePanelEditorStore = create<UsePanelEditorStore>((set) => ({
isPanelOpen: false,
isPanelTableContentOpen: true,
setIsPanelTableContentOpen: (isPanelTableContentOpen) => {
set(() => ({ isPanelTableContentOpen }));
},
setIsPanelOpen: (isPanelOpen) => {
set(() => ({ isPanelOpen }));
},
}));
@@ -1,14 +1,3 @@
export interface DocAttachment {
file: string;
}
export type HeadingBlock = {
id: string;
type: string;
text: string;
content: HeadingBlock[];
contentText: string;
props: {
level: number;
};
};
@@ -1,5 +1,3 @@
import * as Y from 'yjs';
export const randomColor = () => {
const randomInt = (min: number, max: number) => {
return Math.floor(Math.random() * (max - min + 1)) + min;
@@ -28,20 +26,3 @@ function hslToHex(h: number, s: number, l: number) {
export const toBase64 = (
str: WithImplicitCoercion<ArrayBuffer | SharedArrayBuffer>,
) => Buffer.from(str).toString('base64');
type BasicBlock = {
type: string;
content: string;
};
export const blocksToYDoc = (blocks: BasicBlock[], doc: Y.Doc) => {
const xmlFragment = doc.getXmlFragment('document-store');
blocks.forEach((block) => {
const xmlElement = new Y.XmlElement(block.type);
if (block.content) {
xmlElement.insert(0, [new Y.XmlText(block.content)]);
}
xmlFragment.push([xmlElement]);
});
};
@@ -1,4 +1,5 @@
import React, { Fragment } from 'react';
import { Button } from '@openfun/cunningham-react';
import React, { Fragment, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Card, StyledLink, Text } from '@/components';
@@ -7,14 +8,12 @@ import {
Doc,
Role,
currentDocRole,
useTrans,
useTransRole,
} from '@/features/docs/doc-management';
import { Versions } from '@/features/docs/doc-versioning';
import { ModalVersion, Versions } from '@/features/docs/doc-versioning';
import { useDate } from '@/hook';
import { useResponsiveStore } from '@/stores';
import { DocTagPublic } from './DocTagPublic';
import { DocTitle } from './DocTitle';
import { DocToolBox } from './DocToolBox';
interface DocHeaderProps {
@@ -26,25 +25,20 @@ export const DocHeader = ({ doc, versionId }: DocHeaderProps) => {
const { colorsTokens } = useCunninghamTheme();
const { t } = useTranslation();
const { formatDate } = useDate();
const { transRole } = useTrans();
const { isMobile, isSmallMobile } = useResponsiveStore();
const transRole = useTransRole();
const [isModalVersionOpen, setIsModalVersionOpen] = useState(false);
return (
<>
<Card
$margin={isMobile ? 'tiny' : 'small'}
$margin="small"
aria-label={t('It is the card information about the document.')}
>
<Box
$padding={isMobile ? 'tiny' : 'small'}
$direction="row"
$align="center"
>
<Box $padding="small" $direction="row" $align="center">
<StyledLink href="/">
<Text
$isMaterialIcon
$theme="primary"
$variation="600"
$size="2rem"
$css={`&:hover {background-color: ${colorsTokens()['primary-100']}; };`}
$hasTransition
@@ -58,39 +52,39 @@ export const DocHeader = ({ doc, versionId }: DocHeaderProps) => {
$width="1px"
$height="70%"
$background={colorsTokens()['greyscale-100']}
$margin={{ horizontal: 'tiny' }}
$margin={{ horizontal: 'small' }}
/>
<Box
$direction="row"
$justify="space-between"
$css="flex:1;"
$gap="0.5rem 1rem"
$wrap="wrap"
$align="center"
>
<DocTitle doc={doc} />
<DocToolBox doc={doc} versionId={versionId} />
<Box $gap="1rem" $direction="row">
<Text
as="h2"
$align="center"
$margin={{ all: 'none', left: 'tiny' }}
>
{doc.title}
</Text>
{versionId && (
<Button
onClick={() => {
setIsModalVersionOpen(true);
}}
size="small"
>
{t('Restore this version')}
</Button>
)}
</Box>
<DocToolBox doc={doc} />
</Box>
<Box
$direction={isSmallMobile ? 'column' : 'row'}
$align={isSmallMobile ? 'start' : 'center'}
$direction="row"
$align="center"
$css="border-top:1px solid #eee"
$padding={{
horizontal: isMobile ? 'tiny' : 'big',
vertical: 'tiny',
}}
$padding={{ horizontal: 'big', vertical: 'tiny' }}
$gap="0.5rem 2rem"
$justify="space-between"
$wrap="wrap"
$position="relative"
>
<Box
$direction={isSmallMobile ? 'column' : 'row'}
$align={isSmallMobile ? 'start' : 'center'}
$gap="0.5rem 2rem"
$wrap="wrap"
>
<Box $direction="row" $align="center" $gap="0.5rem 2rem" $wrap="wrap">
<DocTagPublic doc={doc} />
<Text $size="s" $display="inline">
{t('Created at')} <strong>{formatDate(doc.created_at)}</strong>
@@ -117,6 +111,13 @@ export const DocHeader = ({ doc, versionId }: DocHeaderProps) => {
</Text>
</Box>
</Card>
{isModalVersionOpen && versionId && (
<ModalVersion
onClose={() => setIsModalVersionOpen(false)}
docId={doc.id}
versionId={versionId}
/>
)}
</>
);
};

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