Compare commits

..

1 Commits

Author SHA1 Message Date
Anthony LC 6708336052 🔖(minor) initial release to 0.1.0
Added
- Coming Soon page
- Impress, project to manage your documents easily
  and collaboratively
2024-05-23 15:32:23 +02:00
251 changed files with 4977 additions and 13477 deletions
-52
View File
@@ -1,52 +0,0 @@
name: Deploy
on:
push:
tags:
- 'preprod'
- 'production'
jobs:
notify-argocd:
runs-on: ubuntu-latest
steps:
-
uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "impress,secrets"
-
name: Checkout repository
uses: actions/checkout@v2
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
-
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/impress/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
-
name: Call argocd github webhook
run: |
data='{"ref": "'$GITHUB_REF'","repository": {"html_url":"'$GITHUB_SERVER_URL'/'$GITHUB_REPOSITORY'"}}'
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${ARGOCD_WEBHOOK_SECRET}'' | awk '{print "X-Hub-Signature: sha1="$2}')
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" $ARGOCD_WEBHOOK_URL
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${ARGOCD_PRODUCTION_WEBHOOK_SECRET}'' | awk '{print "X-Hub-Signature: sha1="$2}')
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" $ARGOCD_PRODUCTION_WEBHOOK_URL
start-test-on-preprod:
needs:
- notify-argocd
runs-on: ubuntu-latest
if: startsWith(github.event.ref, 'refs/tags/preprod')
steps:
-
name: Debug
run: |
echo "Start test when preprod is ready"
+27 -71
View File
@@ -19,31 +19,20 @@ jobs:
runs-on: ubuntu-latest
steps:
-
uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "impress,secrets"
-
name: Checkout repository
uses: actions/checkout@v2
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
-
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/impress/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
name: Checkout
uses: actions/checkout@v4
-
name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: lasuite/impress-backend
-
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: .github/workflows/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
@@ -63,31 +52,20 @@ jobs:
runs-on: ubuntu-latest
steps:
-
uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "impress,secrets"
-
name: Checkout repository
uses: actions/checkout@v2
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
-
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/impress/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
name: Checkout
uses: actions/checkout@v4
-
name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: lasuite/impress-frontend
-
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: .github/workflows/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
@@ -108,31 +86,20 @@ jobs:
runs-on: ubuntu-latest
steps:
-
uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "impress,secrets"
-
name: Checkout repository
uses: actions/checkout@v2
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
-
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/impress/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
name: Checkout
uses: actions/checkout@v4
-
name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: lasuite/impress-y-webrtc-signaling
-
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: .github/workflows/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
@@ -158,24 +125,13 @@ jobs:
github.event_name != 'pull_request'
steps:
-
uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "impress,secrets"
-
name: Checkout repository
uses: actions/checkout@v2
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
name: Checkout
uses: actions/checkout@v4
-
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/impress/secrets.enc.env
secret-file: .github/workflows/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
-
name: Call argocd github webhook
+20 -34
View File
@@ -4,9 +4,11 @@ on:
push:
branches:
- main
tags:
- 'v*'
pull_request:
branches:
- "*"
- '*'
jobs:
install-front:
@@ -19,13 +21,13 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "18.x"
node-version: '18.x'
- name: Restore the frontend cache
uses: actions/cache@v4
id: front-node_modules
with:
path: "src/frontend/**/node_modules"
path: 'src/frontend/**/node_modules'
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
- name: Install dependencies
@@ -36,7 +38,7 @@ jobs:
if: steps.front-node_modules.outputs.cache-hit != 'true'
uses: actions/cache@v4
with:
path: "src/frontend/**/node_modules"
path: 'src/frontend/**/node_modules'
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
build-front:
@@ -50,7 +52,7 @@ jobs:
uses: actions/cache@v4
id: front-node_modules
with:
path: "src/frontend/**/node_modules"
path: 'src/frontend/**/node_modules'
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
- name: Build CI App
@@ -73,7 +75,7 @@ jobs:
uses: actions/cache@v4
id: front-node_modules
with:
path: "src/frontend/**/node_modules"
path: 'src/frontend/**/node_modules'
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
- name: Test App
@@ -90,9 +92,9 @@ jobs:
uses: actions/cache@v4
id: front-node_modules
with:
path: "src/frontend/**/node_modules"
path: 'src/frontend/**/node_modules'
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
- name: Check linting
run: cd src/frontend/ && yarn lint
@@ -103,25 +105,18 @@ jobs:
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
with:
path: "src/frontend/**/node_modules"
path: 'src/frontend/**/node_modules'
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
- name: Restore the build cache
@@ -130,25 +125,15 @@ jobs:
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-webrtc-signaling
load: true
set: |
*.cache-from=type=gha,scope=cached-stage
*.cache-to=type=gha,scope=cached-stage,mode=max
- name: Start Docker services
- name: Build and Start Docker Servers
env:
DOCKER_BUILDKIT: 1
COMPOSE_DOCKER_CLI_BUILD: 1
run: |
docker-compose build --pull --build-arg BUILDKIT_INLINE_CACHE=1
make run
- name: Apply DRF migrations
run: |
make migrate
@@ -169,3 +154,4 @@ jobs:
name: playwright-report
path: src/frontend/apps/e2e/report/
retention-days: 7
+25 -67
View File
@@ -4,9 +4,11 @@ on:
push:
branches:
- main
tags:
- 'v*'
pull_request:
branches:
- "*"
- '*'
jobs:
lint-git:
@@ -16,7 +18,7 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v2
with:
fetch-depth: 0
fetch-depth: 0
- name: show
run: git log
- name: Enforce absence of print statements in code
@@ -32,16 +34,11 @@ jobs:
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
uses: actions/checkout@v2
- 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'
run: git whatchanged --name-only --pretty="" origin..HEAD | grep CHANGELOG
lint-changelog:
runs-on: ubuntu-latest
@@ -63,39 +60,18 @@ jobs:
working-directory: src/mail
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v2
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: "18"
- 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') }}
node-version: '18'
- name: Install yarn
if: steps.mail-templates.outputs.cache-hit != 'true'
run: npm install -g yarn
- name: Install node dependencies
if: steps.mail-templates.outputs.cache-hit != 'true'
run: yarn install --frozen-lockfile
- name: Build mails
if: steps.mail-templates.outputs.cache-hit != 'true'
run: yarn build
- name: Cache mail templates
if: steps.mail-templates.outputs.cache-hit != 'true'
uses: actions/cache@v4
with:
path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
lint-back:
runs-on: ubuntu-latest
defaults:
@@ -107,19 +83,19 @@ jobs:
- name: Install Python
uses: actions/setup-python@v3
with:
python-version: "3.10"
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
run: ~/.local/bin/ruff format impress --diff
- name: Lint code with ruff
run: ~/.local/bin/ruff check .
run: ~/.local/bin/ruff check impress
- name: Lint code with pylint
run: ~/.local/bin/pylint .
run: ~/.local/bin/pylint impress
test-back:
runs-on: ubuntu-latest
needs: build-mails
defaults:
run:
@@ -161,13 +137,6 @@ jobs:
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: Start Minio
run: |
docker pull minio/minio
@@ -190,7 +159,7 @@ jobs:
- name: Install Python
uses: actions/setup-python@v3
with:
python-version: "3.10"
python-version: '3.10'
- name: Install development dependencies
run: pip install --user .[dev]
@@ -209,26 +178,8 @@ jobs:
i18n-crowdin:
runs-on: ubuntu-latest
steps:
-
uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "infrastructure,secrets"
-
name: Checkout repository
- name: Checkout repository
uses: actions/checkout@v2
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
-
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/impress/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
- name: Install gettext (required to make messages)
run: |
@@ -238,7 +189,7 @@ jobs:
- name: Install Python
uses: actions/setup-python@v3
with:
python-version: "3.10"
python-version: '3.10'
- name: Install development dependencies
working-directory: src/backend
@@ -247,11 +198,17 @@ jobs:
- name: Generate the translation base file
run: ~/.local/bin/django-admin makemessages --keep-pot --all
- name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: .github/workflows/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "18.x"
cache: "yarn"
node-version: '18.x'
cache: 'yarn'
cache-dependency-path: src/frontend/yarn.lock
- name: Install dependencies
@@ -270,3 +227,4 @@ jobs:
-v "${{ github.workspace }}:/app" \
crowdin/cli:3.16.0 \
crowdin upload sources -c /app/crowdin/config.yml
+22
View File
@@ -0,0 +1,22 @@
SOPS_PRIVATE=ENC[AES256_GCM,data:53ysyQ9gq2PnAQKNjOL+e+Bu5SQIuOguz8Bo5CpqbpYsF0AmV1WsOutckdClbu6ApqV3m9/Cj1FJ30+L/+j05pvcpqMeehPQwGQ=,iv:VMuML9IXiEqKY9jp+ny76jnQHmewq2rqdBy1wYpZkSI=,tag:aAZgwiWDg1AG4wk3f2Fq4w==,type:str]
CROWDIN_API_TOKEN=ENC[AES256_GCM,data:bwh38oLDH4BpI2H+7oUjtVizyrYvVJ6Av4ECTnyPPthMz6DCaYQn55RXp8rQDgJj4bPRls+JcRVC94zYIjgpkDsbbcqHr620KQKHQHMgoOQ=,iv:hydpwWtCiOkhBpAYyNwDzSjhjfdUJcKX7YX3/PXteN0=,tag:eQLniL5XxkNs5yThUuQHyw==,type:str]
CROWDIN_BASE_PATH=ENC[AES256_GCM,data:LJZE454A6qg=,iv:yIjGACBJSX3S9g7PAHRFn074xL94fHvMLcTKzFYwkwo=,tag:1Z8+UbeDOvTxR80b95KumQ==,type:str]
CROWDIN_PROJECT_ID=ENC[AES256_GCM,data:THoNz661,iv:Ixd0D9tnpEWd2yqZui1HJQEO/h7YsAC1R9Vjj8OHBjA=,tag:wfDHhzaXLD3NwY5zDj24RA==,type:str]
DOCKER_HUB_PASSWORD=ENC[AES256_GCM,data:jj92OOVMtsagOXQ=,iv:r/u8M70PspZMFCbi8a3FvuCDtWt+9YGArPNHZRpHA+k=,tag:WM3vzVkuQZVdHa3wh4satg==,type:str]
DOCKER_HUB_USER=ENC[AES256_GCM,data:btdtLdLApQ==,iv:y1o2zwyzusBS6JiQSEtZwS2zctISo+UgAFhyZ53vbKQ=,tag:ZLkMJydgjMBmbbKq979z7g==,type:str]
ARGOCD_WEBHOOK_URL=ENC[AES256_GCM,data:0TnoZv7vQI+8MZ/7EITx0Mvez66G6BcCzw+Mic+NH2qh0BdZBH8ynkYBleKw9V6TbucgHasa7duL,iv:GeE5tSpjAndThrXrzz8Dk6ah9Bxv6JQCJmKAfsToDi0=,tag:O2pIhA0ge1xygIv0izSMxg==,type:str]
ARGOCD_WEBHOOK_SECRET=ENC[AES256_GCM,data:SrdWdV24lGztyUnFXeOYGAhqTErRFakIm7hBw8n4NKW6ll6AgeZKY6w7pbvgFknQ+NlRd/EK7bYk7CZtPDGU6zM=,iv:IkWxnTWrvzWwNh4RSt3N7iPHA7K7jkzSHa4CHptxxvU=,tag:XFVYBRsuDF/La1/8ADQ2jw==,type:str]
sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBESDdJSzBaaVlEbHRjSlIy\naVoyY2l6RVVqVXhOekV4NHdHQjV6Q0IzSEJNCk9JY3BFQ2tFWXBZVFMyWTJUYjdz\nMVdheTd4cjhFREl5MmNncmlobVNyUUUKLS0tIEg1MHBsV2FoRkFlN2JoNlFuTFFS\nNG5yUXZpQVY4Z1FGZmVLUjBqQWhSQTgKfT7hD5LVWg2NOrdyeIiVt6BX/4dt6fpN\nyydn2U0yxMg9fUZ7KkixAaWpChL3rvi3OWM07h6EdsznTwehLiMFTw==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_0__map_recipient=age15fyxdwmg5mvldtqqus87xspuws2u0cpvwheehrtvkexj4tnsqqysw6re2x
sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBJa3EzbDJBeHcrUE44SXpM\ndlVheHdxc2I4ellwcHlUQkhWL2NiMFpBYUd3CmJxZUZhL0tZVkViQTZFRVRFbndC\nd2ljZUJxczZqSmdqcXlzYkZlZ2t4MTgKLS0tIFFmbHE1NXpOYlRnb2wzSTRVbTQ4\nMDhTNzN6WHovMXFhek5pbXZlMW1PdEkKJlydhV9Es+y2ngMwZMGnuF+JnEV1TGZH\nkWoBHxTSA7WEgwnhGaCe7kuzXrvv2ikrV1Ww7sN4wmqfCGC2sdkPBQ==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_1__map_recipient=age16hnlml8yv4ynwy0seer57g8qww075crd0g7nsundz3pj4wk7m3vqftszg7
sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAzZEpFZU5maklnN1N4S0kw\nRGFNYzBGR2tFT2d5VzlRYU9NUWVvZld0REQ4CldvTlFtK0RFU0tuNjVhNEM4VzlC\nWjJhUEZVY0l0T05yNVBabXNEdndlbVkKLS0tIGxxdEROcWxpSHczMkN0dkdicnVZ\nT1BXR1hSa2l1SXdYS3RoWWh6NGdWSHcKZJd6HYESjLomY7/S9+eCCN4cFXERipNl\nWtOVZXlufN5BMxX8n8TlKS34oD1t6/CMaZZdmp2SHHslipA+CGRZ5g==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_2__map_recipient=age1plkp8td6zzfcavjusmsfrlk54t9vn8jjxm8zaz7cmnr7kzl2nfnsd54hwg
sops_age__list_3__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAzakNpcGkzWlp6NWt1NFU0\ncmhFek1DTU5YS1MyYzRoOGJ2RXdjRU5WcEZBCjN5eUp6WVh0YmdNMzdHTUNJTVZM\ncHZTY3pxbHd0TmhSWmQyVndZS1JjZ00KLS0tIFNxYjZXRHBKbjNxVitQaGlKQVh0\ncHAwbzFyL3hUVmN2dVNQaklIcXZKQjgKr4IO6BoTFO7Km9V/h8tF3UNRCGUXymIw\nnQGL0ZDyIQw7MMBQQ2mksYPSBTFmaejbSd29UkhVnYFuCjJ+LVmX1w==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_3__map_recipient=age12g6f5fse25tgrwweleh4jls3qs52hey2edh759smulwmk5lnzadslu2cp3
sops_age__list_4__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBpS0hNdDk2Lys4Rk9nVlV1\nWHVwOHcxT3RmZkVSMWh6L0M1bGRrNEt3c1M0CmdseVlqaFZYZjd6KzI0ejdDSG55\nNkFlMGpiOFhMZWtKYkVodGpmUWRsMjgKLS0tIG5ZbVFadk5XVlREZFFEcWNiSDhw\nVnh5b3BURGU4bCtQQzR3b3hxcXdGSlEKBw7E/umovQnucE4oYeuoHFlEtYBMVXPL\n6YjZzBpBxJ+4kZpMvqsXzowQ7ZDEods9pEcuJmHqxrRpLeOrYrykTA==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_4__map_recipient=age1qy04neuzwpasmvljqrcvhwnf0kz5cpyteze38c8avp0czewskasszv9pyw
sops_lastmodified=2024-04-03T15:36:15Z
sops_mac=ENC[AES256_GCM,data:1v44C4K4YjV1m7tZKRgj8SiDamdD+L4p3TVwwOl6+05KCOh2uH2ohH+5MH7MTFL489oqaadpjBQfELSJ8h/4fN5MT6+Trbtk5QFLv4moLZx1tSCE1Tuam2cicFem2mlOrxb0pK/tU1qzCLvZke3yvFmiJEa+92u7y96hXM4VR6Y=,iv:23T3Tl5DvRH8zvef7ftbr5GWk+YFfLCzZ/eEzqjMKXY=,tag:TIch+2911w5qleXo55zM0w==,type:str]
sops_unencrypted_suffix=_unencrypted
sops_version=3.8.1
-3
View File
@@ -1,3 +0,0 @@
[submodule "secrets"]
path = secrets
url = ../secrets
+12 -8
View File
@@ -1,9 +1,13 @@
creation_rules:
- path_regex: ./*
key_groups:
- age:
- age15fyxdwmg5mvldtqqus87xspuws2u0cpvwheehrtvkexj4tnsqqysw6re2x # jacques
- age16hnlml8yv4ynwy0seer57g8qww075crd0g7nsundz3pj4wk7m3vqftszg7 # github-repo
- age1plkp8td6zzfcavjusmsfrlk54t9vn8jjxm8zaz7cmnr7kzl2nfnsd54hwg # Anthony Le-Courric
- age12g6f5fse25tgrwweleh4jls3qs52hey2edh759smulwmk5lnzadslu2cp3 # Antoine Lebaud
# Here we have
# - Jacques key-id: age15fyxdwmg5mvldtqqus87xspuws2u0cpvwheehrtvkexj4tnsqqysw6re2x
# - github-repo key-id: age16hnlml8yv4ynwy0seer57g8qww075crd0g7nsundz3pj4wk7m3vqftszg7
# - Anthony Le-Courric key-id: age1plkp8td6zzfcavjusmsfrlk54t9vn8jjxm8zaz7cmnr7kzl2nfnsd54hwg
# - Antoine Lebaud key-id: age12g6f5fse25tgrwweleh4jls3qs52hey2edh759smulwmk5lnzadslu2cp3
# - argocd key-id: age1qy04neuzwpasmvljqrcvhwnf0kz5cpyteze38c8avp0czewskasszv9pyw
- age:
age15fyxdwmg5mvldtqqus87xspuws2u0cpvwheehrtvkexj4tnsqqysw6re2x,
age16hnlml8yv4ynwy0seer57g8qww075crd0g7nsundz3pj4wk7m3vqftszg7,
age1plkp8td6zzfcavjusmsfrlk54t9vn8jjxm8zaz7cmnr7kzl2nfnsd54hwg,
age12g6f5fse25tgrwweleh4jls3qs52hey2edh759smulwmk5lnzadslu2cp3,
age1qy04neuzwpasmvljqrcvhwnf0kz5cpyteze38c8avp0czewskasszv9pyw
-50
View File
@@ -7,53 +7,3 @@ and this project adheres to
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [1.0.0] - 2024-07-02
## Added
- 🛂(frontend) Manage the document's right (#75)
- ✨(frontend) Update document (#68)
- ✨(frontend) Remove document (#68)
- 🐳(docker) dockerize dev frontend (#63)
- 👔(backend) list users with email filtering (#79)
- ✨(frontend) add user to a document (#52)
- ✨(frontend) invite user to a document (#52)
- 🛂(frontend) manage members (update role / list / remove) (#81)
- ✨(frontend) offline mode (#88)
- 🌐(frontend) translate cgu (#83)
- ✨(service-worker) offline doc management (#94)
- ⚗️(frontend) Add beta tag on logo (#121)
## Changed
- ♻️(frontend) Change site from Impress to Docs (#76)
- ✨(frontend) Generate PDF from a modal (#68)
- 🔧(helm) sticky session by request_uri for signaling server (#78)
- ♻️(frontend) change logo (#84)
- ♻️(frontend) pdf has title doc (#84)
- ⚡️(e2e) unique login between tests (#80)
- ⚡️(CI) improve e2e job (#86)
- ♻️(frontend) improve the error and message info ui (#93)
- ✏️(frontend) change all occurences of pad to doc (#99)
## Fixed
- 🐛(frontend) Fix the break line when generate PDF (#84)
## Delete
- 💚(CI) Remove trigger workflow on push tags on CI (#68)
- 🔥(frontend) Remove coming soon page (#121)
## [0.1.0] - 2024-05-24
## Added
- ✨(frontend) Coming Soon page (#67)
- 🚀 Impress, project to manage your documents easily and collaboratively.
[unreleased]: https://github.com/numerique-gouv/impress/compare/v1.0.0...main
[1.0.0]: https://github.com/numerique-gouv/impress/releases/v1.0.0
[0.1.0]: https://github.com/numerique-gouv/impress/releases/v0.1.0
+13 -5
View File
@@ -82,12 +82,13 @@ bootstrap: \
data/static \
create-env-files \
build \
run-frontend-dev \
run \
migrate \
demo \
back-i18n-compile \
mails-install \
mails-build
mails-build \
install-front-impress
.PHONY: bootstrap
# -- Docker/compose
@@ -104,7 +105,10 @@ logs: ## display app-dev logs (follow mode)
.PHONY: logs
run: ## start the wsgi (production) and development server
@$(COMPOSE) up --force-recreate -d nginx
@$(COMPOSE) up --force-recreate -d app-dev
@$(COMPOSE) up --force-recreate -d celery-dev
@$(COMPOSE) up --force-recreate -d keycloak
@$(COMPOSE) up --force-recreate -d y-webrtc-signaling
@echo "Wait for postgresql to be up..."
@$(WAIT_DB)
@@ -297,9 +301,13 @@ help:
.PHONY: help
# Front
run-frontend-dev: ## Install and run the frontend dev
@$(COMPOSE) up --force-recreate -d frontend-dev
.PHONY: run-frontend-dev
install-front-impress: ## Install the frontend dependencies of app Impress
cd $(PATH_FRONT_IMPRESS) && yarn
.PHONY: install-front-impress
run-front-impress: ## Start app Impress
cd $(PATH_FRONT_IMPRESS) && yarn dev
.PHONY: run-front-impress
frontend-i18n-extract: ## Extract the frontend translation inside a json to be used for crowdin
cd $(PATH_FRONT) && yarn i18n:extract
+5 -2
View File
@@ -31,7 +31,10 @@ 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.
Then you can run the following command to start the project in development mode:
```bash
$ make run-front-impress
```
You will be prompted to log in, the default credentials are:
```bash
username: impress
@@ -49,7 +52,7 @@ Your Docker services should now be up and running 🎉
Note that if you need to run them afterwards, you can use the eponym Make rule:
```bash
$ make run-frontend-dev
$ make run
```
### Adding content
+3 -3
View File
@@ -21,10 +21,10 @@ docker_build(
'localhost:5001/impress-y-webrtc-signaling:latest',
context='..',
dockerfile='../src/frontend/Dockerfile',
only=['./src/frontend/', './docker/', './.dockerignore'],
only=['./src/frontend/', './docker/', './dockerignore'],
target = 'y-webrtc-signaling',
live_update=[
sync('../src/frontend/apps/y-webrtc-signaling/src', '/home/frontend/apps/y-webrtc-signaling/src'),
sync('../src/frontend', '/home/frontend'),
]
)
@@ -32,7 +32,7 @@ docker_build(
'localhost:5001/impress-frontend:latest',
context='..',
dockerfile='../src/frontend/Dockerfile',
only=['./src/frontend', './docker', './.dockerignore'],
only=['./src/frontend', './docker', './dockerignore'],
target = 'impress',
live_update=[
sync('../src/frontend', '/home/frontend'),
Executable → Regular
View File
+1 -16
View File
@@ -65,7 +65,6 @@ services:
- mailcatcher
- redis
- createbuckets
- nginx
celery-dev:
user: ${DOCKER_USER:-1000}
@@ -119,6 +118,7 @@ services:
volumes:
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
depends_on:
- app
- keycloak
dockerize:
@@ -142,7 +142,6 @@ services:
- ".:/app"
y-webrtc-signaling:
user: ${DOCKER_USER:-1000}
build:
context: .
dockerfile: ./src/frontend/Dockerfile
@@ -155,20 +154,6 @@ services:
- /home/frontend/apps/y-webrtc-signaling/node_modules/
- /home/frontend/apps/y-webrtc-signaling/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-webrtc-signaling
- celery-dev
kc_postgresql:
image: postgres:14.3
+1 -7
View File
@@ -13,13 +13,7 @@
"enabled": false,
"groupName": "ignored js dependencies",
"matchManagers": ["npm"],
"matchPackageNames": [
"fetch-mock",
"node",
"node-fetch",
"i18next-parser",
"eslint"
]
"matchPackageNames": ["node", "node-fetch", "i18next-parser", "eslint"]
}
]
}
-30
View File
@@ -1,30 +0,0 @@
#!/bin/bash
mkdir -p "$(dirname -- "${BASH_SOURCE[0]}")/../.git/hooks/"
PRE_COMMIT_FILE="$(dirname -- "${BASH_SOURCE[0]}")/../.git/hooks/pre-commit"
cat <<'EOF' >$PRE_COMMIT_FILE
#!/bin/bash
# directories containing potential secrets
DIRS="."
bold=$(tput bold)
normal=$(tput sgr0)
# allow to read user input, assigns stdin to keyboard
exec </dev/tty
for d in $DIRS; do
# find files containing secrets that should be encrypted
for f in $(find "${d}" -type f -regex ".*\.enc\..*"); do
if ! $(grep -q "unencrypted_suffix" $f); then
printf '\xF0\x9F\x92\xA5 '
echo "File $f has non encrypted secrets!"
exit 1
fi
done
done
EOF
chmod +x $PRE_COMMIT_FILE
-4
View File
@@ -1,4 +0,0 @@
#!/bin/bash
git submodule update --init --recursive
git submodule foreach 'git fetch origin; git checkout $(git rev-parse --abbrev-ref HEAD); git reset --hard origin/$(git rev-parse --abbrev-ref HEAD); git submodule update --recursive; git clean -dfx'
-3
View File
@@ -1,3 +0,0 @@
#!/bin/bash
find . -name "*.enc.*" -exec sops updatekeys -y {} \;
Submodule secrets deleted from 1485c6dc9d
+1 -30
View File
@@ -78,44 +78,15 @@ class TemplateAdmin(admin.ModelAdmin):
inlines = (TemplateAccessInline,)
class DocumentAccessInline(admin.TabularInline):
"""Inline admin class for template accesses."""
model = models.DocumentAccess
extra = 0
@admin.register(models.Document)
class DocumentAdmin(admin.ModelAdmin):
"""Document admin interface declaration."""
inlines = (DocumentAccessInline,)
@admin.register(models.Invitation)
class InvitationAdmin(admin.ModelAdmin):
"""Admin interface to handle invitations."""
fields = (
"email",
"document",
"role",
"created_at",
"issuer",
)
readonly_fields = (
"created_at",
"is_expired",
"issuer",
)
list_display = (
"email",
"document",
"created_at",
"is_expired",
)
def save_model(self, request, obj, form, change):
obj.issuer = request.user
obj.save()
+16 -94
View File
@@ -3,17 +3,28 @@ from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from rest_framework import exceptions, serializers
from timezone_field.rest_framework import TimeZoneSerializerField
from core import models
from .fields import JSONField
class UserSerializer(serializers.ModelSerializer):
"""Serialize users."""
timezone = TimeZoneSerializerField(use_pytz=False, required=True)
class Meta:
model = models.User
fields = ["id", "email"]
read_only_fields = ["id", "email"]
fields = [
"id",
"language",
"timezone",
"is_device",
"is_staff",
]
read_only_fields = ["id", "is_device", "is_staff"]
class BaseAccessSerializer(serializers.ModelSerializer):
@@ -55,6 +66,7 @@ class BaseAccessSerializer(serializers.ModelSerializer):
# Create
else:
teams = user.get_teams()
try:
resource_id = self.context["resource_id"]
except KeyError as exc:
@@ -62,7 +74,6 @@ class BaseAccessSerializer(serializers.ModelSerializer):
"You must set a resource ID in kwargs to create a new access."
) from exc
teams = user.get_teams()
if not self.Meta.model.objects.filter( # pylint: disable=no-member
Q(user=user) | Q(team__in=teams),
role__in=[models.RoleChoices.OWNER, models.RoleChoices.ADMIN],
@@ -91,19 +102,10 @@ class BaseAccessSerializer(serializers.ModelSerializer):
class DocumentAccessSerializer(BaseAccessSerializer):
"""Serialize document accesses."""
user_id = serializers.PrimaryKeyRelatedField(
queryset=models.User.objects.all(),
write_only=True,
source="user",
required=False,
allow_null=True,
)
user = UserSerializer(read_only=True)
class Meta:
model = models.DocumentAccess
resource_field_name = "document"
fields = ["id", "user", "user_id", "team", "role", "abilities"]
fields = ["id", "user", "team", "role", "abilities"]
read_only_fields = ["id", "abilities"]
@@ -134,8 +136,7 @@ class BaseResourceSerializer(serializers.ModelSerializer):
class DocumentSerializer(BaseResourceSerializer):
"""Serialize documents."""
content = serializers.CharField(required=False)
accesses = DocumentAccessSerializer(many=True, read_only=True)
content = JSONField(required=False)
class Meta:
model = models.Document
@@ -171,82 +172,3 @@ class DocumentGenerationSerializer(serializers.Serializer):
required=False,
default="html",
)
class InvitationSerializer(serializers.ModelSerializer):
"""Serialize invitations."""
abilities = serializers.SerializerMethodField(read_only=True)
class Meta:
model = models.Invitation
fields = [
"id",
"abilities",
"created_at",
"email",
"document",
"role",
"issuer",
"is_expired",
]
read_only_fields = [
"id",
"abilities",
"created_at",
"document",
"issuer",
"is_expired",
]
def get_abilities(self, invitation) -> dict:
"""Return abilities of the logged-in user on the instance."""
request = self.context.get("request")
if request:
return invitation.get_abilities(request.user)
return {}
def validate(self, attrs):
"""Validate and restrict invitation to new user based on email."""
request = self.context.get("request")
user = getattr(request, "user", None)
role = attrs.get("role")
try:
document_id = self.context["resource_id"]
except KeyError as exc:
raise exceptions.ValidationError(
"You must set a document ID in kwargs to create a new document invitation."
) from exc
if not user and user.is_authenticated:
raise exceptions.PermissionDenied(
"Anonymous users are not allowed to create invitations."
)
teams = user.get_teams()
if not models.DocumentAccess.objects.filter(
Q(user=user) | Q(team__in=teams),
document=document_id,
role__in=[models.RoleChoices.OWNER, models.RoleChoices.ADMIN],
).exists():
raise exceptions.PermissionDenied(
"You are not allowed to manage invitations for this document."
)
if (
role == models.RoleChoices.OWNER
and not models.DocumentAccess.objects.filter(
Q(user=user) | Q(team__in=teams),
document=document_id,
role=models.RoleChoices.OWNER,
).exists()
):
raise exceptions.PermissionDenied(
"Only owners of a document can invite other users as owners."
)
attrs["document_id"] = document_id
attrs["issuer"] = user
return attrs
+10 -116
View File
@@ -1,4 +1,5 @@
"""API endpoints"""
import json
from io import BytesIO
from django.contrib.postgres.aggregates import ArrayAgg
@@ -111,7 +112,8 @@ class Pagination(pagination.PageNumberPagination):
class UserViewSet(
mixins.UpdateModelMixin, viewsets.GenericViewSet, mixins.ListModelMixin
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
"""User ViewSet"""
@@ -119,26 +121,6 @@ class UserViewSet(
queryset = models.User.objects.all()
serializer_class = serializers.UserSerializer
def get_queryset(self):
"""
Limit listed users by querying the email field with a trigram similarity
search if a query is provided.
Limit listed users by excluding users already in the document if a document_id
is provided.
"""
queryset = self.queryset
if self.action == "list":
# Exclude all users already in the given document
if document_id := self.request.GET.get("document_id", ""):
queryset = queryset.exclude(documentaccess__document_id=document_id)
# Filter users by email similarity
if query := self.request.GET.get("q", ""):
queryset = queryset.filter(email__trigram_word_similar=query)
return queryset
@decorators.action(
detail=False,
methods=["get"],
@@ -311,19 +293,6 @@ class DocumentViewSet(
resource_field_name = "document"
queryset = models.Document.objects.all()
def perform_create(self, serializer):
"""
Override perform_create to use the provided ID in the payload if it exists
"""
document_id = self.request.data.get("id")
document = serializer.save(id=document_id) if document_id else serializer.save()
self.access_model_class.objects.create(
user=self.request.user,
role=models.RoleChoices.OWNER,
**{self.resource_field_name: document},
)
@decorators.action(detail=True, methods=["get"], url_path="versions")
def versions_list(self, request, *args, **kwargs):
"""
@@ -375,7 +344,7 @@ class DocumentViewSet(
return drf_response.Response(
{
"content": response["Body"].read().decode("utf-8"),
"content": json.loads(response["Body"].read()),
"last_modified": response["LastModified"],
}
)
@@ -399,15 +368,15 @@ class DocumentAccessViewSet(
POST /api/v1.0/documents/<resource_id>/accesses/ with expected data:
- user: str
- role: str [administrator|editor|reader]
- role: str [owner|admin|member]
Return newly created document access
PUT /api/v1.0/documents/<resource_id>/accesses/<document_access_id>/ with expected data:
- role: str [owner|admin|editor|reader]
- role: str [owner|admin|member]
Return updated document access
PATCH /api/v1.0/documents/<resource_id>/accesses/<document_access_id>/ with expected data:
- role: str [owner|admin|editor|reader]
- role: str [owner|admin|member]
Return partially updated document access
DELETE /api/v1.0/documents/<resource_id>/accesses/<document_access_id>/
@@ -489,15 +458,15 @@ class TemplateAccessViewSet(
POST /api/v1.0/templates/<template_id>/accesses/ with expected data:
- user: str
- role: str [administrator|editor|reader]
- role: str [owner|admin|member]
Return newly created template access
PUT /api/v1.0/templates/<template_id>/accesses/<template_access_id>/ with expected data:
- role: str [owner|admin|editor|reader]
- role: str [owner|admin|member]
Return updated template access
PATCH /api/v1.0/templates/<template_id>/accesses/<template_access_id>/ with expected data:
- role: str [owner|admin|editor|reader]
- role: str [owner|admin|member]
Return partially updated template access
DELETE /api/v1.0/templates/<template_id>/accesses/<template_access_id>/
@@ -510,78 +479,3 @@ class TemplateAccessViewSet(
queryset = models.TemplateAccess.objects.select_related("user").all()
resource_field_name = "template"
serializer_class = serializers.TemplateAccessSerializer
class InvitationViewset(
mixins.CreateModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.DestroyModelMixin,
viewsets.GenericViewSet,
):
"""API ViewSet for user invitations to document.
GET /api/v1.0/documents/<document_id>/invitations/:<invitation_id>/
Return list of invitations related to that document or one
document access if an id is provided.
POST /api/v1.0/documents/<document_id>/invitations/ with expected data:
- email: str
- role: str [administrator|editor|reader]
Return newly created invitation (issuer and document are automatically set)
PUT / PATCH : Not permitted. Instead of updating your invitation,
delete and create a new one.
DELETE /api/v1.0/documents/<document_id>/invitations/<invitation_id>/
Delete targeted invitation
"""
lookup_field = "id"
pagination_class = Pagination
permission_classes = [permissions.IsAuthenticated, permissions.AccessPermission]
queryset = (
models.Invitation.objects.all()
.select_related("document")
.order_by("-created_at")
)
serializer_class = serializers.InvitationSerializer
def get_serializer_context(self):
"""Extra context provided to the serializer class."""
context = super().get_serializer_context()
context["resource_id"] = self.kwargs["resource_id"]
return context
def get_queryset(self):
"""Return the queryset according to the action."""
queryset = super().get_queryset()
queryset = queryset.filter(document=self.kwargs["resource_id"])
if self.action == "list":
user = self.request.user
teams = user.get_teams()
# Determine which role the logged-in user has in the document
user_roles_query = (
models.DocumentAccess.objects.filter(
Q(user=user) | Q(team__in=teams),
document=self.kwargs["resource_id"],
)
.values("document")
.annotate(roles_array=ArrayAgg("role"))
.values("roles_array")
)
queryset = (
# The logged-in user should be part of a document to see its accesses
queryset.filter(
Q(document__accesses__user=user)
| Q(document__accesses__team__in=teams),
)
# Abilities are computed based on logged-in user's role and
# the user role on each document access
.annotate(user_roles=Subquery(user_roles_query))
.distinct()
)
return queryset
+1 -13
View File
@@ -35,7 +35,7 @@ class DocumentFactory(factory.django.DjangoModelFactory):
title = factory.Sequence(lambda n: f"document{n}")
is_public = factory.Faker("boolean")
content = factory.Sequence(lambda n: f"content{n}")
content = factory.LazyFunction(lambda: {"foo": fake.word()})
@factory.post_generation
def users(self, create, extracted, **kwargs):
@@ -112,15 +112,3 @@ class TeamTemplateAccessFactory(factory.django.DjangoModelFactory):
template = factory.SubFactory(TemplateFactory)
team = factory.Sequence(lambda n: f"team{n}")
role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices])
class InvitationFactory(factory.django.DjangoModelFactory):
"""A factory to create invitations for a user"""
class Meta:
model = models.Invitation
email = factory.Faker("email")
document = factory.SubFactory(DocumentFactory)
role = factory.fuzzy.FuzzyChoice([role[0] for role in models.RoleChoices.choices])
issuer = factory.SubFactory(UserFactory)
+3 -24
View File
@@ -1,4 +1,4 @@
# Generated by Django 5.0.3 on 2024-05-28 20:29
# Generated by Django 5.0.3 on 2024-04-19 11:38
import django.contrib.auth.models
import django.core.validators
@@ -89,7 +89,7 @@ class Migration(migrations.Migration):
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
('team', models.CharField(blank=True, max_length=100)),
('role', models.CharField(choices=[('reader', 'Reader'), ('editor', 'Editor'), ('administrator', 'Administrator'), ('owner', 'Owner')], default='reader', max_length=20)),
('role', models.CharField(choices=[('member', 'Member'), ('administrator', 'Administrator'), ('owner', 'Owner')], default='member', max_length=20)),
('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='accesses', to='core.document')),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
@@ -100,23 +100,6 @@ class Migration(migrations.Migration):
'ordering': ('-created_at',),
},
),
migrations.CreateModel(
name='Invitation',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
('email', models.EmailField(max_length=254, verbose_name='email address')),
('role', models.CharField(choices=[('reader', 'Reader'), ('editor', 'Editor'), ('administrator', 'Administrator'), ('owner', 'Owner')], default='reader', max_length=20)),
('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to='core.document')),
('issuer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Document invitation',
'verbose_name_plural': 'Document invitations',
'db_table': 'impress_invitation',
},
),
migrations.CreateModel(
name='TemplateAccess',
fields=[
@@ -124,7 +107,7 @@ class Migration(migrations.Migration):
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
('team', models.CharField(blank=True, max_length=100)),
('role', models.CharField(choices=[('reader', 'Reader'), ('editor', 'Editor'), ('administrator', 'Administrator'), ('owner', 'Owner')], default='reader', max_length=20)),
('role', models.CharField(choices=[('member', 'Member'), ('administrator', 'Administrator'), ('owner', 'Owner')], default='member', max_length=20)),
('template', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='accesses', to='core.template')),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
@@ -147,10 +130,6 @@ class Migration(migrations.Migration):
model_name='documentaccess',
constraint=models.CheckConstraint(check=models.Q(models.Q(('team', ''), ('user__isnull', False)), models.Q(('team__gt', ''), ('user__isnull', True)), _connector='OR'), name='check_document_access_either_user_or_team', violation_error_message='Either user or team must be set, not both.'),
),
migrations.AddConstraint(
model_name='invitation',
constraint=models.UniqueConstraint(fields=('email', 'document'), name='email_and_document_unique_together'),
),
migrations.AddConstraint(
model_name='templateaccess',
constraint=models.UniqueConstraint(condition=models.Q(('user__isnull', False)), fields=('user', 'template'), name='unique_template_user', violation_error_message='This user is already in this template.'),
@@ -1,14 +0,0 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('core', '0001_initial'),
]
operations = [
migrations.RunSQL(
"CREATE EXTENSION IF NOT EXISTS pg_trgm;",
reverse_sql="DROP EXTENSION IF EXISTS pg_trgm;",
),
]
+22 -188
View File
@@ -2,27 +2,22 @@
Declare and configure the models for the impress core application
"""
import hashlib
import smtplib
import json
import textwrap
import uuid
from datetime import timedelta
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 import mail, validators
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
from django.db import models
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 lazy
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
from django.utils.translation import override
import frontmatter
import markdown
@@ -31,8 +26,6 @@ from timezone_field import TimeZoneField
from weasyprint import CSS, HTML
from weasyprint.text.fonts import FontConfiguration
logger = getLogger(__name__)
def get_resource_roles(resource, user):
"""Compute the roles a user has on a resource."""
@@ -54,9 +47,8 @@ def get_resource_roles(resource, user):
class RoleChoices(models.TextChoices):
"""Defines the possible roles a user can have in a template."""
READER = "reader", _("Reader") # Can read
EDITOR = "editor", _("Editor") # Can read and edit
ADMIN = "administrator", _("Administrator") # Can read, edit, delete and share
MEMBER = "member", _("Member")
ADMIN = "administrator", _("Administrator")
OWNER = "owner", _("Owner")
@@ -172,42 +164,6 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
def __str__(self):
return self.email or self.admin_email or str(self.id)
def save(self, *args, **kwargs):
"""
If it's a new user, give its user access to the documents to which s.he was invited.
"""
is_adding = self._state.adding
super().save(*args, **kwargs)
if is_adding:
self._convert_valid_invitations()
def _convert_valid_invitations(self):
"""
Convert valid invitations to document accesses.
Expired invitations are ignored.
"""
valid_invitations = Invitation.objects.filter(
email=self.email,
created_at__gte=(
timezone.now()
- timedelta(seconds=settings.INVITATION_VALIDITY_DURATION)
),
).select_related("document")
if not valid_invitations.exists():
return
DocumentAccess.objects.bulk_create(
[
DocumentAccess(
user=self, document=invitation.document, role=invitation.role
)
for invitation in valid_invitations
]
)
valid_invitations.delete()
def email_user(self, subject, message, from_email=None, **kwargs):
"""Email this user."""
if not self.email:
@@ -233,7 +189,7 @@ class BaseAccess(BaseModel):
)
team = models.CharField(max_length=100, blank=True)
role = models.CharField(
max_length=20, choices=RoleChoices.choices, default=RoleChoices.READER
max_length=20, choices=RoleChoices.choices, default=RoleChoices.MEMBER
)
class Meta:
@@ -265,20 +221,14 @@ class BaseAccess(BaseModel):
RoleChoices.OWNER in roles
and resource.accesses.filter(role=RoleChoices.OWNER).count() > 1
)
set_role_to = (
[RoleChoices.ADMIN, RoleChoices.EDITOR, RoleChoices.READER]
if can_delete
else []
)
set_role_to = [RoleChoices.ADMIN, RoleChoices.MEMBER] if can_delete else []
else:
can_delete = is_owner_or_admin
set_role_to = []
if RoleChoices.OWNER in roles:
set_role_to.append(RoleChoices.OWNER)
if is_owner_or_admin:
set_role_to.extend(
[RoleChoices.ADMIN, RoleChoices.EDITOR, RoleChoices.READER]
)
set_role_to.extend([RoleChoices.ADMIN, RoleChoices.MEMBER])
# Remove the current role as we don't want to propose it as an option
try:
@@ -289,7 +239,6 @@ class BaseAccess(BaseModel):
return {
"destroy": can_delete,
"update": bool(set_role_to),
"partial_update": bool(set_role_to),
"retrieve": bool(roles),
"set_role_to": set_role_to,
}
@@ -316,19 +265,12 @@ class Document(BaseModel):
def __str__(self):
return self.title
@property
def key_base(self):
"""Key base of the location where the document is stored in object storage."""
if not self.pk:
raise RuntimeError(
"The document instance must be saved before requesting a storage key."
)
return str(self.pk)
@property
def file_key(self):
"""Key of the object storage file to which the document content is stored"""
return f"{self.key_base}/file"
if not self.pk:
return None
return str(self.pk)
@property
def content(self):
@@ -339,15 +281,16 @@ class Document(BaseModel):
except (FileNotFoundError, ClientError):
pass
else:
self._content = response["Body"].read().decode("utf-8")
self._content = json.loads(response["Body"].read())
return self._content
@content.setter
def content(self, content):
"""Cache the content, don't write to object storage yet"""
if not isinstance(content, str):
raise ValueError("content should be a string.")
if isinstance(content, str):
content = json.loads(content)
if not isinstance(content, dict):
raise ValueError("content should be a json object.")
self._content = content
def get_content_response(self, version_id=""):
@@ -362,7 +305,7 @@ class Document(BaseModel):
if self._content:
file_key = self.file_key
bytes_content = self._content.encode("utf-8")
bytes_content = json.dumps(self._content).encode("utf-8")
if default_storage.exists(file_key):
response = default_storage.connection.meta.client.head_object(
@@ -461,7 +404,6 @@ class Document(BaseModel):
is_owner_or_admin = bool(
set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
)
is_editor = bool(RoleChoices.EDITOR in roles)
can_get = self.is_public or bool(roles)
can_get_versions = bool(roles)
@@ -471,8 +413,8 @@ class Document(BaseModel):
"versions_list": can_get_versions,
"versions_retrieve": can_get_versions,
"manage_accesses": is_owner_or_admin,
"update": is_owner_or_admin or is_editor,
"partial_update": is_owner_or_admin or is_editor,
"update": is_owner_or_admin,
"partial_update": is_owner_or_admin,
"retrieve": can_get,
}
@@ -552,15 +494,14 @@ class Template(BaseModel):
is_owner_or_admin = bool(
set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
)
is_editor = bool(RoleChoices.EDITOR in roles)
can_get = self.is_public or bool(roles)
return {
"destroy": RoleChoices.OWNER in roles,
"generate_document": can_get,
"manage_accesses": is_owner_or_admin,
"update": is_owner_or_admin or is_editor,
"partial_update": is_owner_or_admin or is_editor,
"update": is_owner_or_admin,
"partial_update": is_owner_or_admin,
"retrieve": can_get,
}
@@ -582,7 +523,7 @@ class Template(BaseModel):
document_html = HTML(
string=DjangoTemplate(self.code).render(
Context({"body": html.format_html(body_html), **metadata})
Context({"body": format_html(body_html), **metadata})
)
)
css = CSS(
@@ -635,110 +576,3 @@ class TemplateAccess(BaseAccess):
Compute and return abilities for a given user on the template access.
"""
return self._get_abilities(self.template, user)
class Invitation(BaseModel):
"""User invitation to a document."""
email = models.EmailField(_("email address"), null=False, blank=False)
document = models.ForeignKey(
Document,
on_delete=models.CASCADE,
related_name="invitations",
)
role = models.CharField(
max_length=20, choices=RoleChoices.choices, default=RoleChoices.READER
)
issuer = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name="invitations",
)
class Meta:
db_table = "impress_invitation"
verbose_name = _("Document invitation")
verbose_name_plural = _("Document invitations")
constraints = [
models.UniqueConstraint(
fields=["email", "document"], name="email_and_document_unique_together"
)
]
def __str__(self):
return f"{self.email} invited to {self.document}"
def save(self, *args, **kwargs):
"""Make invitations read-only."""
if self.created_at:
raise exceptions.PermissionDenied()
super().save(*args, **kwargs)
self.email_invitation()
def clean(self):
"""Validate fields."""
super().clean()
# Check if an identity already exists for the provided email
if User.objects.filter(email=self.email).exists():
raise exceptions.ValidationError(
{"email": _("This email is already associated to a registered user.")}
)
@property
def is_expired(self):
"""Calculate if invitation is still valid or has expired."""
if not self.created_at:
return None
validity_duration = timedelta(seconds=settings.INVITATION_VALIDITY_DURATION)
return timezone.now() > (self.created_at + validity_duration)
def get_abilities(self, user):
"""Compute and return abilities for a given user."""
can_delete = False
roles = []
if user.is_authenticated:
teams = user.get_teams()
try:
roles = self.user_roles or []
except AttributeError:
try:
roles = self.document.accesses.filter(
models.Q(user=user) | models.Q(team__in=teams),
).values_list("role", flat=True)
except (self._meta.model.DoesNotExist, IndexError):
roles = []
can_delete = bool(
set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
)
return {
"destroy": can_delete,
"update": False,
"partial_update": False,
"retrieve": bool(roles),
}
def email_invitation(self):
"""Email invitation to the user."""
try:
with override(self.issuer.language):
title = _("Invitation to join Impress!")
template_vars = {"title": title, "site": Site.objects.get_current()}
msg_html = render_to_string("mail/html/invitation.html", template_vars)
msg_plain = render_to_string("mail/text/invitation.txt", template_vars)
mail.send_mail(
title,
msg_plain,
settings.EMAIL_FROM,
[self.email],
html_message=msg_html,
fail_silently=False,
)
except smtplib.SMTPException as exception:
logger.error("invitation to %s was not sent: %s", self.email, exception)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

@@ -1,58 +0,0 @@
"""Custom template tags for the core application of People."""
import base64
from django import template
from django.contrib.staticfiles import finders
from PIL import ImageFile as PillowImageFile
register = template.Library()
def image_to_base64(file_or_path, close=False):
"""
Return the src string of the base64 encoding of an image represented by its path
or file opened or not.
Inspired by Django's "get_image_dimensions"
"""
pil_parser = PillowImageFile.Parser()
if hasattr(file_or_path, "read"):
file = file_or_path
if file.closed and hasattr(file, "open"):
file_or_path.open()
file_pos = file.tell()
file.seek(0)
else:
try:
# pylint: disable=consider-using-with
file = open(file_or_path, "rb")
except OSError:
return ""
close = True
try:
image_data = file.read()
if not image_data:
return ""
pil_parser.feed(image_data)
if pil_parser.image:
mime_type = pil_parser.image.get_format_mimetype()
encoded_string = base64.b64encode(image_data)
return f"data:{mime_type:s};base64, {encoded_string.decode('utf-8'):s}"
return ""
finally:
if close:
file.close()
else:
file.seek(file_pos)
@register.simple_tag
def base64_static(path):
"""Return a static file into a base64."""
full_path = finders.find(path)
if full_path:
return image_to_base64(full_path, True)
return ""
@@ -1,8 +1,6 @@
"""
Tests for Documents API endpoint in impress's core app: create
"""
import uuid
import pytest
from rest_framework.test import APIClient
@@ -47,26 +45,3 @@ def test_api_documents_create_authenticated():
document = Document.objects.get()
assert document.title == "my document"
assert document.accesses.filter(role="owner", user=user).exists()
def test_api_documents_create_with_id_from_payload():
"""
We should be able to create a document with an ID from the payload.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
doc_id = uuid.uuid4()
response = client.post(
"/api/v1.0/documents/",
{"title": "my document", "id": str(doc_id)},
format="json",
)
assert response.status_code == 201
document = Document.objects.get()
assert document.title == "my document"
assert document.id == doc_id
assert document.accesses.filter(role="owner", user=user).exists()
@@ -45,12 +45,14 @@ def test_api_documents_delete_authenticated_unrelated():
assert models.Document.objects.count() == 1
@pytest.mark.parametrize("role", ["reader", "editor", "administrator"])
@pytest.mark.parametrize("role", ["member", "administrator"])
@pytest.mark.parametrize("via", VIA)
def test_api_documents_delete_authenticated_not_owner(via, role, mock_user_get_teams):
def test_api_documents_delete_authenticated_member_or_administrator(
via, role, mock_user_get_teams
):
"""
Authenticated users should not be allowed to delete a document for which they are
only a reader, editor or administrator.
only a member or administrator.
"""
user = factories.UserFactory()
@@ -5,7 +5,6 @@ import pytest
from rest_framework.test import APIClient
from core import factories
from core.api import serializers
pytestmark = pytest.mark.django_db
@@ -32,7 +31,7 @@ def test_api_documents_retrieve_anonymous_public():
"accesses": [],
"title": document.title,
"is_public": True,
"content": document.content,
"content": {"foo": document.content["foo"]},
}
@@ -77,7 +76,7 @@ def test_api_documents_retrieve_authenticated_unrelated_public():
"accesses": [],
"title": document.title,
"is_public": True,
"content": document.content,
"content": {"foo": document.content["foo"]},
}
@@ -113,37 +112,35 @@ def test_api_documents_retrieve_authenticated_related_direct():
document = factories.DocumentFactory()
access1 = factories.UserDocumentAccessFactory(document=document, user=user)
access2 = factories.UserDocumentAccessFactory(document=document)
access1_user = serializers.UserSerializer(instance=user).data
access2_user = serializers.UserSerializer(instance=access2.user).data
response = client.get(
f"/api/v1.0/documents/{document.id!s}/",
)
assert response.status_code == 200
content = response.json()
assert sorted(content.pop("accesses"), key=lambda x: x["id"]) == sorted(
assert sorted(content.pop("accesses"), key=lambda x: x["user"]) == sorted(
[
{
"id": str(access1.id),
"user": access1_user,
"user": str(user.id),
"team": "",
"role": access1.role,
"abilities": access1.get_abilities(user),
},
{
"id": str(access2.id),
"user": access2_user,
"user": str(access2.user.id),
"team": "",
"role": access2.role,
"abilities": access2.get_abilities(user),
},
],
key=lambda x: x["id"],
key=lambda x: x["user"],
)
assert response.json() == {
"id": str(document.id),
"title": document.title,
"content": document.content,
"content": {"foo": document.content["foo"]},
"abilities": document.get_abilities(user),
"is_public": document.is_public,
}
@@ -164,10 +161,7 @@ def test_api_documents_retrieve_authenticated_related_team_none(mock_user_get_te
document = factories.DocumentFactory(is_public=False)
factories.TeamDocumentAccessFactory(
document=document, team="readers", role="reader"
)
factories.TeamDocumentAccessFactory(
document=document, team="editors", role="editor"
document=document, team="members", role="member"
)
factories.TeamDocumentAccessFactory(
document=document, team="administrators", role="administrator"
@@ -184,10 +178,8 @@ def test_api_documents_retrieve_authenticated_related_team_none(mock_user_get_te
@pytest.mark.parametrize(
"teams",
[
["readers"],
["unknown", "readers"],
["editors"],
["unknown", "editors"],
["members"],
["unknown", "members"],
],
)
def test_api_documents_retrieve_authenticated_related_team_members(
@@ -206,11 +198,8 @@ def test_api_documents_retrieve_authenticated_related_team_members(
document = factories.DocumentFactory(is_public=False)
access_reader = factories.TeamDocumentAccessFactory(
document=document, team="readers", role="reader"
)
access_editor = factories.TeamDocumentAccessFactory(
document=document, team="editors", role="editor"
access_member = factories.TeamDocumentAccessFactory(
document=document, team="members", role="member"
)
access_administrator = factories.TeamDocumentAccessFactory(
document=document, team="administrators", role="administrator"
@@ -229,22 +218,14 @@ def test_api_documents_retrieve_authenticated_related_team_members(
"retrieve": True,
"set_role_to": [],
"update": False,
"partial_update": False,
}
assert sorted(content.pop("accesses"), key=lambda x: x["id"]) == sorted(
[
{
"id": str(access_reader.id),
"id": str(access_member.id),
"user": None,
"team": "readers",
"role": access_reader.role,
"abilities": expected_abilities,
},
{
"id": str(access_editor.id),
"user": None,
"team": "editors",
"role": access_editor.role,
"team": "members",
"role": access_member.role,
"abilities": expected_abilities,
},
{
@@ -274,7 +255,7 @@ def test_api_documents_retrieve_authenticated_related_team_members(
assert response.json() == {
"id": str(document.id),
"title": document.title,
"content": document.content,
"content": {"foo": document.content["foo"]},
"abilities": document.get_abilities(user),
"is_public": False,
}
@@ -284,7 +265,7 @@ def test_api_documents_retrieve_authenticated_related_team_members(
"teams",
[
["administrators"],
["editors", "administrators"],
["members", "administrators"],
["unknown", "administrators"],
],
)
@@ -304,11 +285,8 @@ def test_api_documents_retrieve_authenticated_related_team_administrators(
document = factories.DocumentFactory(is_public=False)
access_reader = factories.TeamDocumentAccessFactory(
document=document, team="readers", role="reader"
)
access_editor = factories.TeamDocumentAccessFactory(
document=document, team="editors", role="editor"
access_member = factories.TeamDocumentAccessFactory(
document=document, team="members", role="member"
)
access_administrator = factories.TeamDocumentAccessFactory(
document=document, team="administrators", role="administrator"
@@ -327,29 +305,15 @@ def test_api_documents_retrieve_authenticated_related_team_administrators(
assert sorted(content.pop("accesses"), key=lambda x: x["id"]) == sorted(
[
{
"id": str(access_reader.id),
"id": str(access_member.id),
"user": None,
"team": "readers",
"role": "reader",
"team": "members",
"role": "member",
"abilities": {
"destroy": True,
"retrieve": True,
"set_role_to": ["administrator", "editor"],
"set_role_to": ["administrator"],
"update": True,
"partial_update": True,
},
},
{
"id": str(access_editor.id),
"user": None,
"team": "editors",
"role": "editor",
"abilities": {
"destroy": True,
"retrieve": True,
"set_role_to": ["administrator", "reader"],
"update": True,
"partial_update": True,
},
},
{
@@ -360,9 +324,8 @@ def test_api_documents_retrieve_authenticated_related_team_administrators(
"abilities": {
"destroy": True,
"retrieve": True,
"set_role_to": ["editor", "reader"],
"set_role_to": ["member"],
"update": True,
"partial_update": True,
},
},
{
@@ -375,7 +338,6 @@ def test_api_documents_retrieve_authenticated_related_team_administrators(
"retrieve": True,
"set_role_to": [],
"update": False,
"partial_update": False,
},
},
{
@@ -391,7 +353,7 @@ def test_api_documents_retrieve_authenticated_related_team_administrators(
assert response.json() == {
"id": str(document.id),
"title": document.title,
"content": document.content,
"content": {"foo": document.content["foo"]},
"abilities": document.get_abilities(user),
"is_public": False,
}
@@ -422,11 +384,8 @@ def test_api_documents_retrieve_authenticated_related_team_owners(
document = factories.DocumentFactory(is_public=False)
access_reader = factories.TeamDocumentAccessFactory(
document=document, team="readers", role="reader"
)
access_editor = factories.TeamDocumentAccessFactory(
document=document, team="editors", role="editor"
access_member = factories.TeamDocumentAccessFactory(
document=document, team="members", role="member"
)
access_administrator = factories.TeamDocumentAccessFactory(
document=document, team="administrators", role="administrator"
@@ -445,29 +404,15 @@ def test_api_documents_retrieve_authenticated_related_team_owners(
assert sorted(content.pop("accesses"), key=lambda x: x["id"]) == sorted(
[
{
"id": str(access_reader.id),
"id": str(access_member.id),
"user": None,
"team": "readers",
"role": "reader",
"team": "members",
"role": "member",
"abilities": {
"destroy": True,
"retrieve": True,
"set_role_to": ["owner", "administrator", "editor"],
"set_role_to": ["owner", "administrator"],
"update": True,
"partial_update": True,
},
},
{
"id": str(access_editor.id),
"user": None,
"team": "editors",
"role": "editor",
"abilities": {
"destroy": True,
"retrieve": True,
"set_role_to": ["owner", "administrator", "reader"],
"update": True,
"partial_update": True,
},
},
{
@@ -478,9 +423,8 @@ def test_api_documents_retrieve_authenticated_related_team_owners(
"abilities": {
"destroy": True,
"retrieve": True,
"set_role_to": ["owner", "editor", "reader"],
"set_role_to": ["owner", "member"],
"update": True,
"partial_update": True,
},
},
{
@@ -492,11 +436,10 @@ def test_api_documents_retrieve_authenticated_related_team_owners(
# editable only if there is another owner role than the user's team...
"destroy": other_access.role == "owner",
"retrieve": True,
"set_role_to": ["administrator", "editor", "reader"]
"set_role_to": ["administrator", "member"]
if other_access.role == "owner"
else [],
"update": other_access.role == "owner",
"partial_update": other_access.role == "owner",
},
},
{
@@ -512,7 +455,7 @@ def test_api_documents_retrieve_authenticated_related_team_owners(
assert response.json() == {
"id": str(document.id),
"title": document.title,
"content": document.content,
"content": {"foo": document.content["foo"]},
"abilities": document.get_abilities(user),
"is_public": False,
}
@@ -66,9 +66,9 @@ def test_api_documents_update_authenticated_unrelated():
@pytest.mark.parametrize("via", VIA)
def test_api_documents_update_authenticated_reader(via, mock_user_get_teams):
def test_api_documents_update_authenticated_members(via, mock_user_get_teams):
"""
Users who are editors or reader of a document but not administrators should
Users who are members of a document but not administrators should
not be allowed to update it.
"""
user = factories.UserFactory()
@@ -78,11 +78,11 @@ def test_api_documents_update_authenticated_reader(via, mock_user_get_teams):
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role="reader")
factories.UserDocumentAccessFactory(document=document, user=user, role="member")
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="reader"
document=document, team="lasuite", role="member"
)
old_document_values = serializers.DocumentSerializer(instance=document).data
@@ -106,12 +106,12 @@ def test_api_documents_update_authenticated_reader(via, mock_user_get_teams):
assert document_values == old_document_values
@pytest.mark.parametrize("role", ["editor", "administrator", "owner"])
@pytest.mark.parametrize("role", ["administrator", "owner"])
@pytest.mark.parametrize("via", VIA)
def test_api_documents_update_authenticated_editor_administrator_or_owner(
def test_api_documents_update_authenticated_administrator_or_owner(
via, role, mock_user_get_teams
):
"""A user who is editor, administrator or owner of a document should be allowed to update it."""
"""Administrator or owner of a document should be allowed to update it."""
user = factories.UserFactory()
client = APIClient()
@@ -45,7 +45,7 @@ def test_api_templates_delete_authenticated_unrelated():
assert models.Template.objects.count() == 1
@pytest.mark.parametrize("role", ["reader", "editor", "administrator"])
@pytest.mark.parametrize("role", ["member", "administrator"])
@pytest.mark.parametrize("via", VIA)
def test_api_templates_delete_authenticated_member_or_administrator(
via, role, mock_user_get_teams
@@ -160,10 +160,7 @@ def test_api_templates_retrieve_authenticated_related_team_none(mock_user_get_te
template = factories.TemplateFactory(is_public=False)
factories.TeamTemplateAccessFactory(
template=template, team="readers", role="reader"
)
factories.TeamTemplateAccessFactory(
template=template, team="editors", role="editor"
template=template, team="members", role="member"
)
factories.TeamTemplateAccessFactory(
template=template, team="administrators", role="administrator"
@@ -180,13 +177,11 @@ def test_api_templates_retrieve_authenticated_related_team_none(mock_user_get_te
@pytest.mark.parametrize(
"teams",
[
["readers"],
["unknown", "readers"],
["editors"],
["unknown", "editors"],
["members"],
["unknown", "members"],
],
)
def test_api_templates_retrieve_authenticated_related_team_readers_or_editors(
def test_api_templates_retrieve_authenticated_related_team_members(
teams, mock_user_get_teams
):
"""
@@ -202,11 +197,8 @@ def test_api_templates_retrieve_authenticated_related_team_readers_or_editors(
template = factories.TemplateFactory(is_public=False)
access_reader = factories.TeamTemplateAccessFactory(
template=template, team="readers", role="reader"
)
access_editor = factories.TeamTemplateAccessFactory(
template=template, team="editors", role="editor"
access_member = factories.TeamTemplateAccessFactory(
template=template, team="members", role="member"
)
access_administrator = factories.TeamTemplateAccessFactory(
template=template, team="administrators", role="administrator"
@@ -225,22 +217,14 @@ def test_api_templates_retrieve_authenticated_related_team_readers_or_editors(
"retrieve": True,
"set_role_to": [],
"update": False,
"partial_update": False,
}
assert sorted(content.pop("accesses"), key=lambda x: x["id"]) == sorted(
[
{
"id": str(access_reader.id),
"id": str(access_member.id),
"user": None,
"team": "readers",
"role": access_reader.role,
"abilities": expected_abilities,
},
{
"id": str(access_editor.id),
"user": None,
"team": "editors",
"role": access_editor.role,
"team": "members",
"role": access_member.role,
"abilities": expected_abilities,
},
{
@@ -301,11 +285,8 @@ def test_api_templates_retrieve_authenticated_related_team_administrators(
template = factories.TemplateFactory(is_public=False)
access_reader = factories.TeamTemplateAccessFactory(
template=template, team="readers", role="reader"
)
access_editor = factories.TeamTemplateAccessFactory(
template=template, team="editors", role="editor"
access_member = factories.TeamTemplateAccessFactory(
template=template, team="members", role="member"
)
access_administrator = factories.TeamTemplateAccessFactory(
template=template, team="administrators", role="administrator"
@@ -323,29 +304,15 @@ def test_api_templates_retrieve_authenticated_related_team_administrators(
assert sorted(content.pop("accesses"), key=lambda x: x["id"]) == sorted(
[
{
"id": str(access_reader.id),
"id": str(access_member.id),
"user": None,
"team": "readers",
"role": "reader",
"team": "members",
"role": "member",
"abilities": {
"destroy": True,
"retrieve": True,
"set_role_to": ["administrator", "editor"],
"set_role_to": ["administrator"],
"update": True,
"partial_update": True,
},
},
{
"id": str(access_editor.id),
"user": None,
"team": "editors",
"role": "editor",
"abilities": {
"destroy": True,
"retrieve": True,
"set_role_to": ["administrator", "reader"],
"update": True,
"partial_update": True,
},
},
{
@@ -356,9 +323,8 @@ def test_api_templates_retrieve_authenticated_related_team_administrators(
"abilities": {
"destroy": True,
"retrieve": True,
"set_role_to": ["editor", "reader"],
"set_role_to": ["member"],
"update": True,
"partial_update": True,
},
},
{
@@ -371,7 +337,6 @@ def test_api_templates_retrieve_authenticated_related_team_administrators(
"retrieve": True,
"set_role_to": [],
"update": False,
"partial_update": False,
},
},
{
@@ -419,11 +384,8 @@ def test_api_templates_retrieve_authenticated_related_team_owners(
template = factories.TemplateFactory(is_public=False)
access_reader = factories.TeamTemplateAccessFactory(
template=template, team="readers", role="reader"
)
access_editor = factories.TeamTemplateAccessFactory(
template=template, team="editors", role="editor"
access_member = factories.TeamTemplateAccessFactory(
template=template, team="members", role="member"
)
access_administrator = factories.TeamTemplateAccessFactory(
template=template, team="administrators", role="administrator"
@@ -441,29 +403,15 @@ def test_api_templates_retrieve_authenticated_related_team_owners(
assert sorted(content.pop("accesses"), key=lambda x: x["id"]) == sorted(
[
{
"id": str(access_reader.id),
"id": str(access_member.id),
"user": None,
"team": "readers",
"role": "reader",
"team": "members",
"role": "member",
"abilities": {
"destroy": True,
"retrieve": True,
"set_role_to": ["owner", "administrator", "editor"],
"set_role_to": ["owner", "administrator"],
"update": True,
"partial_update": True,
},
},
{
"id": str(access_editor.id),
"user": None,
"team": "editors",
"role": "editor",
"abilities": {
"destroy": True,
"retrieve": True,
"set_role_to": ["owner", "administrator", "reader"],
"update": True,
"partial_update": True,
},
},
{
@@ -474,9 +422,8 @@ def test_api_templates_retrieve_authenticated_related_team_owners(
"abilities": {
"destroy": True,
"retrieve": True,
"set_role_to": ["owner", "editor", "reader"],
"set_role_to": ["owner", "member"],
"update": True,
"partial_update": True,
},
},
{
@@ -488,11 +435,10 @@ def test_api_templates_retrieve_authenticated_related_team_owners(
# editable only if there is another owner role than the user's team...
"destroy": other_access.role == "owner",
"retrieve": True,
"set_role_to": ["administrator", "editor", "reader"]
"set_role_to": ["administrator", "member"]
if other_access.role == "owner"
else [],
"update": other_access.role == "owner",
"partial_update": other_access.role == "owner",
},
},
{
@@ -66,9 +66,10 @@ def test_api_templates_update_authenticated_unrelated():
@pytest.mark.parametrize("via", VIA)
def test_api_templates_update_authenticated_readers(via, mock_user_get_teams):
def test_api_templates_update_authenticated_members(via, mock_user_get_teams):
"""
Users who are readers of a template should not be allowed to update it.
Users who are members of a template but not administrators should
not be allowed to update it.
"""
user = factories.UserFactory()
@@ -77,11 +78,11 @@ def test_api_templates_update_authenticated_readers(via, mock_user_get_teams):
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role="reader")
factories.UserTemplateAccessFactory(template=template, user=user, role="member")
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="reader"
template=template, team="lasuite", role="member"
)
old_template_values = serializers.TemplateSerializer(instance=template).data
@@ -105,9 +106,9 @@ def test_api_templates_update_authenticated_readers(via, mock_user_get_teams):
assert template_values == old_template_values
@pytest.mark.parametrize("role", ["editor", "administrator", "owner"])
@pytest.mark.parametrize("role", ["administrator", "owner"])
@pytest.mark.parametrize("via", VIA)
def test_api_templates_update_authenticated_editor_or_administrator_or_owner(
def test_api_templates_update_authenticated_administrator_or_owner(
via, role, mock_user_get_teams
):
"""Administrator or owner of a template should be allowed to update it."""
@@ -92,9 +92,6 @@ def test_api_document_accesses_list_authenticated_related(via, mock_user_get_tea
f"/api/v1.0/documents/{document.id!s}/accesses/",
)
access2_user = serializers.UserSerializer(instance=access2.user).data
base_user = serializers.UserSerializer(instance=user).data
assert response.status_code == 200
content = response.json()
assert len(content["results"]) == 3
@@ -102,7 +99,7 @@ def test_api_document_accesses_list_authenticated_related(via, mock_user_get_tea
[
{
"id": str(user_access.id),
"user": base_user if via == "user" else None,
"user": str(user.id) if via == "user" else None,
"team": "lasuite" if via == "team" else "",
"role": user_access.role,
"abilities": user_access.get_abilities(user),
@@ -116,7 +113,7 @@ def test_api_document_accesses_list_authenticated_related(via, mock_user_get_tea
},
{
"id": str(access2.id),
"user": access2_user,
"user": str(access2.user.id),
"team": "",
"role": access2.role,
"abilities": access2.get_abilities(user),
@@ -200,12 +197,10 @@ def test_api_document_accesses_retrieve_authenticated_related(via, mock_user_get
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
)
access_user = serializers.UserSerializer(instance=access.user).data
assert response.status_code == 200
assert response.json() == {
"id": str(access.id),
"user": access_user,
"user": str(access.user.id),
"team": "",
"role": access.role,
"abilities": access.get_abilities(user),
@@ -259,12 +254,9 @@ def test_api_document_accesses_create_authenticated_unrelated():
assert not models.DocumentAccess.objects.filter(user=other_user).exists()
@pytest.mark.parametrize("role", ["reader", "editor"])
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_create_authenticated_reader_or_editor(
via, role, mock_user_get_teams
):
"""Readers or editors of a document should not be allowed to create document accesses."""
def test_api_document_accesses_create_authenticated_member(via, mock_user_get_teams):
"""Members of a document should not be allowed to create document accesses."""
user = factories.UserFactory()
client = APIClient()
@@ -272,21 +264,21 @@ def test_api_document_accesses_create_authenticated_reader_or_editor(
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
factories.UserDocumentAccessFactory(document=document, user=user, role="member")
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
document=document, team="lasuite", role="member"
)
other_user = factories.UserFactory()
for new_role in [role[0] for role in models.RoleChoices.choices]:
for role in [role[0] for role in models.RoleChoices.choices]:
response = client.post(
f"/api/v1.0/documents/{document.id!s}/accesses/",
{
"user": str(other_user.id),
"role": new_role,
"role": role,
},
format="json",
)
@@ -345,7 +337,7 @@ def test_api_document_accesses_create_authenticated_administrator(
response = client.post(
f"/api/v1.0/documents/{document.id!s}/accesses/",
{
"user_id": str(other_user.id),
"user": str(other_user.id),
"role": role,
},
format="json",
@@ -354,13 +346,12 @@ def test_api_document_accesses_create_authenticated_administrator(
assert response.status_code == 201
assert models.DocumentAccess.objects.filter(user=other_user).count() == 1
new_document_access = models.DocumentAccess.objects.filter(user=other_user).get()
other_user = serializers.UserSerializer(instance=other_user).data
assert response.json() == {
"abilities": new_document_access.get_abilities(user),
"id": str(new_document_access.id),
"team": "",
"role": role,
"user": other_user,
"user": str(other_user.id),
}
@@ -390,7 +381,7 @@ def test_api_document_accesses_create_authenticated_owner(via, mock_user_get_tea
response = client.post(
f"/api/v1.0/documents/{document.id!s}/accesses/",
{
"user_id": str(other_user.id),
"user": str(other_user.id),
"role": role,
},
format="json",
@@ -399,10 +390,9 @@ def test_api_document_accesses_create_authenticated_owner(via, mock_user_get_tea
assert response.status_code == 201
assert models.DocumentAccess.objects.filter(user=other_user).count() == 1
new_document_access = models.DocumentAccess.objects.filter(user=other_user).get()
other_user = serializers.UserSerializer(instance=other_user).data
assert response.json() == {
"id": str(new_document_access.id),
"user": other_user,
"user": str(other_user.id),
"team": "",
"role": role,
"abilities": new_document_access.get_abilities(user),
@@ -466,12 +456,9 @@ def test_api_document_accesses_update_authenticated_unrelated():
assert updated_values == old_values
@pytest.mark.parametrize("role", ["reader", "editor"])
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_update_authenticated_reader_or_editor(
via, role, mock_user_get_teams
):
"""Readers or editors of a document should not be allowed to update its accesses."""
def test_api_document_accesses_update_authenticated_member(via, mock_user_get_teams):
"""Members of a document should not be allowed to update its accesses."""
user = factories.UserFactory()
client = APIClient()
@@ -479,11 +466,11 @@ def test_api_document_accesses_update_authenticated_reader_or_editor(
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
factories.UserDocumentAccessFactory(document=document, user=user, role="member")
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
document=document, team="lasuite", role="member"
)
access = factories.UserDocumentAccessFactory(document=document)
@@ -534,14 +521,14 @@ def test_api_document_accesses_update_administrator_except_owner(
access = factories.UserDocumentAccessFactory(
document=document,
role=random.choice(["administrator", "editor", "reader"]),
role=random.choice(["administrator", "member"]),
)
old_values = serializers.DocumentAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(["administrator", "editor", "reader"]),
"role": random.choice(["administrator", "member"]),
}
for field, value in new_values.items():
@@ -642,7 +629,7 @@ def test_api_document_accesses_update_administrator_to_owner(via, mock_user_get_
access = factories.UserDocumentAccessFactory(
document=document,
user=other_user,
role=random.choice(["administrator", "editor", "reader"]),
role=random.choice(["administrator", "member"]),
)
old_values = serializers.DocumentAccessSerializer(instance=access).data
@@ -749,7 +736,7 @@ def test_api_document_accesses_update_owner_self(via, mock_user_get_teams):
)
old_values = serializers.DocumentAccessSerializer(instance=access).data
new_role = random.choice(["administrator", "editor", "reader"])
new_role = random.choice(["administrator", "member"])
response = client.put(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
@@ -766,13 +753,7 @@ def test_api_document_accesses_update_owner_self(via, mock_user_get_teams):
response = client.put(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
data={
**old_values,
"role": new_role,
"user_id": old_values.get("user", {}).get("id")
if old_values.get("user") is not None
else None,
},
data={**old_values, "role": new_role},
format="json",
)
@@ -816,12 +797,11 @@ def test_api_document_accesses_delete_authenticated():
assert models.DocumentAccess.objects.count() == 1
@pytest.mark.parametrize("role", ["reader", "editor"])
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_delete_reader_or_editor(via, role, mock_user_get_teams):
def test_api_document_accesses_delete_member(via, mock_user_get_teams):
"""
Authenticated users should not be allowed to delete a document access for a
document in which they are a simple reader or editor.
document in which they are a simple member.
"""
user = factories.UserFactory()
@@ -830,11 +810,11 @@ def test_api_document_accesses_delete_reader_or_editor(via, role, mock_user_get_
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
factories.UserDocumentAccessFactory(document=document, user=user, role="member")
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
document=document, team="lasuite", role="member"
)
access = factories.UserDocumentAccessFactory(document=document)
@@ -875,7 +855,7 @@ def test_api_document_accesses_delete_administrators_except_owners(
)
access = factories.UserDocumentAccessFactory(
document=document, role=random.choice(["reader", "editor", "administrator"])
document=document, role=random.choice(["member", "administrator"])
)
assert models.DocumentAccess.objects.count() == 2
@@ -1,504 +0,0 @@
"""
Unit tests for the Invitation model
"""
import random
import time
import pytest
from rest_framework import status
from rest_framework.test import APIClient
from core import factories, models
from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
def test_api_document_invitations__create__anonymous():
"""Anonymous users should not be able to create invitations."""
document = factories.DocumentFactory()
invitation_values = {
"email": "guest@example.com",
"role": random.choice(models.RoleChoices.choices)[0],
}
response = APIClient().post(
f"/api/v1.0/documents/{document.id}/invitations/",
invitation_values,
format="json",
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_document_invitations__create__authenticated_outsider():
"""Users outside of document should not be permitted to invite to document."""
user = factories.UserFactory()
document = factories.DocumentFactory()
invitation_values = {
"email": "guest@example.com",
"role": random.choice(models.RoleChoices.choices)[0],
}
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id}/invitations/",
invitation_values,
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
@pytest.mark.parametrize(
"inviting,invited,is_allowed",
(
["reader", "reader", False],
["reader", "editor", False],
["reader", "administrator", False],
["reader", "owner", False],
["editor", "reader", False],
["editor", "editor", False],
["editor", "administrator", False],
["editor", "owner", False],
["administrator", "reader", True],
["administrator", "editor", True],
["administrator", "administrator", True],
["administrator", "owner", False],
["owner", "reader", True],
["owner", "editor", True],
["owner", "administrator", True],
["owner", "owner", True],
),
)
@pytest.mark.parametrize("via", VIA)
def test_api_document_invitations__create__privileged_members(
via, inviting, invited, is_allowed, mock_user_get_teams
):
"""
Only owners and administrators should be able to invite new users.
Only owners can invite owners.
"""
user = factories.UserFactory()
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=inviting)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=inviting
)
invitation_values = {
"email": "guest@example.com",
"role": invited,
}
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id}/invitations/",
invitation_values,
format="json",
)
if is_allowed:
assert response.status_code == status.HTTP_201_CREATED
assert models.Invitation.objects.count() == 1
else:
assert response.status_code == status.HTTP_403_FORBIDDEN
assert models.Invitation.objects.exists() is False
def test_api_document_invitations__create__issuer_and_document_override():
"""It should not be possible to set the "document" and "issuer" fields."""
user = factories.UserFactory()
document = factories.DocumentFactory(users=[(user, "owner")])
other_document = factories.DocumentFactory(users=[(user, "owner")])
invitation_values = {
"document": str(other_document.id),
"issuer": str(factories.UserFactory().id),
"email": "guest@example.com",
"role": random.choice(models.RoleChoices.choices)[0],
}
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id}/invitations/",
invitation_values,
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
# document and issuer automatically set
assert response.json()["document"] == str(document.id)
assert response.json()["issuer"] == str(user.id)
def test_api_document_invitations__create__cannot_duplicate_invitation():
"""An email should not be invited multiple times to the same document."""
existing_invitation = factories.InvitationFactory()
document = existing_invitation.document
# Grant privileged role on the Document to the user
user = factories.UserFactory()
models.DocumentAccess.objects.create(
document=document, user=user, role="administrator"
)
# Create a new invitation to the same document with the exact same email address
invitation_values = {
"email": existing_invitation.email,
"role": random.choice(["administrator", "editor", "reader"]),
}
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id}/invitations/",
invitation_values,
format="json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["__all__"] == [
"Document invitation with this Email address and Document already exists."
]
def test_api_document_invitations__create__cannot_invite_existing_users():
"""
It should not be possible to invite already existing users.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(users=[(user, "owner")])
existing_user = factories.UserFactory()
# Build an invitation to the email of an exising identity in the db
invitation_values = {
"email": existing_user.email,
"role": random.choice(models.RoleChoices.choices)[0],
}
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id}/invitations/",
invitation_values,
format="json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["email"] == [
"This email is already associated to a registered user."
]
def test_api_document_invitations__list__anonymous_user():
"""Anonymous users should not be able to list invitations."""
document = factories.DocumentFactory()
response = APIClient().get(f"/api/v1.0/documents/{document.id}/invitations/")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@pytest.mark.parametrize("via", VIA)
def test_api_document_invitations__list__authenticated(
via, mock_user_get_teams, django_assert_num_queries
):
"""
Authenticated users should be able to list invitations for documents to which they are
related, whatever the role and including invitations issued by other users.
"""
user = factories.UserFactory()
other_user = factories.UserFactory()
document = factories.DocumentFactory()
role = random.choice(models.RoleChoices.choices)[0]
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
)
invitation = factories.InvitationFactory(
document=document, role="administrator", issuer=user
)
other_invitations = factories.InvitationFactory.create_batch(
2, document=document, role="reader", issuer=other_user
)
# invitations from other documents should not be listed
other_document = factories.DocumentFactory()
factories.InvitationFactory.create_batch(2, document=other_document, role="reader")
client = APIClient()
client.force_login(user)
with django_assert_num_queries(3):
response = client.get(
f"/api/v1.0/documents/{document.id}/invitations/",
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["count"] == 3
assert sorted(response.json()["results"], key=lambda x: x["created_at"]) == sorted(
[
{
"id": str(i.id),
"created_at": i.created_at.isoformat().replace("+00:00", "Z"),
"email": str(i.email),
"document": str(document.id),
"role": i.role,
"issuer": str(i.issuer.id),
"is_expired": False,
"abilities": {
"destroy": role in ["administrator", "owner"],
"update": False,
"partial_update": False,
"retrieve": True,
},
}
for i in [invitation, *other_invitations]
],
key=lambda x: x["created_at"],
)
def test_api_document_invitations__list__expired_invitations_still_listed(settings):
"""
Expired invitations are still listed.
"""
user = factories.UserFactory()
other_user = factories.UserFactory()
document = factories.DocumentFactory(
users=[(user, "administrator"), (other_user, "owner")]
)
# override settings to accelerate validation expiration
settings.INVITATION_VALIDITY_DURATION = 1 # second
expired_invitation = factories.InvitationFactory(
document=document,
role="reader",
issuer=user,
)
time.sleep(1)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/documents/{document.id}/invitations/",
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["count"] == 1
assert sorted(response.json()["results"], key=lambda x: x["created_at"]) == sorted(
[
{
"id": str(expired_invitation.id),
"created_at": expired_invitation.created_at.isoformat().replace(
"+00:00", "Z"
),
"email": str(expired_invitation.email),
"document": str(document.id),
"role": expired_invitation.role,
"issuer": str(expired_invitation.issuer.id),
"is_expired": True,
"abilities": {
"destroy": True,
"update": False,
"partial_update": False,
"retrieve": True,
},
},
],
key=lambda x: x["created_at"],
)
def test_api_document_invitations__retrieve__anonymous_user():
"""
Anonymous users should not be able to retrieve invitations.
"""
invitation = factories.InvitationFactory()
response = APIClient().get(
f"/api/v1.0/documents/{invitation.document.id}/invitations/{invitation.id}/",
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_api_document_invitations__retrieve__unrelated_user():
"""
Authenticated unrelated users should not be able to retrieve invitations.
"""
user = factories.UserFactory()
invitation = factories.InvitationFactory()
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/documents/{invitation.document.id!s}/invitations/{invitation.id!s}/",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
@pytest.mark.parametrize("via", VIA)
def test_api_document_invitations__retrieve__document_member(via, mock_user_get_teams):
"""
Authenticated users related to the document should be able to retrieve invitations
whatever their role in the document.
"""
user = factories.UserFactory()
invitation = factories.InvitationFactory()
role = random.choice(models.RoleChoices.choices)[0]
if via == USER:
factories.UserDocumentAccessFactory(
document=invitation.document, user=user, role=role
)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=invitation.document, team="lasuite", role=role
)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/documents/{invitation.document.id}/invitations/{invitation.id}/",
)
assert response.status_code == status.HTTP_200_OK
assert response.json() == {
"id": str(invitation.id),
"created_at": invitation.created_at.isoformat().replace("+00:00", "Z"),
"email": invitation.email,
"document": str(invitation.document.id),
"role": str(invitation.role),
"issuer": str(invitation.issuer.id),
"is_expired": False,
"abilities": {
"destroy": role in ["administrator", "owner"],
"update": False,
"partial_update": False,
"retrieve": True,
},
}
@pytest.mark.parametrize("via", VIA)
@pytest.mark.parametrize(
"method",
["put", "patch"],
)
def test_api_document_invitations__update__forbidden(method, via, mock_user_get_teams):
"""
Update of invitations is currently forbidden.
"""
user = factories.UserFactory()
invitation = factories.InvitationFactory()
if via == USER:
factories.UserDocumentAccessFactory(
document=invitation.document, user=user, role="owner"
)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=invitation.document, team="lasuite", role="owner"
)
client = APIClient()
client.force_login(user)
url = f"/api/v1.0/documents/{invitation.document.id}/invitations/{invitation.id}/"
if method == "put":
response = client.put(url)
if method == "patch":
response = client.patch(url)
assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
assert response.json()["detail"] == f'Method "{method.upper()}" not allowed.'
def test_api_document_invitations__delete__anonymous():
"""Anonymous user should not be able to delete invitations."""
invitation = factories.InvitationFactory()
response = APIClient().delete(
f"/api/v1.0/documents/{invitation.document.id}/invitations/{invitation.id}/",
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_api_document_invitations__delete__authenticated_outsider():
"""Members unrelated to a document should not be allowed to cancel invitations."""
user = factories.UserFactory()
document = factories.DocumentFactory()
invitation = factories.InvitationFactory(document=document)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/documents/{document.id}/invitations/{invitation.id}/",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
@pytest.mark.parametrize("via", VIA)
@pytest.mark.parametrize("role", ["owner", "administrator"])
def test_api_document_invitations__delete__privileged_members(
role, via, mock_user_get_teams
):
"""Privileged member should be able to cancel invitation."""
user = factories.UserFactory()
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
)
invitation = factories.InvitationFactory(document=document)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/documents/{document.id}/invitations/{invitation.id}/",
)
assert response.status_code == status.HTTP_204_NO_CONTENT
@pytest.mark.parametrize("role", ["reader", "editor"])
@pytest.mark.parametrize("via", VIA)
def test_api_document_invitations_delete_readers_or_editors(
via, role, mock_user_get_teams
):
"""Readers or editors should not be able to cancel invitation."""
user = factories.UserFactory()
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
)
invitation = factories.InvitationFactory(document=document)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/documents/{document.id}/invitations/{invitation.id}/",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert (
response.json()["detail"]
== "You do not have permission to perform this action."
)
@@ -128,7 +128,7 @@ def test_api_document_versions_list_authenticated_related(via, mock_user_get_tea
assert len(content["versions"]) == 0
# Add a new version to the document
document.content = "new content"
document.content = {"foo": "bar"}
document.save()
response = client.get(
@@ -243,7 +243,7 @@ def test_api_document_versions_retrieve_authenticated_related(via, mock_user_get
# 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.content = {"foo": "bar"}
document.save()
version_id = document.get_versions_slice()["versions"][0]["version_id"]
@@ -253,7 +253,7 @@ def test_api_document_versions_retrieve_authenticated_related(via, mock_user_get
)
assert response.status_code == 200
assert response.json()["content"] == "new content"
assert response.json()["content"] == {"foo": "bar"}
def test_api_document_versions_create_anonymous():
@@ -437,12 +437,11 @@ def test_api_document_versions_delete_authenticated_private():
assert response.json() == {"detail": "Not found."}
@pytest.mark.parametrize("role", ["reader", "editor"])
@pytest.mark.parametrize("via", VIA)
def test_api_document_versions_delete_reader_or_editor(via, role, mock_user_get_teams):
def test_api_document_versions_delete_member(via, mock_user_get_teams):
"""
Authenticated users should not be allowed to delete a document version for a
document in which they are a simple reader or editor.
document in which they are a simple member.
"""
user = factories.UserFactory()
@@ -451,16 +450,16 @@ def test_api_document_versions_delete_reader_or_editor(via, role, mock_user_get_
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
factories.UserDocumentAccessFactory(document=document, user=user, role="member")
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
document=document, team="lasuite", role="member"
)
# 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.content = {"foo": "bar"}
document.save()
versions = document.get_versions_slice()["versions"]
@@ -504,7 +503,7 @@ def test_api_document_versions_delete_administrator_or_owner(via, mock_user_get_
# 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.content = {"foo": "bar"}
document.save()
versions = document.get_versions_slice()["versions"]
@@ -254,12 +254,9 @@ def test_api_template_accesses_create_authenticated_unrelated():
assert not models.TemplateAccess.objects.filter(user=other_user).exists()
@pytest.mark.parametrize("role", ["reader", "editor"])
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_create_authenticated_editor_or_reader(
via, role, mock_user_get_teams
):
"""Editors or readers of a template should not be allowed to create template accesses."""
def test_api_template_accesses_create_authenticated_member(via, mock_user_get_teams):
"""Members of a template should not be allowed to create template accesses."""
user = factories.UserFactory()
client = APIClient()
@@ -267,21 +264,21 @@ def test_api_template_accesses_create_authenticated_editor_or_reader(
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role=role)
factories.UserTemplateAccessFactory(template=template, user=user, role="member")
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role=role
template=template, team="lasuite", role="member"
)
other_user = factories.UserFactory()
for new_role in [role[0] for role in models.RoleChoices.choices]:
for role in [role[0] for role in models.RoleChoices.choices]:
response = client.post(
f"/api/v1.0/templates/{template.id!s}/accesses/",
{
"user": str(other_user.id),
"role": new_role,
"role": role,
},
format="json",
)
@@ -459,12 +456,9 @@ def test_api_template_accesses_update_authenticated_unrelated():
assert updated_values == old_values
@pytest.mark.parametrize("role", ["reader", "editor"])
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_update_authenticated_editor_or_reader(
via, role, mock_user_get_teams
):
"""Editors or readers of a template should not be allowed to update its accesses."""
def test_api_template_accesses_update_authenticated_member(via, mock_user_get_teams):
"""Members of a template should not be allowed to update its accesses."""
user = factories.UserFactory()
client = APIClient()
@@ -472,11 +466,11 @@ def test_api_template_accesses_update_authenticated_editor_or_reader(
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role=role)
factories.UserTemplateAccessFactory(template=template, user=user, role="member")
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role=role
template=template, team="lasuite", role="member"
)
access = factories.UserTemplateAccessFactory(template=template)
@@ -527,14 +521,14 @@ def test_api_template_accesses_update_administrator_except_owner(
access = factories.UserTemplateAccessFactory(
template=template,
role=random.choice(["administrator", "editor", "reader"]),
role=random.choice(["administrator", "member"]),
)
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(["administrator", "editor", "reader"]),
"role": random.choice(["administrator", "member"]),
}
for field, value in new_values.items():
@@ -635,7 +629,7 @@ def test_api_template_accesses_update_administrator_to_owner(via, mock_user_get_
access = factories.UserTemplateAccessFactory(
template=template,
user=other_user,
role=random.choice(["administrator", "editor", "reader"]),
role=random.choice(["administrator", "member"]),
)
old_values = serializers.TemplateAccessSerializer(instance=access).data
@@ -742,7 +736,7 @@ def test_api_template_accesses_update_owner_self(via, mock_user_get_teams):
)
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_role = random.choice(["administrator", "editor", "reader"])
new_role = random.choice(["administrator", "member"])
response = client.put(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
@@ -803,12 +797,11 @@ def test_api_template_accesses_delete_authenticated():
assert models.TemplateAccess.objects.count() == 1
@pytest.mark.parametrize("role", ["reader", "editor"])
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_delete_editor_or_reader(via, role, mock_user_get_teams):
def test_api_template_accesses_delete_member(via, mock_user_get_teams):
"""
Authenticated users should not be allowed to delete a template access for a
template in which they are a simple editor or reader.
template in which they are a simple member.
"""
user = factories.UserFactory()
@@ -817,11 +810,11 @@ def test_api_template_accesses_delete_editor_or_reader(via, role, mock_user_get_
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role=role)
factories.UserTemplateAccessFactory(template=template, user=user, role="member")
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role=role
template=template, team="lasuite", role="member"
)
access = factories.UserTemplateAccessFactory(template=template)
@@ -862,7 +855,7 @@ def test_api_template_accesses_delete_administrators_except_owners(
)
access = factories.UserTemplateAccessFactory(
template=template, role=random.choice(["reader", "editor", "administrator"])
template=template, role=random.choice(["member", "administrator"])
)
assert models.TemplateAccess.objects.count() == 2
+15 -70
View File
@@ -15,15 +15,13 @@ def test_api_users_list_anonymous():
factories.UserFactory()
client = APIClient()
response = client.get("/api/v1.0/users/")
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
assert response.status_code == 404
assert "Not Found" in response.content.decode("utf-8")
def test_api_users_list_authenticated():
"""
Authenticated users should be able to list users.
Authenticated users should not be able to list users.
"""
user = factories.UserFactory()
@@ -34,62 +32,8 @@ def test_api_users_list_authenticated():
response = client.get(
"/api/v1.0/users/",
)
assert response.status_code == 200
content = response.json()
assert len(content["results"]) == 3
def test_api_users_list_query_email():
"""
Authenticated users should be able to list users
and filter by email.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
dave = factories.UserFactory(email="david.bowman@work.com")
nicole = factories.UserFactory(email="nicole_foole@work.com")
frank = factories.UserFactory(email="frank_poole@work.com")
factories.UserFactory(email="heywood_floyd@work.com")
response = client.get(
"/api/v1.0/users/?q=david.bowman@work.com",
)
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(dave.id)]
response = client.get("/api/v1.0/users/?q=oole")
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(nicole.id), str(frank.id)]
def test_api_users_list_query_email_exclude_doc_user():
"""
Authenticated users should be able to list users
and filter by email and exclude users who have access to a document.
"""
user = factories.UserFactory()
document = factories.DocumentFactory()
client = APIClient()
client.force_login(user)
nicole = factories.UserFactory(email="nicole_foole@work.com")
frank = factories.UserFactory(email="frank_poole@work.com")
factories.UserFactory(email="heywood_floyd@work.com")
factories.UserDocumentAccessFactory(document=document, user=frank)
response = client.get("/api/v1.0/users/?q=oole&document_id=" + str(document.id))
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(nicole.id)]
assert response.status_code == 404
assert "Not Found" in response.content.decode("utf-8")
def test_api_users_retrieve_me_anonymous():
@@ -118,7 +62,10 @@ def test_api_users_retrieve_me_authenticated():
assert response.status_code == 200
assert response.json() == {
"id": str(user.id),
"email": user.email,
"language": user.language,
"timezone": str(user.timezone),
"is_device": False,
"is_staff": False,
}
@@ -179,10 +126,8 @@ def test_api_users_create_anonymous():
"password": "mypassword",
},
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
assert response.status_code == 404
assert "Not Found" in response.content.decode("utf-8")
assert models.User.objects.exists() is False
@@ -201,8 +146,8 @@ def test_api_users_create_authenticated():
},
format="json",
)
assert response.status_code == 405
assert response.json() == {"detail": 'Method "POST" not allowed.'}
assert response.status_code == 404
assert "Not Found" in response.content.decode("utf-8")
assert models.User.objects.exclude(id=user.id).exists() is False
@@ -377,7 +322,7 @@ def test_api_users_delete_list_anonymous():
client = APIClient()
response = client.delete("/api/v1.0/users/")
assert response.status_code == 401
assert response.status_code == 404
assert models.User.objects.count() == 2
@@ -393,7 +338,7 @@ def test_api_users_delete_list_authenticated():
"/api/v1.0/users/",
)
assert response.status_code == 405
assert response.status_code == 404
assert models.User.objects.count() == 3
@@ -17,11 +17,11 @@ def test_models_document_accesses_str():
"""
user = factories.UserFactory(email="david.bowman@example.com")
access = factories.UserDocumentAccessFactory(
role="reader",
role="member",
user=user,
document__title="admins",
)
assert str(access) == "david.bowman@example.com is reader in document admins"
assert str(access) == "david.bowman@example.com is member in document admins"
def test_models_document_accesses_unique_user():
@@ -87,7 +87,6 @@ def test_models_document_access_get_abilities_anonymous():
"destroy": False,
"retrieve": False,
"update": False,
"partial_update": False,
"set_role_to": [],
}
@@ -101,7 +100,6 @@ def test_models_document_access_get_abilities_authenticated():
"destroy": False,
"retrieve": False,
"update": False,
"partial_update": False,
"set_role_to": [],
}
@@ -121,8 +119,7 @@ def test_models_document_access_get_abilities_for_owner_of_self_allowed():
"destroy": True,
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["administrator", "editor", "reader"],
"set_role_to": ["administrator", "member"],
}
@@ -136,7 +133,6 @@ def test_models_document_access_get_abilities_for_owner_of_self_last():
"destroy": False,
"retrieve": True,
"update": False,
"partial_update": False,
"set_role_to": [],
}
@@ -153,8 +149,7 @@ def test_models_document_access_get_abilities_for_owner_of_owner():
"destroy": True,
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["administrator", "editor", "reader"],
"set_role_to": ["administrator", "member"],
}
@@ -170,14 +165,13 @@ def test_models_document_access_get_abilities_for_owner_of_administrator():
"destroy": True,
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["owner", "editor", "reader"],
"set_role_to": ["owner", "member"],
}
def test_models_document_access_get_abilities_for_owner_of_editor():
"""Check abilities of editor access for the owner of a document."""
access = factories.UserDocumentAccessFactory(role="editor")
def test_models_document_access_get_abilities_for_owner_of_member():
"""Check abilities of member access for the owner of a document."""
access = factories.UserDocumentAccessFactory(role="member")
factories.UserDocumentAccessFactory(document=access.document) # another one
user = factories.UserDocumentAccessFactory(
document=access.document, role="owner"
@@ -187,25 +181,7 @@ def test_models_document_access_get_abilities_for_owner_of_editor():
"destroy": True,
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["owner", "administrator", "reader"],
}
def test_models_document_access_get_abilities_for_owner_of_reader():
"""Check abilities of reader access for the owner of a document."""
access = factories.UserDocumentAccessFactory(role="reader")
factories.UserDocumentAccessFactory(document=access.document) # another one
user = factories.UserDocumentAccessFactory(
document=access.document, role="owner"
).user
abilities = access.get_abilities(user)
assert abilities == {
"destroy": True,
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["owner", "administrator", "editor"],
"set_role_to": ["owner", "administrator"],
}
@@ -224,7 +200,6 @@ def test_models_document_access_get_abilities_for_administrator_of_owner():
"destroy": False,
"retrieve": True,
"update": False,
"partial_update": False,
"set_role_to": [],
}
@@ -241,14 +216,13 @@ def test_models_document_access_get_abilities_for_administrator_of_administrator
"destroy": True,
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["editor", "reader"],
"set_role_to": ["member"],
}
def test_models_document_access_get_abilities_for_administrator_of_editor():
"""Check abilities of editor access for the administrator of a document."""
access = factories.UserDocumentAccessFactory(role="editor")
def test_models_document_access_get_abilities_for_administrator_of_member():
"""Check abilities of member access for the administrator of a document."""
access = factories.UserDocumentAccessFactory(role="member")
factories.UserDocumentAccessFactory(document=access.document) # another one
user = factories.UserDocumentAccessFactory(
document=access.document, role="administrator"
@@ -258,73 +232,53 @@ def test_models_document_access_get_abilities_for_administrator_of_editor():
"destroy": True,
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["administrator", "reader"],
"set_role_to": ["administrator"],
}
def test_models_document_access_get_abilities_for_administrator_of_reader():
"""Check abilities of reader access for the administrator of a document."""
access = factories.UserDocumentAccessFactory(role="reader")
factories.UserDocumentAccessFactory(document=access.document) # another one
user = factories.UserDocumentAccessFactory(
document=access.document, role="administrator"
).user
abilities = access.get_abilities(user)
assert abilities == {
"destroy": True,
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["administrator", "editor"],
}
# - for member
# - for editor
def test_models_document_access_get_abilities_for_editor_of_owner():
"""Check abilities of owner access for the editor of a document."""
def test_models_document_access_get_abilities_for_member_of_owner():
"""Check abilities of owner access for the member of a document."""
access = factories.UserDocumentAccessFactory(role="owner")
factories.UserDocumentAccessFactory(document=access.document) # another one
user = factories.UserDocumentAccessFactory(
document=access.document, role="editor"
document=access.document, role="member"
).user
abilities = access.get_abilities(user)
assert abilities == {
"destroy": False,
"retrieve": True,
"update": False,
"partial_update": False,
"set_role_to": [],
}
def test_models_document_access_get_abilities_for_editor_of_administrator():
"""Check abilities of administrator access for the editor of a document."""
def test_models_document_access_get_abilities_for_member_of_administrator():
"""Check abilities of administrator access for the member of a document."""
access = factories.UserDocumentAccessFactory(role="administrator")
factories.UserDocumentAccessFactory(document=access.document) # another one
user = factories.UserDocumentAccessFactory(
document=access.document, role="editor"
document=access.document, role="member"
).user
abilities = access.get_abilities(user)
assert abilities == {
"destroy": False,
"retrieve": True,
"update": False,
"partial_update": False,
"set_role_to": [],
}
def test_models_document_access_get_abilities_for_editor_of_editor_user(
def test_models_document_access_get_abilities_for_member_of_member_user(
django_assert_num_queries
):
"""Check abilities of editor access for the editor of a document."""
access = factories.UserDocumentAccessFactory(role="editor")
"""Check abilities of member access for the member of a document."""
access = factories.UserDocumentAccessFactory(role="member")
factories.UserDocumentAccessFactory(document=access.document) # another one
user = factories.UserDocumentAccessFactory(
document=access.document, role="editor"
document=access.document, role="member"
).user
with django_assert_num_queries(1):
@@ -334,77 +288,17 @@ def test_models_document_access_get_abilities_for_editor_of_editor_user(
"destroy": False,
"retrieve": True,
"update": False,
"partial_update": False,
"set_role_to": [],
}
# - for reader
def test_models_document_access_get_abilities_for_reader_of_owner():
"""Check abilities of owner access for the reader of a document."""
access = factories.UserDocumentAccessFactory(role="owner")
factories.UserDocumentAccessFactory(document=access.document) # another one
user = factories.UserDocumentAccessFactory(
document=access.document, role="reader"
).user
abilities = access.get_abilities(user)
assert abilities == {
"destroy": False,
"retrieve": True,
"update": False,
"partial_update": False,
"set_role_to": [],
}
def test_models_document_access_get_abilities_for_reader_of_administrator():
"""Check abilities of administrator access for the reader of a document."""
access = factories.UserDocumentAccessFactory(role="administrator")
factories.UserDocumentAccessFactory(document=access.document) # another one
user = factories.UserDocumentAccessFactory(
document=access.document, role="reader"
).user
abilities = access.get_abilities(user)
assert abilities == {
"destroy": False,
"retrieve": True,
"update": False,
"partial_update": False,
"set_role_to": [],
}
def test_models_document_access_get_abilities_for_reader_of_reader_user(
django_assert_num_queries
):
"""Check abilities of reader access for the reader of a document."""
access = factories.UserDocumentAccessFactory(role="reader")
factories.UserDocumentAccessFactory(document=access.document) # another one
user = factories.UserDocumentAccessFactory(
document=access.document, role="reader"
).user
with django_assert_num_queries(1):
abilities = access.get_abilities(user)
assert abilities == {
"destroy": False,
"retrieve": True,
"update": False,
"partial_update": False,
"set_role_to": [],
}
def test_models_document_access_get_abilities_preset_role(django_assert_num_queries):
"""No query is done if the role is preset, e.g., with a query annotation."""
access = factories.UserDocumentAccessFactory(role="reader")
access = factories.UserDocumentAccessFactory(role="member")
user = factories.UserDocumentAccessFactory(
document=access.document, role="reader"
document=access.document, role="member"
).user
access.user_roles = ["reader"]
access.user_roles = ["member"]
with django_assert_num_queries(0):
abilities = access.get_abilities(user)
@@ -413,6 +307,5 @@ def test_models_document_access_get_abilities_preset_role(django_assert_num_quer
"destroy": False,
"retrieve": True,
"update": False,
"partial_update": False,
"set_role_to": [],
}
@@ -47,12 +47,6 @@ def test_models_documents_title_max_length():
factories.DocumentFactory(title="a" * 256)
def test_models_documents_file_key():
"""The file key should be built from the instance uuid."""
document = factories.DocumentFactory(id="9531a5f1-42b1-496c-b3f4-1c09ed139b3c")
assert document.file_key == "9531a5f1-42b1-496c-b3f4-1c09ed139b3c/file"
# get_abilities
@@ -153,28 +147,9 @@ def test_models_documents_get_abilities_administrator():
}
def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
"""Check abilities returned for the editor of a document."""
access = factories.UserDocumentAccessFactory(role="editor")
with django_assert_num_queries(1):
abilities = access.document.get_abilities(access.user)
assert abilities == {
"destroy": False,
"retrieve": True,
"update": True,
"manage_accesses": False,
"partial_update": True,
"versions_destroy": False,
"versions_list": True,
"versions_retrieve": True,
}
def test_models_documents_get_abilities_reader_user(django_assert_num_queries):
"""Check abilities returned for the reader of a document."""
access = factories.UserDocumentAccessFactory(role="reader")
def test_models_documents_get_abilities_member_user(django_assert_num_queries):
"""Check abilities returned for the member of a document."""
access = factories.UserDocumentAccessFactory(role="member")
with django_assert_num_queries(1):
abilities = access.document.get_abilities(access.user)
@@ -193,8 +168,8 @@ def test_models_documents_get_abilities_reader_user(django_assert_num_queries):
def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
"""No query is done if the role is preset e.g. with query annotation."""
access = factories.UserDocumentAccessFactory(role="reader")
access.document.user_roles = ["reader"]
access = factories.UserDocumentAccessFactory(role="member")
access.document.user_roles = ["member"]
with django_assert_num_queries(0):
abilities = access.document.get_abilities(access.user)
@@ -221,7 +196,7 @@ def test_models_documents_get_versions_slice(settings):
# Create a document with 7 versions
document = factories.DocumentFactory()
for i in range(6):
document.content = f"bar{i:d}"
document.content = {"foo": f"bar{i:d}"}
document.save()
# Add a version not related to the first document
@@ -271,7 +246,7 @@ def test_models_documents_version_duplicate():
assert len(response["Versions"]) == 1
# Save modified content
document.content = "new content"
document.content = {"foo": "spam"}
document.save()
response = default_storage.connection.meta.client.list_object_versions(
@@ -1,337 +0,0 @@
"""
Unit tests for the Invitation model
"""
import smtplib
import time
from logging import Logger
from unittest import mock
from django.contrib.auth.models import AnonymousUser
from django.core import exceptions, mail
import pytest
from faker import Faker
from freezegun import freeze_time
from core import factories, models
from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
fake = Faker()
def test_models_invitations_readonly_after_create():
"""Existing invitations should be readonly."""
invitation = factories.InvitationFactory()
with pytest.raises(exceptions.PermissionDenied):
invitation.save()
def test_models_invitations_email_no_empty_mail():
"""The "email" field should not be empty."""
with pytest.raises(exceptions.ValidationError, match="This field cannot be blank"):
factories.InvitationFactory(email="")
def test_models_invitations_email_no_null_mail():
"""The "email" field is required."""
with pytest.raises(exceptions.ValidationError, match="This field cannot be null"):
factories.InvitationFactory(email=None)
def test_models_invitations_document_required():
"""The "document" field is required."""
with pytest.raises(exceptions.ValidationError, match="This field cannot be null"):
factories.InvitationFactory(document=None)
def test_models_invitations_document_should_be_document_instance():
"""The "document" field should be a document instance."""
with pytest.raises(
ValueError, match='Invitation.document" must be a "Document" instance'
):
factories.InvitationFactory(document="ee")
def test_models_invitations_role_required():
"""The "role" field is required."""
with pytest.raises(exceptions.ValidationError, match="This field cannot be blank"):
factories.InvitationFactory(role="")
def test_models_invitations_role_among_choices():
"""The "role" field should be a valid choice."""
with pytest.raises(
exceptions.ValidationError, match="Value 'boss' is not a valid choice"
):
factories.InvitationFactory(role="boss")
def test_models_invitations__is_expired(settings):
"""
The 'is_expired' property should return False until validity duration
is exceeded and True afterwards.
"""
expired_invitation = factories.InvitationFactory()
assert expired_invitation.is_expired is False
settings.INVITATION_VALIDITY_DURATION = 1
time.sleep(1)
assert expired_invitation.is_expired is True
def test_models_invitation__new_user__convert_invitations_to_accesses():
"""
Upon creating a new user, invitations linked to the email
should be converted to accesses and then deleted.
"""
# Two invitations to the same mail but to different documents
invitation_to_document1 = factories.InvitationFactory()
invitation_to_document2 = factories.InvitationFactory(
email=invitation_to_document1.email
)
other_invitation = factories.InvitationFactory(
document=invitation_to_document2.document
) # another person invited to document2
new_user = factories.UserFactory(email=invitation_to_document1.email)
# The invitation regarding
assert models.DocumentAccess.objects.filter(
document=invitation_to_document1.document, user=new_user
).exists()
assert models.DocumentAccess.objects.filter(
document=invitation_to_document2.document, user=new_user
).exists()
assert not models.Invitation.objects.filter(
document=invitation_to_document1.document, email=invitation_to_document1.email
).exists() # invitation "consumed"
assert not models.Invitation.objects.filter(
document=invitation_to_document2.document, email=invitation_to_document2.email
).exists() # invitation "consumed"
assert models.Invitation.objects.filter(
document=invitation_to_document2.document, email=other_invitation.email
).exists() # the other invitation remains
def test_models_invitation__new_user__filter_expired_invitations():
"""
Upon creating a new identity, valid invitations should be converted into accesses
and expired invitations should remain unchanged.
"""
document = factories.DocumentFactory()
with freeze_time("2020-01-01"):
expired_invitation = factories.InvitationFactory(document=document)
user_email = expired_invitation.email
valid_invitation = factories.InvitationFactory(email=user_email)
new_user = factories.UserFactory(email=user_email)
# valid invitation should have granted access to the related document
assert models.DocumentAccess.objects.filter(
document=valid_invitation.document, user=new_user
).exists()
assert not models.Invitation.objects.filter(
document=valid_invitation.document, email=user_email
).exists()
# expired invitation should not have been consumed
assert not models.DocumentAccess.objects.filter(
document=expired_invitation.document, user=new_user
).exists()
assert models.Invitation.objects.filter(
document=expired_invitation.document, email=user_email
).exists()
@pytest.mark.parametrize("num_invitations, num_queries", [(0, 3), (1, 6), (20, 6)])
def test_models_invitation__new_user__user_creation_constant_num_queries(
django_assert_num_queries, num_invitations, num_queries
):
"""
The number of queries executed during user creation should not be proportional
to the number of invitations being processed.
"""
user_email = fake.email()
if num_invitations != 0:
factories.InvitationFactory.create_batch(num_invitations, email=user_email)
# with no invitation, we skip an "if", resulting in 8 requests
# otherwise, we should have 11 queries with any number of invitations
with django_assert_num_queries(num_queries):
models.User.objects.create(email=user_email, password="!")
def test_models_document_invitations_email():
"""Check email invitation during invitation creation."""
member_access = factories.UserDocumentAccessFactory(role="reader")
document = member_access.document
# pylint: disable-next=no-member
assert len(mail.outbox) == 0
factories.UserDocumentAccessFactory(document=document)
invitation = factories.InvitationFactory(document=document, email="john@people.com")
# pylint: disable-next=no-member
assert len(mail.outbox) == 1
# pylint: disable-next=no-member
email = mail.outbox[0]
assert email.to == [invitation.email]
assert email.subject == "Invitation to join Impress!"
email_content = " ".join(email.body.split())
assert "Invitation to join Impress!" in email_content
assert "[//example.com]" in email_content
@mock.patch(
"django.core.mail.send_mail",
side_effect=smtplib.SMTPException("Error SMTPException"),
)
@mock.patch.object(Logger, "error")
def test_models_document_invitations_email_failed(mock_logger, _mock_send_mail):
"""Check invitation behavior when an SMTP error occurs during invitation creation."""
member_access = factories.UserDocumentAccessFactory(role="reader")
document = member_access.document
# pylint: disable-next=no-member
assert len(mail.outbox) == 0
factories.UserDocumentAccessFactory(document=document)
# No error should be raised
invitation = factories.InvitationFactory(document=document, email="john@people.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 == invitation.email
assert isinstance(exception, smtplib.SMTPException)
# get_abilities
def test_models_document_invitations_get_abilities_anonymous():
"""Check abilities returned for an anonymous user."""
access = factories.InvitationFactory()
abilities = access.get_abilities(AnonymousUser())
assert abilities == {
"destroy": False,
"retrieve": False,
"partial_update": False,
"update": False,
}
def test_models_document_invitations_get_abilities_authenticated():
"""Check abilities returned for an authenticated user."""
access = factories.InvitationFactory()
user = factories.UserFactory()
abilities = access.get_abilities(user)
assert abilities == {
"destroy": False,
"retrieve": False,
"partial_update": False,
"update": False,
}
@pytest.mark.parametrize("via", VIA)
@pytest.mark.parametrize("role", ["administrator", "owner"])
def test_models_document_invitations_get_abilities_privileged_member(
role, via, mock_user_get_teams
):
"""Check abilities for a document member with a privileged role."""
user = factories.UserFactory()
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
)
factories.UserDocumentAccessFactory(document=document) # another one
invitation = factories.InvitationFactory(document=document)
abilities = invitation.get_abilities(user)
assert abilities == {
"destroy": True,
"retrieve": True,
"partial_update": False,
"update": False,
}
@pytest.mark.parametrize("via", VIA)
def test_models_document_invitations_get_abilities_reader(via, mock_user_get_teams):
"""Check abilities for a document reader with 'reader' role."""
user = factories.UserFactory()
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role="reader")
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="reader"
)
invitation = factories.InvitationFactory(document=document)
abilities = invitation.get_abilities(user)
assert abilities == {
"destroy": False,
"retrieve": True,
"partial_update": False,
"update": False,
}
@pytest.mark.parametrize("via", VIA)
def test_models_document_invitations_get_abilities_editor(via, mock_user_get_teams):
"""Check abilities for a document editor with 'editor' role."""
user = factories.UserFactory()
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role="editor")
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="editor"
)
invitation = factories.InvitationFactory(document=document)
abilities = invitation.get_abilities(user)
assert abilities == {
"destroy": False,
"retrieve": True,
"partial_update": False,
"update": False,
}
@@ -17,11 +17,11 @@ def test_models_template_accesses_str():
"""
user = factories.UserFactory(email="david.bowman@example.com")
access = factories.UserTemplateAccessFactory(
role="reader",
role="member",
user=user,
template__title="admins",
)
assert str(access) == "david.bowman@example.com is reader in template admins"
assert str(access) == "david.bowman@example.com is member in template admins"
def test_models_template_accesses_unique_user():
@@ -87,7 +87,6 @@ def test_models_template_access_get_abilities_anonymous():
"destroy": False,
"retrieve": False,
"update": False,
"partial_update": False,
"set_role_to": [],
}
@@ -101,7 +100,6 @@ def test_models_template_access_get_abilities_authenticated():
"destroy": False,
"retrieve": False,
"update": False,
"partial_update": False,
"set_role_to": [],
}
@@ -121,8 +119,7 @@ def test_models_template_access_get_abilities_for_owner_of_self_allowed():
"destroy": True,
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["administrator", "editor", "reader"],
"set_role_to": ["administrator", "member"],
}
@@ -136,7 +133,6 @@ def test_models_template_access_get_abilities_for_owner_of_self_last():
"destroy": False,
"retrieve": True,
"update": False,
"partial_update": False,
"set_role_to": [],
}
@@ -153,8 +149,7 @@ def test_models_template_access_get_abilities_for_owner_of_owner():
"destroy": True,
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["administrator", "editor", "reader"],
"set_role_to": ["administrator", "member"],
}
@@ -170,14 +165,13 @@ def test_models_template_access_get_abilities_for_owner_of_administrator():
"destroy": True,
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["owner", "editor", "reader"],
"set_role_to": ["owner", "member"],
}
def test_models_template_access_get_abilities_for_owner_of_editor():
"""Check abilities of editor access for the owner of a template."""
access = factories.UserTemplateAccessFactory(role="editor")
def test_models_template_access_get_abilities_for_owner_of_member():
"""Check abilities of member access for the owner of a template."""
access = factories.UserTemplateAccessFactory(role="member")
factories.UserTemplateAccessFactory(template=access.template) # another one
user = factories.UserTemplateAccessFactory(
template=access.template, role="owner"
@@ -187,25 +181,7 @@ def test_models_template_access_get_abilities_for_owner_of_editor():
"destroy": True,
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["owner", "administrator", "reader"],
}
def test_models_template_access_get_abilities_for_owner_of_reader():
"""Check abilities of reader access for the owner of a template."""
access = factories.UserTemplateAccessFactory(role="reader")
factories.UserTemplateAccessFactory(template=access.template) # another one
user = factories.UserTemplateAccessFactory(
template=access.template, role="owner"
).user
abilities = access.get_abilities(user)
assert abilities == {
"destroy": True,
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["owner", "administrator", "editor"],
"set_role_to": ["owner", "administrator"],
}
@@ -224,7 +200,6 @@ def test_models_template_access_get_abilities_for_administrator_of_owner():
"destroy": False,
"retrieve": True,
"update": False,
"partial_update": False,
"set_role_to": [],
}
@@ -241,14 +216,13 @@ def test_models_template_access_get_abilities_for_administrator_of_administrator
"destroy": True,
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["editor", "reader"],
"set_role_to": ["member"],
}
def test_models_template_access_get_abilities_for_administrator_of_editor():
"""Check abilities of editor access for the administrator of a template."""
access = factories.UserTemplateAccessFactory(role="editor")
def test_models_template_access_get_abilities_for_administrator_of_member():
"""Check abilities of member access for the administrator of a template."""
access = factories.UserTemplateAccessFactory(role="member")
factories.UserTemplateAccessFactory(template=access.template) # another one
user = factories.UserTemplateAccessFactory(
template=access.template, role="administrator"
@@ -258,73 +232,53 @@ def test_models_template_access_get_abilities_for_administrator_of_editor():
"destroy": True,
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["administrator", "reader"],
"set_role_to": ["administrator"],
}
def test_models_template_access_get_abilities_for_administrator_of_reader():
"""Check abilities of reader access for the administrator of a template."""
access = factories.UserTemplateAccessFactory(role="reader")
factories.UserTemplateAccessFactory(template=access.template) # another one
user = factories.UserTemplateAccessFactory(
template=access.template, role="administrator"
).user
abilities = access.get_abilities(user)
assert abilities == {
"destroy": True,
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["administrator", "editor"],
}
# - for member
# - For editor
def test_models_template_access_get_abilities_for_editor_of_owner():
"""Check abilities of owner access for the editor of a template."""
def test_models_template_access_get_abilities_for_member_of_owner():
"""Check abilities of owner access for the member of a template."""
access = factories.UserTemplateAccessFactory(role="owner")
factories.UserTemplateAccessFactory(template=access.template) # another one
user = factories.UserTemplateAccessFactory(
template=access.template, role="editor"
template=access.template, role="member"
).user
abilities = access.get_abilities(user)
assert abilities == {
"destroy": False,
"retrieve": True,
"update": False,
"partial_update": False,
"set_role_to": [],
}
def test_models_template_access_get_abilities_for_editor_of_administrator():
"""Check abilities of administrator access for the editor of a template."""
def test_models_template_access_get_abilities_for_member_of_administrator():
"""Check abilities of administrator access for the member of a template."""
access = factories.UserTemplateAccessFactory(role="administrator")
factories.UserTemplateAccessFactory(template=access.template) # another one
user = factories.UserTemplateAccessFactory(
template=access.template, role="editor"
template=access.template, role="member"
).user
abilities = access.get_abilities(user)
assert abilities == {
"destroy": False,
"retrieve": True,
"update": False,
"partial_update": False,
"set_role_to": [],
}
def test_models_template_access_get_abilities_for_editor_of_editor_user(
def test_models_template_access_get_abilities_for_member_of_member_user(
django_assert_num_queries
):
"""Check abilities of editor access for the editor of a template."""
access = factories.UserTemplateAccessFactory(role="editor")
"""Check abilities of member access for the member of a template."""
access = factories.UserTemplateAccessFactory(role="member")
factories.UserTemplateAccessFactory(template=access.template) # another one
user = factories.UserTemplateAccessFactory(
template=access.template, role="editor"
template=access.template, role="member"
).user
with django_assert_num_queries(1):
@@ -334,77 +288,17 @@ def test_models_template_access_get_abilities_for_editor_of_editor_user(
"destroy": False,
"retrieve": True,
"update": False,
"partial_update": False,
"set_role_to": [],
}
# - For reader
def test_models_template_access_get_abilities_for_reader_of_owner():
"""Check abilities of owner access for the reader of a template."""
access = factories.UserTemplateAccessFactory(role="owner")
factories.UserTemplateAccessFactory(template=access.template) # another one
user = factories.UserTemplateAccessFactory(
template=access.template, role="reader"
).user
abilities = access.get_abilities(user)
assert abilities == {
"destroy": False,
"retrieve": True,
"update": False,
"partial_update": False,
"set_role_to": [],
}
def test_models_template_access_get_abilities_for_reader_of_administrator():
"""Check abilities of administrator access for the reader of a template."""
access = factories.UserTemplateAccessFactory(role="administrator")
factories.UserTemplateAccessFactory(template=access.template) # another one
user = factories.UserTemplateAccessFactory(
template=access.template, role="reader"
).user
abilities = access.get_abilities(user)
assert abilities == {
"destroy": False,
"retrieve": True,
"update": False,
"partial_update": False,
"set_role_to": [],
}
def test_models_template_access_get_abilities_for_reader_of_reader_user(
django_assert_num_queries
):
"""Check abilities of reader access for the reader of a template."""
access = factories.UserTemplateAccessFactory(role="reader")
factories.UserTemplateAccessFactory(template=access.template) # another one
user = factories.UserTemplateAccessFactory(
template=access.template, role="reader"
).user
with django_assert_num_queries(1):
abilities = access.get_abilities(user)
assert abilities == {
"destroy": False,
"retrieve": True,
"update": False,
"partial_update": False,
"set_role_to": [],
}
def test_models_template_access_get_abilities_preset_role(django_assert_num_queries):
"""No query is done if the role is preset, e.g., with a query annotation."""
access = factories.UserTemplateAccessFactory(role="reader")
access = factories.UserTemplateAccessFactory(role="member")
user = factories.UserTemplateAccessFactory(
template=access.template, role="reader"
template=access.template, role="member"
).user
access.user_roles = ["reader"]
access.user_roles = ["member"]
with django_assert_num_queries(0):
abilities = access.get_abilities(user)
@@ -413,6 +307,5 @@ def test_models_template_access_get_abilities_preset_role(django_assert_num_quer
"destroy": False,
"retrieve": True,
"update": False,
"partial_update": False,
"set_role_to": [],
}
@@ -134,26 +134,9 @@ def test_models_templates_get_abilities_administrator():
}
def test_models_templates_get_abilities_editor_user(django_assert_num_queries):
"""Check abilities returned for the editor of a template."""
access = factories.UserTemplateAccessFactory(role="editor")
with django_assert_num_queries(1):
abilities = access.template.get_abilities(access.user)
assert abilities == {
"destroy": False,
"retrieve": True,
"update": True,
"manage_accesses": False,
"partial_update": True,
"generate_document": True,
}
def test_models_templates_get_abilities_reader_user(django_assert_num_queries):
"""Check abilities returned for the reader of a template."""
access = factories.UserTemplateAccessFactory(role="reader")
def test_models_templates_get_abilities_member_user(django_assert_num_queries):
"""Check abilities returned for the member of a template."""
access = factories.UserTemplateAccessFactory(role="member")
with django_assert_num_queries(1):
abilities = access.template.get_abilities(access.user)
@@ -170,8 +153,8 @@ def test_models_templates_get_abilities_reader_user(django_assert_num_queries):
def test_models_templates_get_abilities_preset_role(django_assert_num_queries):
"""No query is done if the role is preset e.g. with query annotation."""
access = factories.UserTemplateAccessFactory(role="reader")
access.template.user_roles = ["reader"]
access = factories.UserTemplateAccessFactory(role="member")
access.template.user_roles = ["member"]
with django_assert_num_queries(0):
abilities = access.template.get_abilities(access.user)
-6
View File
@@ -20,12 +20,6 @@ document_related_router.register(
viewsets.DocumentAccessViewSet,
basename="document_accesses",
)
document_related_router.register(
"invitations",
viewsets.InvitationViewset,
basename="invitations",
)
# - Routes nested under a template
template_related_router = DefaultRouter()
+15 -4
View File
@@ -1,10 +1,21 @@
<page size="A4">
<div class="header">
<img
src="https://upload.wikimedia.org/wikipedia/fr/7/72/Logo_du_Gouvernement_de_la_R%C3%A9publique_fran%C3%A7aise_%282020%29.svg"
/>
<image src="https://upload.wikimedia.org/wikipedia/fr/7/72/Logo_du_Gouvernement_de_la_R%C3%A9publique_fran%C3%A7aise_%282020%29.svg"/>
<h2 class="header-title">Direction<br/>Interministérielle<br/>du numérique</h2>
</div>
<div class="second-row">
<div class="who-ref">
<div class="who">La directrice</div>
<p class="ref">Réf: 1200001</p>
</div>
<div class="date">Paris, le 28/09/2023</div>
</div>
<div class="third-row">
<h4 class="title">Note</h4>
<h5 class="subtitle">à Monsieur le Premier Ministre</h5>
</div>
<div class="content">
<div class="object">Objet: Generated PDF</div>
<div class="body">{{ body }}</div>
</div>
</page>
</page>
+42 -5
View File
@@ -2,17 +2,54 @@ body {
background: white;
font-family: arial
}
img {
width: 5cm;
margin-left: -0.4cm;
}
.header {
display: flex;
justify-content: space-between;
}
.header img {
width: 5cm;
margin-left: -0.4cm;
.header-title {
text-align: right;
margin-top: 3rem;
font-size: 1.2rem;
}
.second-row {
display: flex;
justify-content: space-between;
margin-top: 1.2cm;
}
.ref {
margin-top: 0;
}
.who {
font-weight: medium;
}
.date, .ref {
font-size: 12px;
}
.title, .subtitle {
margin: 0;
}
.subtitle {
font-weight: normal;
}
.object {
font-weight: bold;
margin-bottom: 1.2cm;
margin-top: 3rem
}
.body{
margin-top: 1.5rem
}
img {
max-width: 100%;
h1 {
font-size: 18px;
}
h2 {
font-size: 14px;
}
p {
text-align: justify;
ligne-height: 0.8;
}
-5
View File
@@ -1,5 +0,0 @@
"""Parameters that define how the demo site will be built."""
NB_OBJECTS = {
"users": 100,
}
@@ -1,6 +1,7 @@
# ruff: noqa: S311, S106
"""create_demo management command"""
import json
import logging
import random
import time
@@ -14,8 +15,6 @@ from faker import Faker
from core import models
from demo import defaults
fake = Faker()
logger = logging.getLogger("impress.commands.demo.create_demo")
@@ -110,35 +109,15 @@ def create_demo(stdout):
queue = BulkQueue(stdout)
with Timeit(stdout, "Creating users"):
for i in range(defaults.NB_OBJECTS["users"]):
queue.push(
models.User(
admin_email=f"user{i:d}@example.com",
email=f"user{i:d}@example.com",
password="!",
is_superuser=False,
is_active=True,
is_staff=False,
language=random.choice(settings.LANGUAGES)[0],
)
)
queue.flush()
with Timeit(stdout, "Creating Template"):
with open(
file="demo/data/template/code.txt", mode="r", encoding="utf-8"
) as text_file:
with open("demo/data/template/code.txt", "r") as text_file:
code_data = text_file.read()
with open(
file="demo/data/template/css.txt", mode="r", encoding="utf-8"
) as text_file:
with open("demo/data/template/css.txt", "r") as text_file:
css_data = text_file.read()
queue.push(
models.Template(
id="baca9e2a-59fb-42ef-b5c6-6f6b05637111",
title="Demo Template",
description="This is the demo template",
code=code_data,
@@ -1,5 +1,7 @@
"""Test the `create_demo` management command"""
from unittest import mock
from django.core.management import call_command
from django.test import override_settings
-1
View File
@@ -281,7 +281,6 @@ class Base(Configuration):
EMAIL_FROM = values.Value("from@example.com")
AUTH_USER_MODEL = "core.User"
INVITATION_VALIDITY_DURATION = 604800 # 7 days, in seconds
# CORS
CORS_ALLOW_CREDENTIALS = True
+2 -3
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "impress"
version = "1.0.0"
version = "0.1.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -42,7 +42,6 @@ dependencies = [
"dockerflow==2022.8.0",
"easy_thumbnails==2.8.5",
"factory_boy==3.3.0",
"freezegun==1.5.0",
"gunicorn==22.0.0",
"jsonschema==4.20.0",
"markdown==3.5.1",
@@ -50,7 +49,7 @@ dependencies = [
"psycopg[binary]==3.1.14",
"PyJWT==2.8.0",
"python-frontmatter==1.0.1",
"requests==2.32.2",
"requests==2.31.0",
"sentry-sdk==1.38.0",
"url-normalize==1.4.3",
"WeasyPrint>=60.2",
+1 -10
View File
@@ -39,7 +39,6 @@ COPY ./src/frontend/packages/eslint-config-impress/package.json ./packages/eslin
RUN yarn install --frozen-lockfile
COPY .dockerignore ./.dockerignore
COPY ./src/frontend/ .
### ---- Front-end builder image ----
@@ -47,14 +46,6 @@ FROM frontend-deps as impress
WORKDIR /home/frontend/apps/impress
FROM frontend-deps as impress-dev
WORKDIR /home/frontend/apps/impress
EXPOSE 3000
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
@@ -79,4 +70,4 @@ COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
CMD ["nginx", "-g", "daemon off;"]
CMD ["nginx", "-g", "daemon off;"]
-1
View File
@@ -2,5 +2,4 @@
test-results/
report/
blob-report/
playwright/.auth/
playwright/.cache/
@@ -1,10 +1,10 @@
import { expect, test } from '@playwright/test';
test.beforeEach(async ({ page }) => {
import { keyCloakSignIn } from './common';
test.beforeEach(async ({ page, browserName }) => {
await page.goto('/');
await expect(
page.locator('header').first().locator('h2').getByText('Docs'),
).toBeVisible();
await keyCloakSignIn(page, browserName);
await page.goto('unknown-page404');
});
@@ -1,19 +0,0 @@
import { test as setup } from '@playwright/test';
import { keyCloakSignIn } from './common';
setup('authenticate-chromium', async ({ page }) => {
await page.goto('/');
await keyCloakSignIn(page, 'chromium');
await page
.context()
.storageState({ path: `playwright/.auth/user-chromium.json` });
});
setup('authenticate-webkit', async ({ page }) => {
await page.goto('/');
await keyCloakSignIn(page, 'webkit');
await page
.context()
.storageState({ path: `playwright/.auth/user-webkit.json` });
});
@@ -5,17 +5,14 @@ export const keyCloakSignIn = async (page: Page, browserName: string) => {
timeout: 5000,
});
const login = `user-e2e-${browserName}`;
const password = `password-e2e-${browserName}`;
if (title?.includes('Sign in to your account')) {
await page
.getByRole('textbox', { name: 'username' })
.fill(`user-e2e-${browserName}`);
if (await page.getByLabel('Restart login').isVisible()) {
await page.getByRole('textbox', { name: 'password' }).fill(password);
await page.click('input[type="submit"]', { force: true });
} 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);
await page
.getByRole('textbox', { name: 'password' })
.fill(`password-e2e-${browserName}`);
await page.click('input[type="submit"]', { force: true });
}
@@ -26,34 +23,26 @@ export const randomName = (name: string, browserName: string, length: number) =>
return `${browserName}-${Math.floor(Math.random() * 10000)}-${index}-${name}`;
});
export const createDoc = async (
export const createPad = async (
page: Page,
docName: string,
padName: string,
browserName: string,
length: number,
isPublic: boolean = false,
) => {
const panel = page.getByLabel('Documents panel').first();
const buttonCreate = page.getByRole('button', {
name: 'Create the document',
});
const panel = page.getByLabel('Pads panel').first();
const buttonCreate = page.getByRole('button', { name: 'Create the pad' });
const randomDocs = randomName(docName, browserName, length);
for (let i = 0; i < randomDocs.length; i++) {
await panel.getByRole('button', { name: 'Add a document' }).click();
await page.getByText('Document name').fill(randomDocs[i]);
if (isPublic) {
await page.getByText('Is it public ?').click();
}
const randomPads = randomName(padName, browserName, length);
for (let i = 0; i < randomPads.length; i++) {
await panel.getByRole('button', { name: 'Add a pad' }).click();
await page.getByText('Pad name').fill(randomPads[i]);
await expect(buttonCreate).toBeEnabled();
await buttonCreate.click();
await expect(panel.locator('li').getByText(randomDocs[i])).toBeVisible();
await expect(panel.locator('li').getByText(randomPads[i])).toBeVisible();
}
return randomDocs;
return randomPads;
};
export const createTemplate = async (
@@ -89,18 +78,15 @@ export const addNewMember = async (
page: Page,
index: number,
role: 'Admin' | 'Owner' | 'Member',
fillText: string = 'user',
fillText: string = 'test',
) => {
const responsePromiseSearchUser = page.waitForResponse(
(response) =>
response.url().includes(`/users/?q=${fillText}`) &&
response.status() === 200,
);
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Add members' }).click();
const inputSearch = page.getByLabel(/Find a member to add to the document/);
await page.getByLabel('Add members to the team').click();
const inputSearch = page.getByLabel(/Find a member to add to the team/);
// Select a new user
await inputSearch.fill(fillText);
@@ -108,20 +94,23 @@ export const addNewMember = async (
// Intercept response
const responseSearchUser = await responsePromiseSearchUser;
const users = (await responseSearchUser.json()).results as {
email: string;
name: string;
}[];
// Choose user
await page.getByRole('option', { name: users[index].email }).click();
await page.getByRole('option', { name: users[index].name }).click();
// Choose a role
await page.getByRole('radio', { name: role }).click();
await page.getByRole('button', { name: 'Validate' }).click();
const table = page.getByLabel('List members card').getByRole('table');
await expect(table.getByText(users[index].name)).toBeVisible();
await expect(
page.getByText(`User ${users[index].email} added to the document.`),
page.getByText(`Member ${users[index].name} added to the team`),
).toBeVisible();
return users[index].email;
return users[index].name;
};
@@ -1,200 +0,0 @@
import { expect, test } from '@playwright/test';
import { createDoc, randomName } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Document add users', () => {
test('it selects 2 users and 1 invitation', async ({ page, browserName }) => {
const responsePromise = page.waitForResponse(
(response) =>
response.url().includes('/users/?q=user') && response.status() === 200,
);
await createDoc(page, 'select-multi-users', browserName, 1);
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Add members' }).click();
const inputSearch = page.getByLabel(/Find a member to add to the document/);
await expect(inputSearch).toBeVisible();
// Select user 1
await inputSearch.fill('user');
const response = await responsePromise;
const users = (await response.json()).results as {
email: string;
}[];
await page.getByRole('option', { name: users[0].email }).click();
// Select user 2
await inputSearch.fill('user');
await page.getByRole('option', { name: users[1].email }).click();
// Select email
const email = randomName('test@test.fr', browserName, 1)[0];
await inputSearch.fill(email);
await page.getByRole('option', { name: email }).click();
// Check user 1 tag
await expect(
page.getByText(`${users[0].email}`, { exact: true }),
).toBeVisible();
await expect(page.getByLabel(`Remove ${users[0].email}`)).toBeVisible();
// Check user 2 tag
await expect(
page.getByText(`${users[1].email}`, { exact: true }),
).toBeVisible();
await expect(page.getByLabel(`Remove ${users[1].email}`)).toBeVisible();
// Check invitation tag
await expect(page.getByText(email, { exact: true })).toBeVisible();
await expect(page.getByLabel(`Remove ${email}`)).toBeVisible();
// Check roles are displayed
await expect(page.getByText(/Choose a role/)).toBeVisible();
await expect(page.getByRole('radio', { name: 'Reader' })).toBeChecked();
await expect(page.getByRole('radio', { name: 'Owner' })).toBeVisible();
await expect(
page.getByRole('radio', { name: 'Administrator' }),
).toBeVisible();
});
test('it sends a new invitation and adds a new user', async ({
page,
browserName,
}) => {
const responsePromiseSearchUser = page.waitForResponse(
(response) =>
response.url().includes('/users/?q=user') && response.status() === 200,
);
await createDoc(page, 'user-invitation', browserName, 1);
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Add members' }).click();
const inputSearch = page.getByLabel(/Find a member to add to the document/);
const email = randomName('test@test.fr', browserName, 1)[0];
await inputSearch.fill(email);
await page.getByRole('option', { name: email }).click();
// Select a new user
await inputSearch.fill('user');
const responseSearchUser = await responsePromiseSearchUser;
const [user] = (await responseSearchUser.json()).results as {
email: string;
}[];
await page.getByRole('option', { name: user.email }).click();
// Choose a role
await page.getByRole('radio', { name: 'Administrator' }).click();
const responsePromiseCreateInvitation = page.waitForResponse(
(response) =>
response.url().includes('/invitations/') && response.status() === 201,
);
const responsePromiseAddUser = page.waitForResponse(
(response) =>
response.url().includes('/accesses/') && response.status() === 201,
);
await page.getByRole('button', { name: 'Validate' }).click();
// Check invitation sent
await expect(page.getByText(`Invitation sent to ${email}`)).toBeVisible();
const responseCreateInvitation = await responsePromiseCreateInvitation;
expect(responseCreateInvitation.ok()).toBeTruthy();
// Check user added
await expect(
page.getByText(`User ${user.email} added to the document.`),
).toBeVisible();
const responseAddUser = await responsePromiseAddUser;
expect(responseAddUser.ok()).toBeTruthy();
});
test('it try to add twice the same user', async ({ page, browserName }) => {
const responsePromiseSearchUser = page.waitForResponse(
(response) =>
response.url().includes('/users/?q=user') && response.status() === 200,
);
await createDoc(page, 'user-twice', browserName, 1);
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Add members' }).click();
const inputSearch = page.getByLabel(/Find a member to add to the document/);
await inputSearch.fill('user');
const responseSearchUser = await responsePromiseSearchUser;
const [user] = (await responseSearchUser.json()).results as {
email: string;
}[];
await page.getByRole('option', { name: user.email }).click();
// Choose a role
await page.getByRole('radio', { name: 'Owner' }).click();
const responsePromiseAddMember = page.waitForResponse(
(response) =>
response.url().includes('/accesses/') && response.status() === 201,
);
await page.getByRole('button', { name: 'Validate' }).click();
await expect(
page.getByText(`User ${user.email} added to the document.`),
).toBeVisible();
const responseAddMember = await responsePromiseAddMember;
expect(responseAddMember.ok()).toBeTruthy();
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Add members' }).click();
await inputSearch.fill('user');
await expect(page.getByRole('option', { name: user.email })).toBeHidden();
});
test('it try to add twice the same invitation', async ({
page,
browserName,
}) => {
await createDoc(page, 'invitation-twice', browserName, 1);
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Add members' }).click();
const inputSearch = page.getByLabel(/Find a member to add to the document/);
const email = randomName('test@test.fr', browserName, 1)[0];
await inputSearch.fill(email);
await page.getByRole('option', { name: email }).click();
// Choose a role
await page.getByRole('radio', { name: 'Owner' }).click();
const responsePromiseCreateInvitation = page.waitForResponse(
(response) =>
response.url().includes('/invitations/') && response.status() === 201,
);
await page.getByRole('button', { name: 'Validate' }).click();
// Check invitation sent
await expect(page.getByText(`Invitation sent to ${email}`)).toBeVisible();
const responseCreateInvitation = await responsePromiseCreateInvitation;
expect(responseCreateInvitation.ok()).toBeTruthy();
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Add members' }).click();
await inputSearch.fill(email);
await expect(page.getByRole('option', { name: email })).toBeHidden();
});
});
@@ -1,130 +0,0 @@
import { expect, test } from '@playwright/test';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
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.getByLabel('Create new document card').first();
await expect(card.getByLabel('Document name')).toBeVisible();
await expect(card.getByLabel('icon group')).toBeVisible();
await expect(
card.getByRole('heading', {
name: 'Name the document',
level: 3,
}),
).toBeVisible();
await expect(card.getByText('Is it public ?')).toBeVisible();
await expect(
card.getByRole('button', {
name: 'Create the document',
}),
).toBeVisible();
await expect(
card.getByRole('button', {
name: 'Cancel',
}),
).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.getByLabel('Create new document card').first();
await card
.getByRole('button', {
name: 'Cancel',
})
.click();
await expect(buttonCreateHomepage).toBeVisible();
});
test('checks the routing on new doc created', async ({
page,
browserName,
}) => {
const panel = page.getByLabel('Documents panel').first();
await panel.getByRole('button', { name: 'Add a document' }).click();
const docName = `My routing doc ${browserName}-${Math.floor(Math.random() * 1000)}`;
await page.getByText('Document name').fill(docName);
await page.getByRole('button', { name: 'Create the document' }).click();
const elDoc = page.locator('h2').getByText(docName);
await expect(elDoc).toBeVisible();
await panel.getByRole('button', { name: 'Add a document' }).click();
await expect(elDoc).toBeHidden();
await panel.locator('li').getByText(docName).click();
await expect(elDoc).toBeVisible();
});
test('checks alias docs url with homepage', async ({ page }) => {
await expect(page).toHaveURL('/');
const buttonCreateHomepage = page.getByRole('button', {
name: 'Create a new document',
});
await expect(buttonCreateHomepage).toBeVisible();
await page.goto('/docs');
await expect(buttonCreateHomepage).toBeVisible();
await expect(page).toHaveURL(/\/docs$/);
});
test('checks 404 on docs/[id] page', async ({ page }) => {
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(300);
await page.goto('/docs/some-unknown-doc');
await expect(
page.getByText(
'It seems that the page you are looking for does not exist or cannot be displayed correctly.',
),
).toBeVisible({
timeout: 15000,
});
});
test('checks that the doc is public', async ({ page, browserName }) => {
const responsePromiseDoc = page.waitForResponse(
(response) =>
response.url().includes('/documents/') && response.status() === 201,
);
const panel = page.getByLabel('Documents panel').first();
await panel.getByRole('button', { name: 'Add a document' }).click();
const docName = `My routing doc ${browserName}-${Math.floor(Math.random() * 1000)}`;
await page.getByText('Document name').fill(docName);
await page.getByText('Is it public ?').click();
await page.getByRole('button', { name: 'Create the document' }).click();
const responseDoc = await responsePromiseDoc;
const is_public = (await responseDoc.json()).is_public;
expect(is_public).toBeTruthy();
});
});
@@ -1,217 +0,0 @@
import { expect, test } from '@playwright/test';
import { createDoc } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Doc Editor', () => {
test('checks the Doc Editor interact correctly', async ({
page,
browserName,
}) => {
const randomDoc = await createDoc(page, 'doc-editor', browserName, 1);
await expect(page.locator('h2').getByText(randomDoc[0])).toBeVisible();
await page.locator('.ProseMirror.bn-editor').click();
await page.locator('.ProseMirror.bn-editor').fill('Hello World');
await expect(page.getByText('Hello World')).toBeVisible();
});
test('checks the Doc is connected to the webrtc server', async ({
page,
browserName,
}) => {
const webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
return webSocket.url().includes('ws://localhost:4444/');
});
const randomDoc = await createDoc(page, 'doc-editor', browserName, 1);
await expect(page.locator('h2').getByText(randomDoc[0])).toBeVisible();
const webSocket = await webSocketPromise;
expect(webSocket.url()).toContain('ws://localhost:4444/');
const framesentPromise = webSocket.waitForEvent('framesent');
await page.locator('.ProseMirror.bn-editor').click();
await page.locator('.ProseMirror.bn-editor').fill('Hello World');
const framesent = await framesentPromise;
const payload = JSON.parse(framesent.payload as string) as {
type: string;
};
const typeCases = ['publish', 'subscribe', 'unsubscribe', 'ping'];
expect(typeCases.includes(payload.type)).toBeTruthy();
});
test('markdown button converts from markdown to the editor syntax json', async ({
page,
browserName,
}) => {
const randomDoc = await createDoc(page, 'doc-markdown', browserName, 1);
await expect(page.locator('h2').getByText(randomDoc[0])).toBeVisible();
await page.locator('.ProseMirror.bn-editor').click();
await page
.locator('.ProseMirror.bn-editor')
.fill('[test markdown](http://test-markdown.html)');
await expect(page.getByText('[test markdown]')).toBeVisible();
await page.getByText('[test markdown]').dblclick();
await page.locator('button[data-test="convertMarkdown"]').click();
await expect(page.getByText('[test markdown]')).toBeHidden();
await expect(
page.getByRole('link', {
name: 'test markdown',
}),
).toHaveAttribute('href', 'http://test-markdown.html');
});
test('it renders correctly when we switch from one doc to another', async ({
page,
browserName,
}) => {
const [firstDoc, secondDoc] = await createDoc(
page,
'doc-multiple',
browserName,
2,
);
const panel = page.getByLabel('Documents panel').first();
// Check the first doc
await panel.getByText(firstDoc).click();
await expect(page.locator('h2').getByText(firstDoc)).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
await panel.getByText(secondDoc).click();
await expect(page.locator('h2').getByText(secondDoc)).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 panel.getByText(firstDoc).click();
await expect(page.locator('h2').getByText(firstDoc)).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,
browserName,
}) => {
const [doc] = await createDoc(page, 'doc-save-page', browserName, 1);
const panel = page.getByLabel('Documents panel').first();
// Check the first doc
await panel.getByText(doc).click();
await expect(page.locator('h2').getByText(doc)).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();
await panel
.getByRole('button', {
name: 'Add a document',
})
.click();
await expect(
page.getByText(`Your document "${doc}" has been saved.`),
).toBeVisible();
const card = page.getByLabel('Create new document card').first();
await expect(
card.getByRole('heading', {
name: 'Name the document',
level: 3,
}),
).toBeVisible();
await page.goto('/');
await panel.getByText(doc).click();
await expect(page.getByText('Hello World Doc persisted 1')).toBeVisible();
});
test('it saves the doc when we quit pages', async ({ page, browserName }) => {
// eslint-disable-next-line playwright/no-skipped-test
test.skip(browserName === 'webkit', 'This test is very flaky with webkit');
const [doc] = await createDoc(page, 'doc-save-quit', browserName, 1);
const panel = page.getByLabel('Documents panel').first();
// Check the first doc
await panel.getByText(doc).click();
await expect(page.locator('h2').getByText(doc)).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('/');
await panel.getByText(doc).click();
await expect(page.getByText('Hello World Doc persisted 2')).toBeVisible();
});
test('it cannot edit if viewer', async ({ page, browserName }) => {
await page.route('**/documents/**/', async (route) => {
const request = route.request();
if (
request.method().includes('GET') &&
!request.url().includes('page=')
) {
await route.fulfill({
json: {
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
content: '',
title: 'Mocked document',
accesses: [],
abilities: {
destroy: false, // Means not owner
versions_destroy: false,
versions_list: true,
versions_retrieve: true,
manage_accesses: false, // Means not admin
update: false,
partial_update: false, // Means not editor
retrieve: true,
},
is_public: false,
},
});
} else {
await route.continue();
}
});
await createDoc(page, 'doc-right-edit', browserName, 1);
await expect(
page.getByText(
"Read only, you don't have the right to update this document.",
),
).toBeVisible();
});
});
@@ -1,192 +0,0 @@
import { expect, test } from '@playwright/test';
import { addNewMember, createDoc } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Members Delete', () => {
test('it cannot delete himself when it is the last owner', async ({
page,
browserName,
}) => {
await createDoc(page, 'member-delete-1', browserName, 1);
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Manage members' }).click();
const table = page.getByLabel('List members card').getByRole('table');
const cells = table.getByRole('row').nth(1).getByRole('cell');
await expect(cells.nth(0)).toHaveText(
new RegExp(`user@${browserName}.e2e`, 'i'),
);
await cells.nth(2).getByLabel('Member options').click();
await page.getByLabel('Open the modal to delete this member').click();
await expect(
page.getByText(
'You are the last owner, you cannot be removed from your document.',
),
).toBeVisible();
await expect(page.getByRole('button', { name: 'Validate' })).toBeDisabled();
});
test('it deletes himself when it is not the last owner', async ({
page,
browserName,
}) => {
await createDoc(page, 'member-delete-2', browserName, 1);
await addNewMember(page, 0, 'Owner');
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Manage members' }).click();
const table = page.getByLabel('List members card').getByRole('table');
// find row where regexp match the name
const cells = table
.getByRole('row')
.filter({ hasText: new RegExp(`user@${browserName}.e2e`, 'i') })
.getByRole('cell');
await cells.nth(2).getByLabel('Member options').click();
await page.getByLabel('Open the modal to delete this member').click();
await page.getByRole('button', { name: 'Validate' }).click();
await expect(
page.getByText(`The member has been removed from the document`),
).toBeVisible();
await expect(
page.getByRole('button', { name: `Create a new document` }),
).toBeVisible();
});
test('it cannot delete owner member', async ({ page, browserName }) => {
await createDoc(page, 'member-delete-3', browserName, 1);
const username = await addNewMember(page, 0, 'Owner');
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Manage members' }).click();
const table = page.getByLabel('List members card').getByRole('table');
// find row where regexp match the name
const cells = table
.getByRole('row')
.filter({ hasText: username })
.getByRole('cell');
await cells.getByLabel('Member options').click();
await page.getByLabel('Open the modal to delete this member').click();
await expect(
page.getByText(`You cannot remove other owner.`),
).toBeVisible();
await expect(page.getByRole('button', { name: 'Validate' })).toBeDisabled();
});
test('it deletes admin member', async ({ page, browserName }) => {
await createDoc(page, 'member-delete-4', browserName, 1);
const username = await addNewMember(page, 0, 'Admin');
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Manage members' }).click();
const table = page.getByLabel('List members card').getByRole('table');
// find row where regexp match the name
const cells = table
.getByRole('row')
.filter({ hasText: username })
.getByRole('cell');
await cells.getByLabel('Member options').click();
await page.getByLabel('Open the modal to delete this member').click();
await page.getByRole('button', { name: 'Validate' }).click();
await expect(
page.getByText(`The member has been removed from the document`),
).toBeVisible();
await expect(table.getByText(username)).toBeHidden();
});
test('it cannot delete owner member when admin', async ({
page,
browserName,
}) => {
await createDoc(page, 'member-delete-5', browserName, 1);
const username = await addNewMember(page, 0, 'Owner');
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Manage members' }).click();
const table = page.getByLabel('List members card').getByRole('table');
// find row where regexp match the name
const myCells = table
.getByRole('row')
.filter({ hasText: new RegExp(`user@${browserName}.e2e`, 'i') })
.getByRole('cell');
await myCells.getByLabel('Member options').click();
// Change role to Admin
await page.getByText('Update role').click();
const radioGroup = page.getByLabel('Radio buttons to update the roles');
await radioGroup.getByRole('radio', { name: 'Administrator' }).click();
await page.getByRole('button', { name: 'Validate' }).click();
const cells = table
.getByRole('row')
.filter({ hasText: username })
.getByRole('cell');
await expect(cells.getByLabel('Member options')).toBeHidden();
});
test('it deletes admin member when admin', async ({ page, browserName }) => {
await createDoc(page, 'member-delete-6', browserName, 1);
// To not be the only owner
await addNewMember(page, 0, 'Owner');
const username = await addNewMember(page, 1, 'Admin');
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Manage members' }).click();
const table = page.getByLabel('List members card').getByRole('table');
// find row where regexp match the name
const myCells = table
.getByRole('row')
.filter({ hasText: new RegExp(`user@${browserName}.e2e`, 'i') })
.getByRole('cell');
await myCells.getByLabel('Member options').click();
// Change role to Admin
await page.getByText('Update role').click();
const radioGroup = page.getByLabel('Radio buttons to update the roles');
await radioGroup.getByRole('radio', { name: 'Administrator' }).click();
await page.getByRole('button', { name: 'Validate' }).click();
await expect(page.getByText(`The role has been updated`)).toBeVisible();
await expect(page.getByText(`The role has been updated`)).toBeHidden({
timeout: 5000,
});
const cells = table
.getByRole('row')
.filter({ hasText: new RegExp(username, 'i') })
.getByRole('cell');
await cells.nth(2).getByLabel('Member options').click();
await page.getByLabel('Open the modal to delete this member').click();
await page.getByRole('button', { name: 'Validate' }).click();
await expect(
page.getByText(`The member has been removed from the document`),
).toBeVisible();
await expect(table.getByText(username)).toBeHidden();
});
});
@@ -1,113 +0,0 @@
import { expect, test } from '@playwright/test';
import { createDoc } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Document grid members', () => {
test('it display the grid', async ({ page, browserName }) => {
await createDoc(page, 'grid-display', browserName, 1);
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Manage members' }).click();
await expect(page.getByText('Members of the document')).toBeVisible();
const table = page.getByLabel('List members card').getByRole('table');
const thead = table.locator('thead');
await expect(thead.getByText(/Emails/i)).toBeVisible();
await expect(thead.getByText(/Roles/i)).toBeVisible();
const cells = table.getByRole('row').nth(1).getByRole('cell');
await expect(cells.nth(0)).toHaveText(`user@${browserName}.e2e`);
await expect(cells.nth(1)).toHaveText(/Owner/i);
await expect(cells.nth(2)).toHaveAccessibleName(
'Open the member options modal',
);
});
test('it display the grid with many members', async ({
page,
browserName,
}) => {
await page.route('**/documents/*/', async (route) => {
const request = route.request();
if (
request.method().includes('GET') &&
!request.url().includes('page=')
) {
await route.fulfill({
json: {
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
content: '',
title: 'Mocked document',
accesses: [],
abilities: {
destroy: true,
manage_accesses: true,
partial_update: true,
},
is_public: false,
},
});
} else {
await route.continue();
}
});
await page.route(
'**/documents/b0df4343-c8bd-4c20-9ff6-fbf94fc94egg/accesses/?page=*',
async (route) => {
const request = route.request();
const url = new URL(request.url());
const pageId = url.searchParams.get('page');
const accesses = {
count: 100,
next: null,
previous: null,
results: Array.from({ length: 20 }, (_, i) => ({
id: `2ff1ec07-86c1-4534-a643-f41824a6c53a-${pageId}-${i}`,
user: {
id: `fc092149-cafa-4ffa-a29d-e4b18af751-${pageId}-${i}`,
email: `impress@impress.world-page-${pageId}-${i}`,
},
team: '',
role: 'owner',
abilities: {
destroy: false,
partial_update: true,
},
})),
};
if (request.method().includes('GET')) {
await route.fulfill({
json: accesses,
});
} else {
await route.continue();
}
},
);
await createDoc(page, 'grid-no-member', browserName, 1);
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Manage members' }).click();
await expect(
page.getByText('impress@impress.world-page-1-19'),
).toBeVisible();
await page.getByLabel('Go to page 4').click();
await expect(
page.getByText('impress@impress.world-page-1-19'),
).toBeHidden();
await expect(
page.getByText('impress@impress.world-page-4-19'),
).toBeVisible();
});
});
@@ -1,159 +0,0 @@
import { expect, test } from '@playwright/test';
import { waitForElementCount } from '../helpers';
import { createDoc } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Documents Panel', () => {
test('checks all the elements are visible', async ({ page }) => {
const panel = page.getByLabel('Documents panel').first();
await expect(panel.getByText('Documents')).toBeVisible();
await expect(
panel.getByRole('button', {
name: 'Sort the documents',
}),
).toBeVisible();
await expect(
panel.getByRole('button', {
name: 'Add a document',
}),
).toBeVisible();
});
test('checks the sort button', async ({ page }) => {
const responsePromiseSortDesc = page.waitForResponse(
(response) =>
response.url().includes('/documents/?page=1&ordering=-created_at') &&
response.status() === 200,
);
const responsePromiseSortAsc = page.waitForResponse(
(response) =>
response.url().includes('/documents/?page=1&ordering=created_at') &&
response.status() === 200,
);
const panel = page.getByLabel('Documents panel').first();
await panel
.getByRole('button', {
name: 'Sort the documents by creation date ascendent',
})
.click();
const responseSortAsc = await responsePromiseSortAsc;
expect(responseSortAsc.ok()).toBeTruthy();
await panel
.getByRole('button', {
name: 'Sort the documents by creation date descendent',
})
.click();
const responseSortDesc = await responsePromiseSortDesc;
expect(responseSortDesc.ok()).toBeTruthy();
});
test('checks the infinite scroll', async ({ page }) => {
await page.route(
/.*\/documents\/\?page=.*&ordering=-created_at/,
async (route) => {
const request = route.request();
const url = new URL(request.url());
const pageId = url.searchParams.get('page');
const documents = {
count: 40,
next: 'http://localhost:3000/documents/?page=2&ordering=-created_at',
previous: null,
results: Array.from({ length: 20 }, (_, i) => ({
id: `2ff-${pageId}-${i}`,
title: `My document-${pageId}-${i}`,
accesses: [
{
id: 'b644e9b1-0517-4cfb-90ca-f7d6f2f6bb9a',
role: `owner`,
team: '',
user: {
id: 'a4743608-c9d8-4692-bef4-f795e25a3a88',
email: 'user@chromium.e2e',
},
},
],
content: '',
is_public: true,
abilities: {},
})),
};
if (request.method().includes('GET')) {
await route.fulfill({
json: documents,
});
} else {
await route.continue();
}
},
);
await page.route(`**/documents/2ff-1-16/`, async (route) => {
const request = route.request();
if (request.method().includes('GET')) {
await route.fulfill({
json: {
id: '2ff-1-16',
title: 'My document-1-16',
content: '',
abilities: {
partial_update: true,
},
accesses: [
{
id: 'b644e9b1-0517-4cfb-90ca-f7d6f2f6bb9a',
role: `owner`,
team: '',
user: {
id: 'a4743608-c9d8-4692-bef4-f795e25a3a88',
email: '',
},
},
],
},
});
} else {
await route.continue();
}
});
const panel = page.getByLabel('Documents panel').first();
await expect(panel.locator('li')).toHaveCount(20);
await panel.getByText(`My document-1-16`).click();
await waitForElementCount(panel.locator('li'), 21, 10000);
expect(await panel.locator('li').count()).toBeGreaterThan(20);
await expect(panel.getByText(`My document-1-16`)).toBeVisible();
await expect(panel.getByText(`My document-2-15`)).toBeVisible();
});
test('checks the hover and selected state', async ({ page, browserName }) => {
const panel = page.getByLabel('Documents panel').first();
await createDoc(page, 'doc-hover', browserName, 2);
const selectedDoc = panel.locator('li').nth(0);
await expect(selectedDoc).toHaveCSS(
'background-color',
'rgb(202, 202, 251)',
);
const hoverDoc = panel.locator('li').nth(1);
await hoverDoc.hover();
await expect(hoverDoc).toHaveCSS('background-color', 'rgb(227, 227, 253)');
});
});
@@ -1,337 +0,0 @@
import { expect, test } from '@playwright/test';
import cs from 'convert-stream';
import pdf from 'pdf-parse';
import { createDoc } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Doc Tools', () => {
test('it converts the doc to pdf with a template integrated', async ({
page,
browserName,
}) => {
const [randomDoc] = await createDoc(page, 'doc-editor', browserName, 1);
const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`${randomDoc}.pdf`);
});
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await page.locator('.ProseMirror.bn-editor').click();
await page.locator('.ProseMirror.bn-editor').fill('Hello World');
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
name: 'Generate PDF',
})
.click();
await page
.getByRole('button', {
name: 'Download',
})
.click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
const pdfText = (await pdf(pdfBuffer)).text;
expect(pdfText).toContain('Hello World'); // This is the doc text
});
test('it converts the blocknote json in correct html for the pdf', async ({
page,
browserName,
}) => {
const [randomDoc] = await createDoc(page, 'doc-editor', browserName, 1);
let body = '';
await page.route('**/templates/*/generate-document/', async (route) => {
const request = route.request();
body = request.postDataJSON().body;
await route.continue();
});
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await page.locator('.bn-block-outer').last().fill('Hello World');
await page.locator('.bn-block-outer').last().click();
await page.keyboard.press('Enter');
await page.keyboard.press('Enter');
await page.locator('.bn-block-outer').last().fill('Break');
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
name: 'Generate PDF',
})
.click();
await page
.getByRole('button', {
name: 'Download',
})
.click();
// Empty paragraph should be replaced by a <br/>
expect(body).toContain('<br/><br/>');
});
test('it updates the doc', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(
page,
'doc-update',
browserName,
1,
true,
);
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
name: 'Update document',
})
.click();
await expect(
page.locator('h2').getByText(`Update document "${randomDoc}"`),
).toBeVisible();
await expect(
page.getByRole('checkbox', { name: 'Is it public ?' }),
).toBeChecked();
await page.getByText('Document name').fill(`${randomDoc}-updated`);
await page.getByText('Is it public ?').click();
await page
.getByRole('button', {
name: 'Validate the modification',
})
.click();
await expect(
page.getByText('The document has been updated.'),
).toBeVisible();
const panel = page.getByLabel('Documents panel').first();
await expect(
panel.locator('li').getByText(`${randomDoc}-updated`),
).toBeVisible();
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
name: 'Update document',
})
.click();
await expect(
page.getByRole('checkbox', { name: 'Is it public ?' }),
).not.toBeChecked();
});
test('it deletes the doc', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-delete', browserName, 1);
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
name: 'Delete document',
})
.click();
await expect(
page.locator('h2').getByText(`Deleting the document "${randomDoc}"`),
).toBeVisible();
await page
.getByRole('button', {
name: 'Confirm deletion',
})
.click();
await expect(
page.getByText('The document has been deleted.'),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Create a new document' }),
).toBeVisible();
const panel = page.getByLabel('Documents panel').first();
await expect(panel.locator('li').getByText(randomDoc)).toBeHidden();
});
test('it checks the options available if administrator', async ({
page,
browserName,
}) => {
await page.route('**/documents/**/', async (route) => {
const request = route.request();
if (
request.method().includes('GET') &&
!request.url().includes('page=')
) {
await route.fulfill({
json: {
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
content: '',
title: 'Mocked document',
accesses: [],
abilities: {
destroy: false, // Means not owner
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
manage_accesses: true, // Means admin
update: true,
partial_update: true,
retrieve: true,
},
is_public: false,
},
});
} else {
await route.continue();
}
});
await createDoc(page, 'doc-tools-right-admin', browserName, 1);
await expect(page.locator('h2').getByText('Mocked document')).toBeVisible();
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('button', { name: 'Add members' }),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Generate PDF' }),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Update document' }),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Delete document' }),
).toBeHidden();
});
test('it checks the options available if editor', async ({
page,
browserName,
}) => {
await page.route('**/documents/**/', async (route) => {
const request = route.request();
if (
request.method().includes('GET') &&
!request.url().includes('page=')
) {
await route.fulfill({
json: {
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
content: '',
title: 'Mocked document',
accesses: [],
abilities: {
destroy: false, // Means not owner
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
manage_accesses: false, // Means not admin
update: true,
partial_update: true, // Means editor
retrieve: true,
},
is_public: false,
},
});
} else {
await route.continue();
}
});
await createDoc(page, 'doc-tools-right-editor', browserName, 1);
await expect(page.locator('h2').getByText('Mocked document')).toBeVisible();
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('button', { name: 'Add members' }),
).toBeHidden();
await expect(
page.getByRole('button', { name: 'Generate PDF' }),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Update document' }),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Delete document' }),
).toBeHidden();
});
test('it checks the options available if reader', async ({
page,
browserName,
}) => {
await page.route('**/documents/**/', async (route) => {
const request = route.request();
if (
request.method().includes('GET') &&
!request.url().includes('page=')
) {
await route.fulfill({
json: {
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
content: '',
title: 'Mocked document',
accesses: [],
abilities: {
destroy: false, // Means not owner
versions_destroy: false,
versions_list: true,
versions_retrieve: true,
manage_accesses: false, // Means not admin
update: false,
partial_update: false, // Means not editor
retrieve: true,
},
is_public: false,
},
});
} else {
await route.continue();
}
});
await createDoc(page, 'doc-tools-right-reader', browserName, 1);
await expect(page.locator('h2').getByText('Mocked document')).toBeVisible();
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('button', { name: 'Add members' }),
).toBeHidden();
await expect(
page.getByRole('button', { name: 'Generate PDF' }),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Update document' }),
).toBeHidden();
await expect(
page.getByRole('button', { name: 'Delete document' }),
).toBeHidden();
});
});
@@ -1,7 +1,10 @@
import { expect, test } from '@playwright/test';
test.beforeEach(async ({ page }) => {
import { keyCloakSignIn } from './common';
test.beforeEach(async ({ page, browserName }) => {
await page.goto('/');
await keyCloakSignIn(page, browserName);
});
test.describe('Footer', () => {
@@ -2,8 +2,9 @@ import { expect, test } from '@playwright/test';
import { keyCloakSignIn } from './common';
test.beforeEach(async ({ page }) => {
test.beforeEach(async ({ page, browserName }) => {
await page.goto('/');
await keyCloakSignIn(page, browserName);
});
test.describe('Header', () => {
@@ -16,12 +17,12 @@ test.describe('Header', () => {
header.getByAltText('Freedom Equality Fraternity Logo'),
).toBeVisible();
await expect(header.getByAltText('Docs Logo')).toBeVisible();
await expect(header.locator('h2').getByText('Docs')).toHaveCSS(
await expect(header.getByAltText('Impress Logo')).toBeVisible();
await expect(header.locator('h2').getByText('Impress')).toHaveCSS(
'color',
'rgb(0, 0, 145)',
);
await expect(header.locator('h2').getByText('Docs')).toHaveCSS(
await expect(header.locator('h2').getByText('Impress')).toHaveCSS(
'font-family',
/Marianne/i,
);
@@ -63,15 +64,8 @@ test.describe('Header', () => {
await expect(page.getByRole('link', { name: 'Grist' })).toBeVisible();
});
});
test.describe('Header: Log out', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('checks logout button', async ({ page, browserName }) => {
await page.goto('/');
await keyCloakSignIn(page, browserName);
test('checks logout button', async ({ page }) => {
await page
.getByRole('button', {
name: 'My account',
@@ -1,14 +1,17 @@
import { expect, test } from '@playwright/test';
test.beforeEach(async ({ page }) => {
import { keyCloakSignIn } from './common';
test.beforeEach(async ({ page, browserName }) => {
await page.goto('/');
await keyCloakSignIn(page, browserName);
});
test.describe('Language', () => {
test('checks the language picker', async ({ page }) => {
await expect(
page.getByRole('button', {
name: 'Create a new document',
name: 'Create a new pad',
}),
).toBeVisible();
@@ -19,7 +22,7 @@ test.describe('Language', () => {
await expect(
page.getByRole('button', {
name: 'Créer un nouveau document',
name: 'Créer un nouveau pad',
}),
).toBeVisible();
});
@@ -0,0 +1,130 @@
import { expect, test } from '@playwright/test';
import { keyCloakSignIn } from './common';
test.beforeEach(async ({ page, browserName }) => {
await page.goto('/');
await keyCloakSignIn(page, browserName);
});
test.describe('Pad Create', () => {
test('checks all the create pad elements are visible', async ({ page }) => {
const buttonCreateHomepage = page.getByRole('button', {
name: 'Create a new pad',
});
await buttonCreateHomepage.click();
await expect(buttonCreateHomepage).toBeHidden();
const card = page.getByLabel('Create new pad card').first();
await expect(card.getByLabel('Pad name')).toBeVisible();
await expect(card.getByLabel('icon group')).toBeVisible();
await expect(
card.getByRole('heading', {
name: 'Name the pad',
level: 3,
}),
).toBeVisible();
await expect(card.getByText('Is it public ?')).toBeVisible();
await expect(
card.getByRole('button', {
name: 'Create the pad',
}),
).toBeVisible();
await expect(
card.getByRole('button', {
name: 'Cancel',
}),
).toBeVisible();
});
test('checks the cancel button interaction', async ({ page }) => {
const buttonCreateHomepage = page.getByRole('button', {
name: 'Create a new pad',
});
await buttonCreateHomepage.click();
await expect(buttonCreateHomepage).toBeHidden();
const card = page.getByLabel('Create new pad card').first();
await card
.getByRole('button', {
name: 'Cancel',
})
.click();
await expect(buttonCreateHomepage).toBeVisible();
});
test('checks the routing on new pad created', async ({
page,
browserName,
}) => {
const panel = page.getByLabel('Pads panel').first();
await panel.getByRole('button', { name: 'Add a pad' }).click();
const padName = `My routing pad ${browserName}-${Math.floor(Math.random() * 1000)}`;
await page.getByText('Pad name').fill(padName);
await page.getByRole('button', { name: 'Create the pad' }).click();
const elPad = page.locator('h2').getByText(padName);
await expect(elPad).toBeVisible();
await panel.getByRole('button', { name: 'Add a pad' }).click();
await expect(elPad).toBeHidden();
await panel.locator('li').getByText(padName).click();
await expect(elPad).toBeVisible();
});
test('checks alias pads url with homepage', async ({ page }) => {
await expect(page).toHaveURL('/');
const buttonCreateHomepage = page.getByRole('button', {
name: 'Create a new pad',
});
await expect(buttonCreateHomepage).toBeVisible();
await page.goto('/pads');
await expect(buttonCreateHomepage).toBeVisible();
await expect(page).toHaveURL(/\/pads$/);
});
test('checks 404 on pads/[id] page', async ({ page }) => {
await page.goto('/pads/some-unknown-pad');
await expect(
page.getByText(
'It seems that the page you are looking for does not exist or cannot be displayed correctly.',
),
).toBeVisible({
timeout: 15000,
});
});
test('checks that the pad is public', async ({ page, browserName }) => {
const responsePromisePad = page.waitForResponse(
(response) =>
response.url().includes('/documents/') && response.status() === 201,
);
const panel = page.getByLabel('Pads panel').first();
await panel.getByRole('button', { name: 'Add a pad' }).click();
const padName = `My routing pad ${browserName}-${Math.floor(Math.random() * 1000)}`;
await page.getByText('Pad name').fill(padName);
await page.getByText('Is it public ?').click();
await page.getByRole('button', { name: 'Create the pad' }).click();
const responsePad = await responsePromisePad;
const is_public = (await responsePad.json()).is_public;
expect(is_public).toBeTruthy();
});
});
@@ -0,0 +1,141 @@
import { expect, test } from '@playwright/test';
import cs from 'convert-stream';
import pdf from 'pdf-parse';
import { createPad, keyCloakSignIn } from './common';
test.beforeEach(async ({ page, browserName }) => {
await page.goto('/');
await keyCloakSignIn(page, browserName);
});
test.describe('Pad Editor', () => {
test('checks the Pad Editor interact correctly', async ({
page,
browserName,
}) => {
const randomPad = await createPad(page, 'pad-editor', browserName, 1);
await expect(page.locator('h2').getByText(randomPad[0])).toBeVisible();
await page.locator('.ProseMirror.bn-editor').click();
await page.locator('.ProseMirror.bn-editor').fill('Hello World');
await expect(page.getByText('Hello World')).toBeVisible();
});
test('checks the Pad is connected to the webrtc server', async ({
page,
browserName,
}) => {
const webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
return webSocket.url().includes('ws://localhost:4444/');
});
const randomPad = await createPad(page, 'pad-editor', browserName, 1);
await expect(page.locator('h2').getByText(randomPad[0])).toBeVisible();
const webSocket = await webSocketPromise;
expect(webSocket.url()).toBe('ws://localhost:4444/');
const framesentPromise = webSocket.waitForEvent('framesent');
await page.locator('.ProseMirror.bn-editor').click();
await page.locator('.ProseMirror.bn-editor').fill('Hello World');
const framesent = await framesentPromise;
const payload = JSON.parse(framesent.payload as string) as {
type: string;
};
const typeCases = ['publish', 'subscribe', 'unsubscribe', 'ping'];
expect(typeCases.includes(payload.type)).toBeTruthy();
});
test('markdown button converts from markdown to the editor syntax json', async ({
page,
browserName,
}) => {
const randomPad = await createPad(page, 'pad-markdown', browserName, 1);
await expect(page.locator('h2').getByText(randomPad[0])).toBeVisible();
await page.locator('.ProseMirror.bn-editor').click();
await page
.locator('.ProseMirror.bn-editor')
.fill('[test markdown](http://test-markdown.html)');
await expect(page.getByText('[test markdown]')).toBeVisible();
await page.getByText('[test markdown]').dblclick();
await page.locator('button[data-test="convertMarkdown"]').click();
await expect(page.getByText('[test markdown]')).toBeHidden();
await expect(
page.getByRole('link', {
name: 'test markdown',
}),
).toHaveAttribute('href', 'http://test-markdown.html');
});
test('it converts the pad to pdf with a template integrated', async ({
page,
browserName,
}) => {
const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes('impress-document.pdf');
});
const randomPad = await createPad(page, 'pad-editor', browserName, 1);
await expect(page.locator('h2').getByText(randomPad[0])).toBeVisible();
await page.locator('.ProseMirror.bn-editor').click();
await page.locator('.ProseMirror.bn-editor').fill('Hello World');
await page.getByText('Generate PDF').first().click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe('impress-document.pdf');
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
const pdfText = (await pdf(pdfBuffer)).text;
expect(pdfText).toContain('Monsieur le Premier Ministre'); // This is the template text
expect(pdfText).toContain('La directrice'); // This is the template text
expect(pdfText).toContain('Hello World'); // This is the pad text
});
test('it renders correctly when we switch from one pad to another', async ({
page,
browserName,
}) => {
const [firstPad, secondPad] = await createPad(
page,
'pad-multiple',
browserName,
2,
);
const panel = page.getByLabel('Pads panel').first();
// Check the first pad
await panel.getByText(firstPad).click();
await expect(page.locator('h2').getByText(firstPad)).toBeVisible();
await page.locator('.ProseMirror.bn-editor').click();
await page.locator('.ProseMirror.bn-editor').fill('Hello World Pad 1');
await expect(page.getByText('Hello World Pad 1')).toBeVisible();
// Check the second pad
await panel.getByText(secondPad).click();
await expect(page.locator('h2').getByText(secondPad)).toBeVisible();
await expect(page.getByText('Hello World Pad 1')).toBeHidden();
await page.locator('.ProseMirror.bn-editor').click();
await page.locator('.ProseMirror.bn-editor').fill('Hello World Pad 2');
await expect(page.getByText('Hello World Pad 2')).toBeVisible();
// Check the first pad again
await panel.getByText(firstPad).click();
await expect(page.locator('h2').getByText(firstPad)).toBeVisible();
await expect(page.getByText('Hello World Pad 2')).toBeHidden();
await expect(page.getByText('Hello World Pad 1')).toBeVisible();
});
});
@@ -0,0 +1,92 @@
import { expect, test } from '@playwright/test';
import { waitForElementCount } from '../helpers';
import { createPad, keyCloakSignIn } from './common';
test.beforeEach(async ({ page, browserName }) => {
await page.goto('/');
await keyCloakSignIn(page, browserName);
});
test.describe('Pads Panel', () => {
test('checks all the elements are visible', async ({ page }) => {
const panel = page.getByLabel('Pads panel').first();
await expect(panel.getByText('Documents')).toBeVisible();
await expect(
panel.getByRole('button', {
name: 'Sort the pads',
}),
).toBeVisible();
await expect(
panel.getByRole('button', {
name: 'Add a pad',
}),
).toBeVisible();
});
test('checks the sort button', async ({ page }) => {
const responsePromiseSortDesc = page.waitForResponse(
(response) =>
response.url().includes('/documents/?page=1&ordering=-created_at') &&
response.status() === 200,
);
const responsePromiseSortAsc = page.waitForResponse(
(response) =>
response.url().includes('/documents/?page=1&ordering=created_at') &&
response.status() === 200,
);
const panel = page.getByLabel('Pads panel').first();
await panel
.getByRole('button', {
name: 'Sort the pads by creation date ascendent',
})
.click();
const responseSortAsc = await responsePromiseSortAsc;
expect(responseSortAsc.ok()).toBeTruthy();
await panel
.getByRole('button', {
name: 'Sort the pads by creation date descendent',
})
.click();
const responseSortDesc = await responsePromiseSortDesc;
expect(responseSortDesc.ok()).toBeTruthy();
});
test('checks the infinite scroll', async ({ page, browserName }) => {
test.setTimeout(90000);
const panel = page.getByLabel('Pads panel').first();
const randomPads = await createPad(page, 'pad-infinite', browserName, 40);
await expect(panel.locator('li')).toHaveCount(20);
await panel.getByText(randomPads[24]).click();
await waitForElementCount(panel.locator('li'), 21, 10000);
expect(await panel.locator('li').count()).toBeGreaterThan(20);
});
test('checks the hover and selected state', async ({ page, browserName }) => {
const panel = page.getByLabel('Pads panel').first();
await createPad(page, 'pad-hover', browserName, 2);
const selectedPad = panel.locator('li').nth(0);
await expect(selectedPad).toHaveCSS(
'background-color',
'rgb(202, 202, 251)',
);
const hoverPad = panel.locator('li').nth(1);
await hoverPad.hover();
await expect(hoverPad).toHaveCSS('background-color', 'rgb(227, 227, 253)');
});
});
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "app-e2e",
"version": "1.0.0",
"version": "0.1.0",
"private": true,
"scripts": {
"lint": "eslint . --ext .ts",
@@ -9,7 +9,7 @@
"test:ui": "yarn test --ui"
},
"devDependencies": {
"@playwright/test": "1.45.0",
"@playwright/test": "1.44.0",
"@types/node": "*",
"@types/pdf-parse": "1.1.4",
"eslint-config-impress": "*",
+3 -14
View File
@@ -25,7 +25,7 @@ export default defineConfig({
reporter: [['html', { outputFolder: './report' }]],
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
baseURL,
baseURL: baseURL,
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
@@ -42,24 +42,13 @@ export default defineConfig({
/* Configure projects for major browsers */
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
locale: 'en-US',
storageState: 'playwright/.auth/user-chromium.json',
},
dependencies: ['setup'],
use: { ...devices['Desktop Chrome'], locale: 'en-US' },
},
{
name: 'webkit',
use: {
...devices['Desktop Safari'],
locale: 'en-US',
storageState: 'playwright/.auth/user-webkit.json',
},
dependencies: ['setup'],
use: { ...devices['Desktop Safari'], locale: 'en-US' },
},
],
});
+1 -1
View File
@@ -1,3 +1,3 @@
NEXT_PUBLIC_API_ORIGIN=http://localhost:8071
NEXT_PUBLIC_API_URL=/api/v1.0/
NEXT_PUBLIC_SIGNALING_URL=ws://localhost:4444
NEXT_PUBLIC_SW_DEACTIVATED=true
@@ -0,0 +1 @@
NEXT_PUBLIC_API_URL=/api/v1.0/
+1
View File
@@ -1 +1,2 @@
NEXT_PUBLIC_API_ORIGIN=http://test.jest
NEXT_PUBLIC_API_URL=/api/
+1 -1
View File
@@ -10,5 +10,5 @@ module.exports = {
rootDir: __dirname,
},
},
ignorePatterns: ['node_modules', '.eslintrc.js', 'service-worker.js'],
ignorePatterns: ['node_modules', '.eslintrc.js'],
};
-2
View File
@@ -33,5 +33,3 @@ yarn-error.log*
# typescript
*.tsbuildinfo
service-worker.js
@@ -1,2 +1 @@
next-env.d.ts
service-worker.js
+6 -2
View File
@@ -8,8 +8,12 @@ server {
try_files $uri index.html $uri/ =404;
}
location /docs/ {
error_page 404 /docs/[id]/;
location /pads/ {
error_page 404 /pads/[id]/;
}
location /templates/ {
error_page 404 /templates/[id]/;
}
error_page 404 /404.html;
+1 -28
View File
@@ -1,9 +1,3 @@
const crypto = require('crypto');
const { InjectManifest } = require('workbox-webpack-plugin');
const buildId = crypto.randomBytes(256).toString('hex').slice(0, 8);
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'export',
@@ -15,11 +9,7 @@ const nextConfig = {
// Enables the styled-components SWC transform
styledComponents: true,
},
generateBuildId: () => buildId,
env: {
NEXT_PUBLIC_BUILD_ID: buildId,
},
webpack(config, { isServer }) {
webpack(config) {
// Grab the existing rule that handles SVG imports
const fileLoaderRule = config.module.rules.find((rule) =>
rule.test?.test?.('.svg'),
@@ -41,23 +31,6 @@ const nextConfig = {
},
);
if (!isServer && process.env.NEXT_PUBLIC_SW_DEACTIVATED !== 'true') {
config.plugins.push(
new InjectManifest({
swSrc: './src/features/service-worker/service-worker.ts',
swDest: '../public/service-worker.js',
include: [
({ asset }) => {
if (asset.name.match(/.*(static).*/)) {
return true;
}
return false;
},
],
}),
);
}
// Modify the file loader rule to ignore *.svg, since we have it handled now.
fileLoaderRule.exclude = /\.svg$/i;
+24 -26
View File
@@ -1,6 +1,6 @@
{
"name": "app-impress",
"version": "1.0.0",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
@@ -15,52 +15,50 @@
"test:watch": "jest --watch"
},
"dependencies": {
"@blocknote/core": "0.14.2",
"@blocknote/mantine": "0.14.2",
"@blocknote/react": "0.14.2",
"@gouvfr-lasuite/integration": "1.0.1",
"@openfun/cunningham-react": "2.9.3",
"@tanstack/react-query": "5.48.0",
"i18next": "23.11.5",
"idb": "8.0.0",
"@blocknote/core": "0.13.2",
"@blocknote/mantine": "0.13.2",
"@blocknote/react": "0.13.2",
"@gouvfr-lasuite/integration": "0.1.3",
"@openfun/cunningham-react": "2.9.0",
"@tanstack/react-query": "5.36.0",
"grapesjs-blocks-basic": "1.0.2",
"i18next": "23.11.4",
"lodash": "4.17.21",
"luxon": "3.4.4",
"next": "14.2.4",
"next": "14.2.3",
"react": "*",
"react-aria-components": "1.2.1",
"react-aria-components": "1.2.0",
"react-dom": "*",
"react-i18next": "14.1.2",
"react-i18next": "14.1.1",
"react-select": "5.8.0",
"styled-components": "6.1.11",
"y-webrtc": "10.3.0",
"yjs": "*",
"zustand": "4.5.4"
"yjs": "13.6.15",
"zustand": "4.5.2"
},
"devDependencies": {
"@svgr/webpack": "8.1.0",
"@tanstack/react-query-devtools": "5.48.0",
"@testing-library/dom": "10.2.0",
"@testing-library/jest-dom": "6.4.6",
"@testing-library/react": "16.0.0",
"@tanstack/react-query-devtools": "5.36.0",
"@testing-library/jest-dom": "6.4.5",
"@testing-library/react": "15.0.7",
"@testing-library/user-event": "14.5.2",
"@types/jest": "29.5.12",
"@types/lodash": "4.17.5",
"@types/lodash": "4.17.1",
"@types/luxon": "3.4.2",
"@types/node": "*",
"@types/react": "18.3.3",
"@types/react": "18.3.2",
"@types/react-dom": "*",
"cross-env": "*",
"dotenv": "16.4.5",
"eslint-config-impress": "*",
"fetch-mock": "9.11.0",
"grapesjs": "0.21.10",
"jest": "29.7.0",
"jest-environment-jsdom": "29.7.0",
"node-fetch": "2.7.0",
"prettier": "3.3.2",
"stylelint": "16.6.1",
"stylelint-config-standard": "36.0.1",
"prettier": "3.2.5",
"stylelint": "16.5.0",
"stylelint-config-standard": "36.0.0",
"stylelint-prettier": "5.0.0",
"typescript": "*",
"workbox-webpack-plugin": "7.1.0"
"typescript": "*"
}
}
@@ -1,29 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<svg
fill="currentColor"
viewBox="0 0 32 32"
id="icon"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<style>
.cls-1 {
fill: none;
}
</style>
</defs>
<title>no-image</title>
<path
d="M30,3.4141,28.5859,2,2,28.5859,3.4141,30l2-2H26a2.0027,2.0027,0,0,0,2-2V5.4141ZM26,26H7.4141l7.7929-7.793,2.3788,2.3787a2,2,0,0,0,2.8284,0L22,19l4,3.9973Zm0-5.8318-2.5858-2.5859a2,2,0,0,0-2.8284,0L19,19.1682l-2.377-2.3771L26,7.4141Z"
/>
<path
d="M6,22V19l5-4.9966,1.3733,1.3733,1.4159-1.416-1.375-1.375a2,2,0,0,0-2.8284,0L6,16.1716V6H22V4H6A2.002,2.002,0,0,0,4,6V22Z"
/>
<rect
id="_Transparent_Rectangle_"
data-name="&lt;Transparent Rectangle&gt;"
class="cls-1"
width="32"
height="32"
/>
</svg>

Before

Width:  |  Height:  |  Size: 796 B

@@ -1,248 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<svg
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
x="0px"
y="0px"
viewBox="0 0 311.6 186.5"
style="enable-background: new 0 0 311.6 186.5"
xml:space="preserve"
>
<style type="text/css">
.st0 {
fill: #ffffff;
}
.st1 {
fill: #000091;
}
.st2 {
fill: #e1000f;
}
.st3 {
fill: #9c9b9b;
}
</style>
<g id="Fond">
<rect x="0" class="st0" width="311.6" height="186.5" />
</g>
<g id="Calque_1">
<path
id="Devise_Républicaine_1_"
d="M100.5,150.8c0.6,0,1.1,0.4,0.8,1.5l-2.7,0.6C99.1,151.8,99.9,150.8,100.5,150.8 M102,155.2
h-0.5c-0.7,0.8-1.4,1.4-2.1,1.4c-0.7,0-1.1-0.4-1.1-1.4c0-0.4,0-0.8,0.1-1.2l4.3-1.4c0.8-2-0.2-2.9-1.4-2.9c-2,0-4.4,3.4-4.4,6.4
c0,1.3,0.6,2.1,1.6,2.1C99.8,158.2,101,157,102,155.2 M101.2,148.9l3-2.8v-0.3h-1.6l-1.9,3.2H101.2z M91.9,150.9h1.4l-2.3,6.2
c-0.2,0.5,0.1,1.1,0.6,1.1c1.3,0,3.4-1.3,4.1-3h-0.4c-0.6,0.6-1.7,1.4-2.6,1.6l2.1-5.8h2.1l0.3-0.9h-2.1l0.8-2.2h-0.8l-1.5,2.2
l-1.8,0.2V150.9z M89.9,150.6c0.2-0.6-0.2-0.9-0.5-0.9c-1.2,0-2.7,1.1-3.3,2.7h0.4c0.4-0.6,1.1-1.2,1.7-1.3l-2.4,6.2
c-0.2,0.6,0.2,0.9,0.5,0.9c1.2,0,2.6-1.1,3.2-2.7h-0.4c-0.4,0.6-1.1,1.2-1.7,1.3L89.9,150.6z M90.4,147.5c0.6,0,1-0.5,1-1
c0-0.6-0.5-1-1-1c-0.6,0-1,0.5-1,1C89.3,147,89.8,147.5,90.4,147.5 M76.6,157c-0.3,0.7,0,1.2,0.7,1.2c0.4,0,0.6-0.1,0.8-0.6
l1.7-4.4c0.8-0.9,2.3-1.9,3-1.9c0.5,0,0.4,0.4,0.1,0.9l-2.5,4.9c-0.2,0.5,0.1,1.1,0.6,1.1c1.2,0,2.7-1.1,3.3-2.7h-0.4
c-0.4,0.6-1.1,1.2-1.7,1.3l2.2-4.4c0.3-0.6,0.4-1.1,0.4-1.5c0-0.7-0.4-1.2-1.2-1.2c-1.1,0-2.2,1.2-3.5,2.7v-1.2
c0-0.8-0.3-1.6-1-1.6c-0.5,0-0.9,0.4-1.3,1v0.2c0.8,0,1.2,1.2,0.6,2.4L76.6,157z M76.6,151.6c0.3-1,0.1-1.9-0.6-1.9
c-0.9,0-1.2,0.7-2.1,2.7v-1.2c0-0.8-0.3-1.6-1-1.6c-0.9,0-1.7,1.4-2.3,2.7H71c0.4-0.6,0.8-1,1.1-1c0.4,0,0.6,0.6,0,1.9l-1.7,3.7
c-0.3,0.7,0,1.2,0.7,1.2c0.4,0,0.6-0.1,0.8-0.6l1.7-4.4c0.5-0.6,0.9-1.1,1.4-1.6H76.6z M67,150.8c0.6,0,1.1,0.4,0.8,1.5l-2.7,0.6
C65.6,151.8,66.4,150.8,67,150.8 M68.5,155.2H68c-0.7,0.8-1.4,1.4-2.1,1.4c-0.7,0-1.1-0.4-1.1-1.4c0-0.4,0-0.8,0.1-1.2l4.3-1.4
c0.8-2-0.2-2.9-1.4-2.9c-2,0-4.4,3.4-4.4,6.4c0,1.3,0.6,2.1,1.6,2.1C66.3,158.2,67.5,157,68.5,155.2 M58.3,150.9h1.4l-2.3,6.2
c-0.2,0.5,0.1,1.1,0.6,1.1c1.3,0,3.4-1.3,4.1-3h-0.4c-0.6,0.6-1.7,1.4-2.6,1.6l2.1-5.8h2.1l0.3-0.9h-2.1l0.8-2.2h-0.8l-1.5,2.2
l-1.8,0.2V150.9z M50.5,155.7c0-1.9,2.1-4.5,3.3-4.5c0.3,0,0.5,0,0.7,0.1l-1.2,3.3c-0.7,0.9-1.8,1.9-2.3,1.9
C50.7,156.6,50.5,156.3,50.5,155.7 M57,149.3l-0.7,0l-0.7,0.7h-0.2c-3.6,0-6.6,4-6.6,6.9c0,0.8,0.5,1.3,1.3,1.3
c0.9,0,1.8-1.3,2.8-2.7l0,0.5c-0.1,1.4,0.3,2.2,1.1,2.2c0.9,0,1.7-1.4,2.3-2.7h-0.4c-0.4,0.6-0.8,1-1.1,1c-0.3,0-0.6-0.6,0-1.9
L57,149.3z M49.5,151.6c0.3-1,0.1-1.9-0.6-1.9c-0.9,0-1.2,0.7-2.1,2.7v-1.2c0-0.8-0.3-1.6-1-1.6c-0.9,0-1.7,1.4-2.3,2.7h0.4
c0.4-0.6,0.8-1,1.1-1c0.4,0,0.6,0.6,0,1.9l-1.7,3.7c-0.3,0.7,0,1.2,0.7,1.2c0.4,0,0.6-0.1,0.8-0.6l1.7-4.4c0.5-0.6,0.9-1.1,1.4-1.6
H49.5z M37.5,157.9l0.2-0.5c-2.1-0.4-2.3-0.4-1.5-2.6l0.8-2.3h2.3c1,0,1,0.4,0.9,1.5h0.6l1.4-3.8h-0.6c-0.5,0.9-0.9,1.5-2,1.5h-2.3
l1.2-3.3c0.4-1,0.6-1.3,2-1.3h1c1.4,0,1.6,0.4,1.6,1.9h0.6l0.5-2.6h-8.6l-0.2,0.5c1.7,0.3,1.8,0.5,1,2.6l-1.9,5.1
c-0.8,2.1-1.1,2.3-3,2.6l-0.1,0.5H37.5z M79.7,131.4c0.6,0,1.1,0.4,0.8,1.5l-2.7,0.6C78.3,132.3,79.1,131.4,79.7,131.4 M81.2,135.7
h-0.5c-0.7,0.8-1.4,1.4-2.1,1.4c-0.7,0-1.1-0.4-1.1-1.4c0-0.4,0-0.8,0.1-1.2l4.3-1.4c0.8-2-0.2-2.9-1.4-2.9c-2,0-4.4,3.4-4.4,6.4
c0,1.3,0.6,2.1,1.6,2.1C79,138.7,80.2,137.6,81.2,135.7 M80.3,129.4l3-2.8v-0.3h-1.6l-1.9,3.2H80.3z M71,131.4h1.4l-2.3,6.2
c-0.2,0.5,0.1,1.1,0.6,1.1c1.3,0,3.4-1.3,4.1-3h-0.4c-0.6,0.6-1.7,1.4-2.6,1.6l2.1-5.8h2.1l0.3-0.9h-2.1l0.8-2.2h-0.8l-1.5,2.2
l-1.8,0.2V131.4z M69.1,131.1c0.2-0.6-0.2-0.9-0.5-0.9c-1.2,0-2.7,1.1-3.3,2.7h0.4c0.4-0.6,1.1-1.2,1.7-1.3l-2.4,6.2
c-0.2,0.6,0.2,0.9,0.5,0.9c1.2,0,2.6-1.1,3.2-2.7h-0.4c-0.4,0.6-1.1,1.2-1.7,1.3L69.1,131.1z M69.5,128c0.6,0,1-0.5,1-1
c0-0.6-0.5-1-1-1c-0.6,0-1,0.5-1,1C68.5,127.6,68.9,128,69.5,128 M61.2,137.3l4.3-11.4l-0.1-0.2l-2.7,0.3v0.3l0.5,0.4
c0.5,0.4,0.3,0.7-0.1,1.9l-3.4,8.9c-0.2,0.5,0.1,1.1,0.6,1.1c1.2,0,2.6-1.1,3.2-2.7h-0.4C62.7,136.6,61.8,137.2,61.2,137.3
M53,136.3c0-1.9,2.1-4.5,3.3-4.5c0.3,0,0.5,0,0.7,0.1l-1.2,3.3c-0.7,0.9-1.8,1.9-2.3,1.9C53.1,137.1,53,136.8,53,136.3
M59.5,129.9l-0.7,0l-0.7,0.7H58c-3.6,0-6.6,4-6.6,6.9c0,0.8,0.5,1.3,1.3,1.3c0.9,0,1.8-1.3,2.8-2.7l0,0.5
c-0.1,1.4,0.3,2.2,1.1,2.2c0.9,0,1.7-1.4,2.3-2.7h-0.4c-0.4,0.6-0.8,1-1.1,1c-0.3,0-0.6-0.6,0-1.9L59.5,129.9z M43.7,140.2
c0-0.8,0.8-1.3,1.9-1.8c0.4,0.2,0.9,0.4,1.7,0.6c1.2,0.4,1.6,0.5,1.6,0.9c0,0.8-1.3,1.3-3.1,1.3C44.4,141.3,43.7,141,43.7,140.2
M46.9,135.2c-0.5,0-0.7-0.4-0.7-0.9c0-1.4,0.7-3.4,1.9-3.4c0.5,0,0.7,0.4,0.7,0.9C48.8,133.1,48,135.2,46.9,135.2 M50.3,139.4
c0-1-0.9-1.4-2.3-1.8c-1.2-0.4-1.8-0.5-1.8-0.9c0-0.3,0.3-0.7,0.8-1c2-0.1,3.4-1.9,3.4-3.5c0-0.3-0.1-0.6-0.2-0.9h1.7l0.3-0.9h-2.7
c-0.3-0.2-0.7-0.3-1.1-0.3c-2.2,0-3.6,1.9-3.6,3.4c0,1.2,0.7,1.9,1.7,2.1c-1,0.5-1.6,1-1.6,1.6c0,0.4,0.1,0.6,0.4,0.8
c-2.3,0.7-3.3,1.5-3.3,2.6c0,1.1,1.4,1.5,3.1,1.5C47.9,142.3,50.3,140.7,50.3,139.4 M39.4,132.8c1,0,1,0.4,0.9,1.5h0.6l1.4-3.8
h-0.6c-0.5,0.9-0.9,1.5-2,1.5h-2.3l1.1-3.1c0.4-1,0.6-1.2,2-1.2h1c1.4,0,1.6,0.4,1.6,1.8h0.6l0.5-2.6h-8.6l-0.2,0.5
c1.7,0.3,1.8,0.5,1,2.6l-1.9,5.1c-0.8,2.1-1.1,2.3-3,2.6l-0.1,0.5H41l1.7-2.7H42c-1.1,1-2.5,2-4.4,2c-2.5,0-2.3-0.1-1.5-2.4
l0.9-2.5H39.4z M40.6,126.2l3-2.1v-0.3h-1.8l-1.7,2.4H40.6z M78.7,111.9c0.6,0,1.1,0.4,0.8,1.5l-2.7,0.6
C77.3,112.8,78,111.9,78.7,111.9 M80.2,116.2h-0.5c-0.7,0.8-1.4,1.4-2.1,1.4c-0.7,0-1.1-0.4-1.1-1.4c0-0.4,0-0.8,0.1-1.2l4.3-1.4
c0.8-2-0.2-2.9-1.4-2.9c-2,0-4.4,3.4-4.4,6.4c0,1.3,0.6,2.1,1.6,2.1C77.9,119.2,79.2,118.1,80.2,116.2 M79.3,110l3-2.8v-0.3h-1.6
l-1.9,3.2H79.3z M70.4,111.9h1.1l-2.3,6.2c-0.2,0.5,0.1,1.1,0.6,1.1c1.3,0,3.4-1.3,4.1-3h-0.4c-0.6,0.6-1.7,1.4-2.6,1.6l2.1-5.8
h2.1l0.3-0.9h-2.1l0.8-2.2h-0.8l-1.5,2.2l-1.5,0.2V111.9z M69.2,112.6c0.3-1,0.1-1.9-0.6-1.9c-0.9,0-1.2,0.7-2.1,2.7v-1.2
c0-0.8-0.3-1.6-1-1.6c-0.9,0-1.7,1.4-2.3,2.7h0.4c0.4-0.6,0.8-1,1.1-1c0.4,0,0.6,0.6,0,1.9l-1.7,3.7c-0.3,0.7,0,1.2,0.7,1.2
c0.4,0,0.6-0.1,0.8-0.6l1.7-4.4c0.5-0.6,0.9-1.1,1.4-1.6H69.2z M59.7,111.9c0.6,0,1.1,0.4,0.8,1.5l-2.7,0.6
C58.3,112.8,59.1,111.9,59.7,111.9 M61.2,116.2h-0.5c-0.7,0.8-1.4,1.4-2.1,1.4c-0.7,0-1.1-0.4-1.1-1.4c0-0.4,0-0.8,0.1-1.2l4.3-1.4
c0.8-2-0.2-2.9-1.4-2.9c-2,0-4.4,3.4-4.4,6.4c0,1.3,0.6,2.1,1.6,2.1C59,119.2,60.2,118.1,61.2,116.2 M50.6,118c-0.4,0-1-0.4-1-0.7
c0-0.1,0.2-0.6,0.4-1.2l0.7-1.9c0.7-0.9,1.9-1.8,2.5-1.8c0.4,0,0.7,0.2,0.7,0.8C53.9,114.9,52.3,118,50.6,118 M55.5,112.5
c0-1.3-0.5-1.7-1.3-1.7c-1.1,0-2.1,1.2-3.1,2.6l2.6-6.8l-0.1-0.2l-2.7,0.3v0.3l0.5,0.4c0.5,0.4,0.3,0.8-0.1,1.9l-2.8,7.2
c-0.2,0.5-0.5,1.2-0.5,1.3c0,0.7,1,1.4,1.9,1.4C52,119.2,55.5,115.4,55.5,112.5 M47,111.6c0.2-0.6-0.2-0.9-0.5-0.9
c-1.2,0-2.7,1.1-3.3,2.7h0.4c0.4-0.6,1.1-1.2,1.7-1.3l-2.4,6.2c-0.2,0.6,0.2,0.9,0.5,0.9c1.2,0,2.6-1.1,3.2-2.7h-0.4
c-0.4,0.6-1.1,1.2-1.7,1.3L47,111.6z M47.5,108.5c0.6,0,1-0.5,1-1c0-0.6-0.5-1-1-1c-0.6,0-1,0.5-1,1
C46.4,108.1,46.9,108.5,47.5,108.5 M41.2,107.5h-5.7l-0.2,0.5c1.7,0.3,1.8,0.5,1,2.6l-1.8,5.1c-0.8,2.1-1.1,2.3-3,2.6l-0.1,0.5H40
l1.9-3.3h-0.7c-1.1,1.2-2.3,2.6-4.2,2.6c-1.4,0-1.6-0.2-0.8-2.4l1.8-5.1c0.8-2.1,1.1-2.3,3-2.6L41.2,107.5z"
/>
<path
d="M40.9,88.2c1,0,1.9-0.2,2.7-0.5c0.8-0.3,1.5-0.8,2.1-1.3v-3.5h-5.3v-3.8h9.4v8.8c-1,1.3-2.2,2.3-3.8,3.1s-3.3,1.2-5.2,1.2
c-1.7,0-3.2-0.3-4.6-0.9c-1.4-0.6-2.6-1.4-3.6-2.4c-1-1-1.8-2.1-2.3-3.5c-0.6-1.3-0.8-2.7-0.8-4.2c0-1.5,0.3-2.9,0.8-4.2
c0.5-1.3,1.3-2.5,2.2-3.5c1-1,2.1-1.8,3.5-2.4s2.8-0.9,4.5-0.9c1.9,0,3.5,0.4,5,1.2c1.5,0.8,2.7,1.8,3.7,3L46,77
c-0.6-0.8-1.3-1.5-2.3-2.1s-2-0.8-3.1-0.8c-1,0-1.9,0.2-2.7,0.5c-0.8,0.4-1.5,0.9-2.1,1.5c-0.6,0.6-1,1.4-1.4,2.2
c-0.3,0.9-0.5,1.8-0.5,2.8s0.2,1.9,0.5,2.8c0.3,0.9,0.8,1.6,1.4,2.2c0.6,0.6,1.4,1.1,2.2,1.5C39,88,39.9,88.2,40.9,88.2z M64,70.3
c1.6,0,3.1,0.3,4.4,0.9s2.5,1.4,3.5,2.4c1,1,1.7,2.1,2.2,3.5c0.5,1.3,0.8,2.7,0.8,4.2c0,1.5-0.3,2.9-0.8,4.2
c-0.5,1.3-1.3,2.5-2.2,3.5c-1,1-2.1,1.8-3.5,2.4s-2.8,0.9-4.4,0.9c-1.6,0-3.1-0.3-4.5-0.9s-2.5-1.4-3.5-2.4c-1-1-1.7-2.1-2.2-3.5
c-0.5-1.3-0.8-2.7-0.8-4.2c0-1.5,0.3-2.9,0.8-4.2c0.5-1.3,1.3-2.5,2.2-3.5c1-1,2.1-1.8,3.5-2.4S62.4,70.3,64,70.3z M64,88.2
c1,0,1.9-0.2,2.7-0.5c0.8-0.4,1.5-0.9,2.1-1.5c0.6-0.6,1-1.4,1.4-2.2s0.5-1.8,0.5-2.8s-0.2-1.9-0.5-2.8c-0.3-0.9-0.8-1.6-1.4-2.2
c-0.6-0.6-1.3-1.1-2.1-1.5c-0.8-0.4-1.7-0.5-2.7-0.5c-1,0-1.9,0.2-2.7,0.5c-0.8,0.4-1.5,0.9-2.1,1.5c-0.6,0.6-1,1.4-1.4,2.2
c-0.3,0.9-0.5,1.8-0.5,2.8s0.2,1.9,0.5,2.8c0.3,0.9,0.8,1.6,1.4,2.2c0.6,0.6,1.3,1.1,2.1,1.5C62.1,88,63,88.2,64,88.2z M91.1,83.8
V70.9h4.2v12.6c0,2.7-0.8,4.8-2.3,6.4c-1.5,1.5-3.5,2.3-6.1,2.3c-2.6,0-4.6-0.8-6.1-2.3s-2.2-3.7-2.2-6.4V70.9h4.2v12.9
c0,1.4,0.4,2.5,1.1,3.2c0.7,0.8,1.8,1.2,3.1,1.2c1.3,0,2.3-0.4,3-1.2C90.8,86.3,91.1,85.2,91.1,83.8z M97.9,70.9h4.5l6.1,16.1
l6.1-16.1h4.5l-7.8,20.7h-5.5L97.9,70.9z M122.2,91.5V70.9h12v3.6h-7.8v4.8h6.7v3.6h-6.7V88h7.8v3.6H122.2z M139.1,91.5V70.9h6.3
c2.3,0,4.1,0.6,5.4,1.7c1.3,1.1,2,2.6,2,4.5c0,1.2-0.3,2.3-0.9,3.2c-0.6,0.9-1.4,1.6-2.4,2.1l6.5,9.1h-5l-5.5-8.3h-2.2v8.3H139.1z
M145.7,74.4h-2.4v5.2h2.4c0.9,0,1.6-0.2,2.1-0.7c0.5-0.5,0.7-1.1,0.7-1.9c0-0.8-0.2-1.4-0.7-1.9C147.2,74.7,146.6,74.4,145.7,74.4
z M158.6,91.5V70.9h5.4l9.2,14.8V70.9h4.2v20.7H172l-9.2-14.8v14.8H158.6z M183.1,91.5V70.9h12v3.6h-7.8v4.8h6.7v3.6h-6.7V88h7.8
v3.6H183.1z M200,91.5V70.9h5.3l5,8.5l5-8.5h5.3v20.7h-4.2V76.8l-4.6,7.6h-3l-4.6-7.6v14.7H200z M226.3,91.5V70.9h12v3.6h-7.8v4.8
h6.7v3.6h-6.7V88h7.8v3.6H226.3z M243.2,91.5V70.9h5.4l9.2,14.8V70.9h4.2v20.7h-5.4l-9.2-14.8v14.8H243.2z M265.7,74.7v-3.8h16.9
v3.8h-6.4v16.8H272V74.7H265.7z"
/>
<g id="Marianne">
<path
id="Fond_2_"
class="st0"
d="M63.6,53.4c0.3-0.3,0.6-0.6,0.9-1h0c0.6-0.6,1.1-1.3,1.8-1.8c0.2-0.2,0.4-0.3,0.6-0.5
c0.1-0.1,0.1-0.2,0.1-0.2c-0.3,0.1-0.4,0.3-0.7,0.4c-0.1,0-0.1-0.1-0.1-0.1c0.2-0.1,0.4-0.3,0.6-0.4c0,0,0,0,0,0
c-0.1,0-0.1-0.1-0.1-0.1c-0.7-0.1-1.2,0.4-1.7,0.8c-0.1,0.1-0.2-0.1-0.3-0.1c-0.8,0.3-1.4,1-2.2,1.3v-0.1c-0.3,0.1-0.6,0.3-1,0.4
c-0.5,0.1-0.9,0.1-1.3,0.1c-0.6,0.1-1.3,0.2-1.9,0.3c0,0,0,0-0.1,0c-0.3,0.1-0.7,0.2-1,0.4c0,0,0,0,0,0c0,0-0.1,0.1-0.1,0.1
c-0.1,0.1-0.2,0.2-0.4,0.3c-0.3,0.2-0.6,0.5-0.9,0.7c0,0-0.1,0-0.1,0c-0.3,0.3-0.6,0.6-0.9,0.8c0,0-0.1,0-0.2,0c0,0,0,0,0,0
c0,0,0,0,0-0.1c0-0.1,0.1-0.2,0.1-0.2c0.1-0.1,0.1-0.2,0.2-0.2c0.1-0.1,0.1-0.2,0.2-0.3c0,0,0-0.1,0-0.1c0,0,0,0-0.1,0
c0.3-0.3,0.6-0.5,0.9-0.7v0c0,0-0.1,0-0.1-0.1c0,0,0.1-0.1,0.1-0.1c0,0,0,0,0,0c0,0,0,0,0,0c-0.1,0.1-0.2,0.1-0.3,0.2
c-0.1,0.1-0.2,0.4-0.4,0.3c0,0-0.1,0-0.1,0c0,0,0,0-0.1,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0
c0,0,0,0,0,0c0,0,0,0,0-0.1c0,0,0,0,0,0c0,0,0-0.1,0.1-0.1c0,0,0,0,0-0.1c0,0,0-0.1,0.1-0.1c0,0,0-0.1,0-0.1
c0.1-0.1,0.2-0.2,0.4-0.3h0c0.2-0.1,0.4-0.2,0.6-0.3c0,0,0.1-0.1,0.1-0.1c-0.3,0.1-0.6,0.2-0.9,0.4c0,0-0.1,0-0.1,0
c0,0-0.1,0-0.1,0c0,0,0,0,0,0c0.1-0.1,0.2-0.2,0.3-0.3c0.1,0,0.1,0,0.1,0.1c1.7-1.3,4.1-1,6.1-1.7c0.2-0.1,0.3-0.2,0.5-0.3
c0.3-0.1,0.5-0.4,0.8-0.5c0.4-0.3,0.7-0.7,0.9-1.2c0-0.1-0.1-0.1-0.1-0.1c-0.7,0.8-1.5,1.3-2.4,1.8c-1.1,0.6-2.4,0.5-3.5,0.6
c0.1-0.1,0.2-0.1,0.3-0.1c0-0.2,0.1-0.2,0.2-0.3H59c0.1,0,0.1-0.1,0.1-0.1c0.1,0,0.3-0.1,0.2-0.1c-0.2-0.2-0.5,0.2-0.8,0
c0.1-0.1,0.1-0.3,0.2-0.3h0.2c0-0.1,0.1-0.2,0.1-0.2c0.8-0.5,1.6-0.9,2.3-1.3c-0.2,0-0.3,0.2-0.4,0.1c0.1,0,0-0.2,0.1-0.2
c0.6-0.2,1.1-0.5,1.7-0.7c-0.2,0-0.4,0.2-0.6,0c0.1-0.1,0.2-0.2,0.3-0.2v-0.2c0-0.1,0.1-0.1,0.1-0.1c-0.1,0-0.1-0.1-0.1-0.1
c0.1-0.1,0.2-0.1,0.3-0.2c-0.1,0-0.2,0-0.2-0.1c0.2-0.2,0.4-0.3,0.7-0.3c-0.1-0.1-0.2,0-0.2-0.1c0-0.1,0.1-0.1,0.1-0.1H63
c-0.1-0.1-0.1-0.2-0.1-0.2c0.3-0.4,0.3-0.9,0.5-1.3c-0.1,0-0.1,0-0.1-0.1c-0.5,0.6-1.4,0.8-2.2,1h-0.3c-0.3,0.1-0.6,0.1-0.9-0.1
c-0.2-0.1-0.3-0.3-0.5-0.4c-0.4-0.3-0.9-0.5-1.3-0.6c-1.3-0.4-2.7-0.6-4.1-0.6c0.6-0.3,1.2-0.3,1.9-0.5c0.9-0.3,1.8-0.6,2.7-0.5
c-0.2-0.1-0.4,0-0.5,0c-0.8-0.1-1.5,0.2-2.3,0.3c-0.5,0.1-1,0.3-1.6,0.4c-0.3,0.1-0.5,0.4-0.9,0.4v-0.2c0.5-0.6,1.2-1.3,2-1.3
c1-0.2,1.9,0,2.8,0.1c0.7,0.1,1.3,0.2,2,0.4c0.3,0,0.3,0.4,0.5,0.5c0.3,0.1,0.6,0,1,0.2c0-0.1-0.1-0.2,0-0.3
c0.2-0.2,0.5,0.1,0.7-0.1c0.4-0.3-0.4-0.7-0.6-1.1c0-0.1,0.1-0.1,0.1-0.1c0.4,0.4,0.7,0.8,1.3,1.1c0.3,0.1,0.9,0.3,0.8-0.1
c-0.3-0.6-0.8-1.1-1.2-1.6v-0.2c-0.1,0-0.1-0.1-0.2-0.1v-0.2c-0.2-0.1-0.2-0.3-0.3-0.5c-0.2-0.3-0.1-0.6-0.2-1
C62.1,39.3,62,39,62,38.7c-0.2-0.9-0.4-1.7-0.5-2.6c-0.1-1,0.6-1.8,1.1-2.7c0.4-0.6,0.8-1.3,1.5-1.7c0.2-0.6,0.6-1.2,1-1.7
c0.4-0.5,1.1-0.8,1.7-1.1c0.7-0.3,1.4-0.5,1.4-0.5l10.7,0c0,0,0.1,0,0.3,0.1c0.2,0.1,0.6,0.3,0.8,0.4c0.4,0.2,0.8,0.5,1,0.9
c0.1,0.2,0.3,0.5,0.2,0.7c-0.1,0.3-0.2,0.7-0.4,0.8c-0.3,0.2-0.7,0.2-1.1,0.1c-0.2,0-0.4-0.1-0.6-0.1c0.8,0.3,1.6,0.7,2.1,1.4
c0.1,0.1,0.3,0.2,0.5,0.2c0.1,0,0.1,0.1,0.1,0.2c-0.1,0.1-0.2,0.2-0.2,0.3h0.2c0.3-0.1,0.2-0.6,0.6-0.5c0.3,0.2,0.4,0.5,0.2,0.8
c-0.2,0.2-0.4,0.4-0.6,0.5c-0.1,0.1-0.1,0.3,0,0.4c0.2,0.2,0.2,0.4,0.3,0.6c0.2,0.4,0.2,0.8,0.4,1.2c0.2,0.8,0.4,1.6,0.4,2.4
c0,0.4-0.2,0.8-0.1,1.2c0.1,0.4,0.4,0.8,0.6,1.1c0.2,0.3,0.4,0.5,0.6,0.9c0.3,0.5,0.9,1.1,0.6,1.7c-0.2,0.4-0.8,0.3-1.1,0.5
c-0.3,0.3-0.1,0.7,0.1,1c0.3,0.5-0.3,0.8-0.7,1c0.1,0.2,0.3,0.1,0.4,0.2c0.1,0.3,0.3,0.4,0.2,0.7c-0.2,0.3-0.9,0.5-0.5,1
c0.2,0.4,0.1,0.8-0.1,1.2c-0.2,0.5-0.6,0.7-1,0.8c-0.3,0.1-0.7,0.1-1,0.1c-0.1-0.1-0.2-0.1-0.3-0.1c-0.9-0.1-1.8-0.4-2.7-0.4
c-0.3,0.1-0.5,0.1-0.7,0.2c-0.2,0.2-0.5,0.4-0.6,0.6c0,0,0,0,0,0c0,0-0.1,0.1-0.1,0.1c0,0,0,0.1-0.1,0.1c0,0,0,0,0,0.1
c-0.2,0.2-0.3,0.4-0.4,0.6c0,0,0,0,0,0c0,0,0,0.1,0,0.1c-0.2,0.3-0.3,0.7-0.4,1c-0.4,1.2-0.2,2.3,0.1,2.5c0.1,0.1,1.8,0.6,2.9,1.1
c0.6,0.2,0.9,0.4,1.3,0.6l-22,0c1-0.7,2-1.1,3.5-1.8C61.6,54.6,63.1,53.9,63.6,53.4 M55.4,49.5c-0.1,0-0.3,0.1-0.3-0.1
c0.1-0.3,0.4-0.3,0.6-0.4c0.1-0.1,0.3-0.2,0.4-0.1c0.1,0.2,0.3,0.1,0.4,0.2C56.2,49.5,55.8,49.4,55.4,49.5 M47.2,48.3
c0,0-0.1-0.1-0.1-0.1c0.7-0.9,1.2-1.8,1.7-2.7c0.7-0.4,1.3-0.9,1.8-1.5c0.9-1,1.9-1.8,3-2.4c0.4-0.2,1-0.1,1.4,0.1
c-0.2,0.2-0.4,0.2-0.6,0.3c-0.1,0-0.1,0-0.2-0.1c0.1-0.1,0.1-0.1,0.1-0.2c-0.5,0.6-1.3,0.9-1.7,1.6c-0.3,0.5-0.5,1.2-1.2,1.4
c-0.2,0.1,0.1-0.2-0.1-0.1C49.6,45.7,48.5,46.9,47.2,48.3 M51.6,44.8c-0.1,0.1-0.1,0.1-0.2,0.2c-0.1,0.1-0.1,0.2-0.2,0.2
c-0.1,0-0.1,0-0.1-0.1c0.1-0.2,0.2-0.4,0.4-0.5C51.6,44.7,51.6,44.8,51.6,44.8 M54.1,52.8c0,0.1-0.1,0.1-0.1,0.2
c0.1,0,0.1,0,0.1,0.1c-0.1,0.1-0.2,0.2-0.4,0.3c0,0,0,0-0.1,0c-0.1,0.1-0.1,0.1-0.2,0.2c-0.1,0.1-0.4,0-0.3-0.1
c0.1-0.1,0.3-0.3,0.4-0.4c0.1-0.1,0.2-0.1,0.2-0.2c0,0,0.1-0.1,0.1-0.1C53.9,52.8,54.2,52.7,54.1,52.8 M53.2,52.4
C53.2,52.4,53.1,52.4,53.2,52.4c-0.2,0.2-0.4,0.3-0.6,0.4c-0.2,0.1-0.5,0.2-0.7,0.3c0,0,0,0,0,0c0,0-0.1,0-0.1,0
c-0.2,0.1-0.4,0.3-0.5,0.4c0,0,0,0-0.1,0.1c0,0,0,0,0,0c0,0,0,0,0,0c0,0-0.1,0.1-0.1,0.1c0,0,0,0,0,0c0,0,0,0,0,0
c0,0-0.1,0.1-0.1,0.1c0,0,0,0.1-0.1,0.1c0,0-0.1,0-0.1,0c0,0,0,0,0,0c0,0-0.1,0-0.1,0c0,0-0.1,0-0.1,0c0,0,0,0,0,0c0,0,0,0,0,0
c-0.1,0.1-0.1,0.1-0.2,0.2c-0.1,0.1-0.2,0.2-0.3,0.3c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0.1
l0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0-0.1,0-0.1l0,0c0,0,0,0,0,0c0.1-0.1,0.1-0.1,0.2-0.2c0,0,0,0,0,0c0,0,0,0,0.1-0.1
c0,0,0.1-0.1,0.1-0.1c0,0,0,0,0,0c0.1-0.1,0.1-0.2,0.2-0.2c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0.1-0.1,0.1-0.1c0,0,0-0.1,0.1-0.1
c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0-0.1,0.1-0.1c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0-0.1c0,0,0,0,0,0c0,0,0-0.1,0-0.1
c0,0,0,0,0,0c0.1-0.1,0.1-0.2,0.2-0.3c0,0,0,0,0,0c-0.1,0-0.1,0.1-0.2,0.2c-0.1,0-0.2,0-0.1-0.1c0,0,0.1-0.1,0.1-0.1c0,0,0,0,0,0
c0.1-0.1,0.2-0.2,0.3-0.3c0.1,0,0.1-0.1,0.2-0.1c0,0,0,0,0,0c0,0,0.1-0.1,0.1-0.1c0,0,0,0,0,0c0.5-0.5,1.4-0.5,2.1-0.8
c0.3-0.1,0.6,0.1,0.9,0c0.2,0,0.3,0,0.5,0.1C54,51.8,53.6,52.1,53.2,52.4 M54.3,48.7c-0.1-0.1,0.2,0,0.2-0.1H54
c-0.1,0-0.1-0.1-0.1-0.1c-0.3,0.1-0.6,0.2-0.9,0.2c-0.4,0.1-0.7,0.4-1.1,0.5c-0.6,0.2-1.1,0.7-1.7,0.9c-0.1,0-0.1-0.1-0.1-0.1
c0.1-0.2,0.3-0.2,0.4-0.4c0-0.1,0-0.1-0.1-0.1c0.4-0.6,1-0.9,1.6-1.4v-0.2c0.2-0.2,0.4-0.3,0.5-0.6c0.1-0.2,0.3-0.4,0.5-0.5
c-0.1-0.1-0.2-0.1-0.2-0.2c-0.2,0-0.4,0.1-0.6-0.1c0.1-0.1,0.2-0.2,0.3-0.2c0,0-0.1,0-0.1-0.1c-0.1-0.1,0.1-0.2,0.3-0.3
c0.2-0.1,0.5-0.1,0.6-0.2c-0.4-0.1-0.8,0.1-1.2-0.1c0.3-0.7,0.7-1.3,1.3-1.6c0.1,0,0.2,0,0.2,0.1c0,0.3-0.2,0.5-0.4,0.5
c0.4,0.1,0.9,0.1,1.3,0.3c-0.1,0.1-0.2,0.1-0.2,0.1c0.3,0.2,0.6,0.1,0.9,0.3c-0.2,0.2-0.3,0-0.5,0c1.7,0.5,3.4,0.9,4.8,1.9
c-1.2,0.6-2.4,0.9-3.7,1.1c-0.2,0-0.3,0-0.4-0.1c0,0.1,0,0.2-0.1,0.2c-0.2,0-0.4,0-0.5,0.1C54.7,48.8,54.4,48.8,54.3,48.7"
/>
<path
id="Rouge_1_"
class="st1"
d="M63.6,53.4c0.3-0.3,0.6-0.6,0.9-1h0c0.6-0.6,1.1-1.3,1.8-1.8c0.2-0.2,0.4-0.3,0.6-0.5
c0.1-0.1,0.1-0.2,0.1-0.2c-0.3,0.1-0.4,0.3-0.7,0.4c-0.1,0-0.1-0.1-0.1-0.1c0.2-0.1,0.4-0.3,0.6-0.4c0,0,0,0,0,0
c-0.1,0-0.1-0.1-0.1-0.1c-0.7-0.1-1.2,0.4-1.7,0.8c-0.1,0.1-0.2-0.1-0.3-0.1c-0.8,0.3-1.4,1-2.2,1.3v-0.1c-0.3,0.1-0.6,0.3-1,0.4
c-0.5,0.1-0.9,0.1-1.3,0.1c-0.6,0.1-1.3,0.2-1.9,0.3c0,0,0,0-0.1,0c-0.3,0.1-0.7,0.2-1,0.4c0,0,0,0,0,0c0,0-0.1,0.1-0.1,0.1
c-0.1,0.1-0.2,0.2-0.4,0.3c-0.3,0.2-0.6,0.5-0.9,0.7c0,0-0.1,0-0.1,0c-0.3,0.3-0.6,0.6-0.9,0.8c0,0-0.1,0-0.2,0c0,0,0,0,0,0
c0,0,0,0,0-0.1c0-0.1,0.1-0.2,0.1-0.2c0.1-0.1,0.1-0.2,0.2-0.2c0.1-0.1,0.1-0.2,0.2-0.3c0,0,0-0.1,0-0.1c0,0,0,0-0.1,0
c0.3-0.3,0.6-0.5,0.9-0.7v0c0,0-0.1,0-0.1-0.1c0,0,0.1-0.1,0.1-0.1c0,0,0,0,0,0c0,0,0,0,0,0c-0.1,0.1-0.2,0.1-0.3,0.2
c-0.1,0.1-0.2,0.4-0.4,0.3c0,0-0.1,0-0.1,0c0,0,0,0-0.1,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0
c0,0,0,0,0,0c0,0,0,0,0-0.1c0,0,0,0,0,0c0,0,0-0.1,0.1-0.1c0,0,0,0,0-0.1c0,0,0-0.1,0.1-0.1c0,0,0-0.1,0-0.1
c0.1-0.1,0.2-0.2,0.4-0.3h0c0.2-0.1,0.4-0.2,0.6-0.3c0,0,0.1-0.1,0.1-0.1c-0.3,0.1-0.6,0.2-0.9,0.4c0,0-0.1,0-0.1,0
c0,0-0.1,0-0.1,0c0,0,0,0,0,0c0.1-0.1,0.2-0.2,0.3-0.3c0.1,0,0.1,0,0.1,0.1c1.7-1.3,4.1-1,6.1-1.7c0.2-0.1,0.3-0.2,0.5-0.3
c0.3-0.1,0.5-0.4,0.8-0.5c0.4-0.3,0.7-0.7,0.9-1.2c0-0.1-0.1-0.1-0.1-0.1c-0.7,0.8-1.5,1.3-2.4,1.8c-1.1,0.6-2.4,0.5-3.5,0.6
c0.1-0.1,0.2-0.1,0.3-0.1c0-0.2,0.1-0.2,0.2-0.3H59c0.1,0,0.1-0.1,0.1-0.1c0.1,0,0.3-0.1,0.2-0.1c-0.2-0.2-0.5,0.2-0.8,0
c0.1-0.1,0.1-0.3,0.2-0.3h0.2c0-0.1,0.1-0.2,0.1-0.2c0.8-0.5,1.6-0.9,2.3-1.3c-0.2,0-0.3,0.2-0.4,0.1c0.1,0,0-0.2,0.1-0.2
c0.6-0.2,1.1-0.5,1.7-0.7c-0.2,0-0.4,0.2-0.6,0c0.1-0.1,0.2-0.2,0.3-0.2v-0.2c0-0.1,0.1-0.1,0.1-0.1c-0.1,0-0.1-0.1-0.1-0.1
c0.1-0.1,0.2-0.1,0.3-0.2c-0.1,0-0.2,0-0.2-0.1c0.2-0.2,0.4-0.3,0.7-0.3c-0.1-0.1-0.2,0-0.2-0.1c0-0.1,0.1-0.1,0.1-0.1H63
c-0.1-0.1-0.1-0.2-0.1-0.2c0.3-0.4,0.3-0.9,0.5-1.3c-0.1,0-0.1,0-0.1-0.1c-0.5,0.6-1.4,0.8-2.2,1h-0.3c-0.3,0.1-0.6,0.1-0.9-0.1
c-0.2-0.1-0.3-0.3-0.5-0.4c-0.4-0.3-0.9-0.5-1.3-0.6c-1.3-0.4-2.7-0.6-4.1-0.6c0.6-0.3,1.2-0.3,1.9-0.5c0.9-0.3,1.8-0.6,2.7-0.5
c-0.2-0.1-0.4,0-0.5,0c-0.8-0.1-1.5,0.2-2.3,0.3c-0.5,0.1-1,0.3-1.6,0.4c-0.3,0.1-0.5,0.4-0.9,0.4V44c0.5-0.6,1.2-1.3,2-1.3
c1-0.2,1.9,0,2.8,0.1c0.7,0.1,1.3,0.2,2,0.4c0.3,0,0.3,0.4,0.5,0.5c0.3,0.1,0.6,0,1,0.2c0-0.1-0.1-0.2,0-0.3
c0.2-0.2,0.5,0.1,0.7-0.1c0.4-0.3-0.4-0.7-0.6-1.1c0-0.1,0.1-0.1,0.1-0.1c0.4,0.4,0.7,0.8,1.3,1.1c0.3,0.1,0.9,0.3,0.8-0.1
c-0.3-0.6-0.8-1.1-1.2-1.6v-0.2c-0.1,0-0.1-0.1-0.2-0.1v-0.2c-0.2-0.1-0.2-0.3-0.3-0.5c-0.2-0.3-0.1-0.6-0.2-1
C62.1,39.3,62,39,62,38.7c-0.2-0.9-0.4-1.7-0.5-2.6c-0.1-1,0.6-1.8,1.1-2.7c0.4-0.6,0.8-1.3,1.5-1.7c0.2-0.6,0.6-1.2,1-1.7
c0.4-0.5,1.1-0.8,1.7-1.1c0.7-0.3,1.4-0.5,1.4-0.5H31.4v28.3h26c1-0.7,2-1.1,3.5-1.8C61.6,54.6,63.1,53.9,63.6,53.4 M55.4,49.5
c-0.1,0-0.3,0.1-0.3-0.1c0.1-0.3,0.4-0.3,0.6-0.4c0.1-0.1,0.3-0.2,0.4-0.1c0.1,0.2,0.3,0.1,0.4,0.2C56.2,49.5,55.8,49.4,55.4,49.5
M47.2,48.3c0,0-0.1-0.1-0.1-0.1c0.7-0.9,1.2-1.8,1.7-2.7c0.7-0.4,1.3-0.9,1.8-1.5c0.9-1,1.9-1.8,3-2.4c0.4-0.2,1-0.1,1.4,0.1
c-0.2,0.2-0.4,0.2-0.6,0.3c-0.1,0-0.1,0-0.2-0.1c0.1-0.1,0.1-0.1,0.1-0.2c-0.5,0.6-1.3,0.9-1.7,1.6c-0.3,0.5-0.5,1.2-1.2,1.4
c-0.2,0.1,0.1-0.2-0.1-0.1C49.7,45.7,48.5,46.9,47.2,48.3 M51.6,44.8c-0.1,0.1-0.1,0.1-0.2,0.2c-0.1,0.1-0.1,0.2-0.2,0.2
c-0.1,0-0.1,0-0.1-0.1c0.1-0.2,0.2-0.4,0.4-0.5C51.6,44.7,51.6,44.8,51.6,44.8 M54.1,52.8c0,0.1-0.1,0.1-0.1,0.2
c0.1,0,0.1,0,0.1,0.1c-0.1,0.1-0.2,0.2-0.4,0.3c0,0,0,0-0.1,0c-0.1,0.1-0.1,0.1-0.2,0.2c-0.1,0.1-0.4,0-0.3-0.1
c0.1-0.1,0.3-0.3,0.4-0.4c0.1-0.1,0.2-0.1,0.2-0.2c0,0,0.1-0.1,0.1-0.1C53.9,52.8,54.2,52.7,54.1,52.8 M53.2,52.4
C53.2,52.4,53.2,52.4,53.2,52.4c-0.2,0.2-0.4,0.3-0.6,0.4c-0.2,0.1-0.5,0.2-0.7,0.3c0,0,0,0,0,0c0,0-0.1,0-0.1,0
c-0.2,0.1-0.4,0.3-0.5,0.4c0,0,0,0-0.1,0.1c0,0,0,0,0,0c0,0,0,0,0,0c0,0-0.1,0.1-0.1,0.1c0,0,0,0,0,0c0,0,0,0,0,0
c0,0-0.1,0.1-0.1,0.1c0,0,0,0.1-0.1,0.1c0,0-0.1,0-0.1,0c0,0,0,0,0,0c0,0-0.1,0-0.1,0c0,0-0.1,0-0.1,0c0,0,0,0,0,0c0,0,0,0,0,0
c-0.1,0.1-0.1,0.1-0.2,0.2c-0.1,0.1-0.2,0.2-0.3,0.3c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0.1
l0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0-0.1,0-0.1l0,0c0,0,0,0,0,0c0.1-0.1,0.1-0.1,0.2-0.2c0,0,0,0,0,0c0,0,0,0,0.1-0.1
c0,0,0.1-0.1,0.1-0.1c0,0,0,0,0,0c0.1-0.1,0.1-0.2,0.2-0.2c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0.1-0.1,0.1-0.1c0,0,0-0.1,0.1-0.1
c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0-0.1,0.1-0.1c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0-0.1c0,0,0,0,0,0c0,0,0-0.1,0-0.1
c0,0,0,0,0,0c0.1-0.1,0.1-0.2,0.2-0.3c0,0,0,0,0,0c-0.1,0-0.1,0.1-0.2,0.2c-0.1,0-0.2,0-0.1-0.1c0,0,0.1-0.1,0.1-0.1c0,0,0,0,0,0
c0.1-0.1,0.2-0.2,0.3-0.3c0.1,0,0.1-0.1,0.2-0.1c0,0,0,0,0,0c0,0,0.1-0.1,0.1-0.1c0,0,0,0,0,0c0.5-0.5,1.4-0.5,2.1-0.8
c0.3-0.1,0.6,0.1,0.9,0c0.2,0,0.3,0,0.5,0.1C54,51.8,53.6,52.1,53.2,52.4 M54.3,48.7c-0.1-0.1,0.2,0,0.2-0.1H54
c-0.1,0-0.1-0.1-0.1-0.1c-0.3,0.1-0.6,0.2-0.9,0.2c-0.4,0.1-0.7,0.4-1.1,0.5c-0.6,0.2-1.1,0.7-1.7,0.9c-0.1,0-0.1-0.1-0.1-0.1
c0.1-0.2,0.3-0.2,0.4-0.4c0-0.1,0-0.1-0.1-0.1c0.4-0.6,1-0.9,1.6-1.4v-0.2c0.2-0.2,0.4-0.3,0.5-0.6c0.1-0.2,0.3-0.4,0.5-0.5
c-0.1-0.1-0.2-0.1-0.2-0.2c-0.2,0-0.4,0.1-0.6-0.1c0.1-0.1,0.2-0.2,0.3-0.2c0,0-0.1,0-0.1-0.1c-0.1-0.1,0.1-0.2,0.3-0.3
c0.2-0.1,0.5-0.1,0.6-0.2c-0.4-0.1-0.8,0.1-1.2-0.1c0.3-0.7,0.7-1.3,1.3-1.6c0.1,0,0.2,0,0.2,0.1c0,0.3-0.2,0.5-0.4,0.5
c0.4,0.1,0.9,0.1,1.3,0.3c-0.1,0.1-0.2,0.1-0.2,0.1c0.3,0.2,0.6,0.1,0.9,0.3c-0.2,0.2-0.3,0-0.5,0c1.7,0.5,3.4,0.9,4.8,1.9
c-1.2,0.6-2.4,0.9-3.7,1.1c-0.2,0-0.3,0-0.4-0.1c0,0.1,0,0.2-0.1,0.2c-0.2,0-0.4,0-0.5,0.1C54.7,48.8,54.4,48.8,54.3,48.7"
/>
<path
id="Bleu_1_"
class="st2"
d="M109.3,28.4H78.9c0,0,0.1,0,0.3,0.1c0.2,0.1,0.6,0.3,0.8,0.4c0.4,0.2,0.8,0.5,1,0.9
c0.1,0.2,0.3,0.5,0.2,0.7c-0.1,0.3-0.2,0.7-0.4,0.8c-0.3,0.2-0.7,0.2-1.1,0.1c-0.2,0-0.4-0.1-0.6-0.1c0.8,0.3,1.6,0.7,2.1,1.4
c0.1,0.1,0.3,0.2,0.5,0.2c0.1,0,0.1,0.1,0.1,0.2c-0.1,0.1-0.2,0.2-0.2,0.3h0.2c0.3-0.1,0.2-0.6,0.6-0.5c0.3,0.2,0.4,0.5,0.2,0.8
c-0.2,0.2-0.4,0.4-0.6,0.5c-0.1,0.1-0.1,0.3,0,0.4c0.2,0.2,0.2,0.4,0.3,0.6c0.2,0.4,0.2,0.8,0.4,1.2c0.2,0.8,0.4,1.6,0.4,2.4
c0,0.4-0.2,0.8-0.1,1.2c0.1,0.4,0.4,0.8,0.6,1.1c0.2,0.3,0.4,0.5,0.6,0.9c0.3,0.5,0.9,1.1,0.6,1.7c-0.2,0.4-0.8,0.3-1.1,0.5
c-0.3,0.3-0.1,0.7,0.1,1c0.3,0.5-0.3,0.8-0.7,1c0.1,0.2,0.3,0.1,0.4,0.2c0.1,0.3,0.3,0.4,0.2,0.7c-0.2,0.3-0.9,0.5-0.5,1
c0.2,0.4,0.1,0.8-0.1,1.2c-0.2,0.5-0.6,0.7-1,0.8c-0.3,0.1-0.7,0.1-1,0.1c-0.1-0.1-0.2-0.1-0.3-0.1c-0.9-0.1-1.8-0.4-2.7-0.4
c-0.3,0.1-0.5,0.1-0.7,0.2c-0.2,0.2-0.5,0.4-0.6,0.6c0,0,0,0,0,0c0,0-0.1,0.1-0.1,0.1c0,0,0,0.1-0.1,0.1c0,0,0,0,0,0.1
c-0.2,0.2-0.3,0.4-0.4,0.6c0,0,0,0,0,0c0,0,0,0.1,0,0.1c-0.2,0.3-0.3,0.7-0.4,1c-0.4,1.2-0.2,2.3,0.1,2.5c0.1,0.1,1.8,0.6,2.9,1.1
c0.6,0.2,0.9,0.4,1.3,0.6h29.9V28.4z"
/>
<path
id="Yeux_1_"
class="st3"
d="M80.7,38.7c0.2,0.1,0.5,0.1,0.5,0.2c-0.1,0.4-0.7,0.5-1.1,1H80c-0.2,0.1-0.1,0.4-0.3,0.4
c-0.2-0.1-0.3,0-0.5,0.1c0.2,0.2,0.5,0.4,0.8,0.3c0.1,0,0.2,0.1,0.2,0.2c0,0,0.1,0,0.1-0.1c0.1,0,0.1,0,0.1,0.1V41
c-0.2,0.2-0.4,0.1-0.6,0.2c0.4,0.1,0.9,0.1,1.2,0c0.3-0.1,0-0.6,0.2-0.9c-0.1,0,0-0.2-0.1-0.2c0.1-0.1,0.2-0.3,0.3-0.3
c0.1,0,0.3-0.1,0.3-0.2c0-0.1-0.2-0.2-0.2-0.3c0.3-0.2,0.6-0.5,0.5-0.9c-0.1-0.2-0.5-0.2-0.8-0.3c-0.3-0.1-0.6,0-0.9,0.1
c-0.3,0-0.5,0.2-0.8,0.2c-0.4,0.1-0.7,0.3-1,0.5c0.4-0.2,0.8-0.2,1.2-0.3C80.1,38.7,80.3,38.6,80.7,38.7"
/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 279 KiB

After

Width:  |  Height:  |  Size: 25 KiB

@@ -11,7 +11,7 @@ describe('Page', () => {
expect(
screen.getByRole('button', {
name: /Create a new document/i,
name: /Create a new pad/i,
}),
).toBeInTheDocument();
});
@@ -9,15 +9,15 @@ describe('fetchAPI', () => {
});
it('adds correctly the basename', () => {
fetchMock.mock('http://test.jest/api/v1.0/some/url', 200);
fetchMock.mock('http://test.jest/api/some/url', 200);
void fetchAPI('some/url');
expect(fetchMock.lastUrl()).toEqual('http://test.jest/api/v1.0/some/url');
expect(fetchMock.lastUrl()).toEqual('http://test.jest/api/some/url');
});
it('adds the credentials automatically', () => {
fetchMock.mock('http://test.jest/api/v1.0/some/url', 200);
fetchMock.mock('http://test.jest/api/some/url', 200);
void fetchAPI('some/url', { body: 'some body' });
@@ -36,18 +36,10 @@ describe('fetchAPI', () => {
.spyOn(useAuthStore.getState(), 'logout')
.mockImplementation(logoutMock);
fetchMock.mock('http://test.jest/api/v1.0/some/url', 401);
fetchMock.mock('http://test.jest/api/some/url', 401);
await fetchAPI('some/url');
expect(logoutMock).toHaveBeenCalled();
});
it('check the versionning', () => {
fetchMock.mock('http://test.jest/api/v2.0/some/url', 200);
void fetchAPI('some/url', {}, '2.0');
expect(fetchMock.lastUrl()).toEqual('http://test.jest/api/v2.0/some/url');
});
});
@@ -13,12 +13,10 @@ function getCSRFToken() {
.pop();
}
export const fetchAPI = async (
input: string,
init?: RequestInit,
apiVersion = '1.0',
) => {
const apiUrl = `${baseApiUrl(apiVersion)}${input}`;
export const fetchAPI = async (input: string, init?: RequestInit) => {
const apiUrl = `${baseApiUrl()}${input}`;
const { logout } = useAuthStore.getState();
const csrfToken = getCSRFToken();
const response = await fetch(apiUrl, {
@@ -32,7 +30,6 @@ export const fetchAPI = async (
});
if (response.status === 401) {
const { logout } = useAuthStore.getState();
logout();
}
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB

@@ -10,6 +10,7 @@ export const IconOptions = ({ isOpen, ...props }: IconOptionsProps) => {
<Text
aria-label={props['aria-label']}
className="material-icons"
$theme="primary"
$css={`
transition: all 0.3s ease-in-out;
transform: rotate(${isOpen ? '90' : '0'}deg);
@@ -1,42 +0,0 @@
import Image from 'next/image';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { default as IconDevise } from '@/assets/icons/icon-devise.svg?url';
import { default as IconMarianne } from '@/assets/icons/icon-marianne.svg?url';
import { Box } from './Box';
import { Text, TextType } from './Text';
interface LogoGouvProps {
imagesWidth?: number;
textProps?: TextType;
}
const LogoGouv = ({ imagesWidth, textProps }: LogoGouvProps) => {
const { t } = useTranslation();
return (
<Box>
<Box>
<Image
priority
src={IconMarianne}
alt={t('Marianne Logo')}
width={imagesWidth}
/>
</Box>
<Text $weight="bold" $size="1.3rem" {...textProps}>
Gouvernement
</Text>
<Image
width={imagesWidth}
priority
src={IconDevise}
alt={t('Freedom Equality Fraternity Logo')}
/>
</Box>
);
};
export default LogoGouv;
@@ -1,5 +1,3 @@
import { Alert, VariantType } from '@openfun/cunningham-react';
import { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Text, TextType } from '@/components';
@@ -7,38 +5,40 @@ import { Box, Text, TextType } from '@/components';
interface TextErrorsProps extends TextType {
causes?: string[];
defaultMessage?: string;
icon?: ReactNode;
}
export const TextErrors = ({
causes,
defaultMessage,
icon,
...textProps
}: TextErrorsProps) => {
const { t } = useTranslation();
return (
<Alert canClose={false} type={VariantType.ERROR} icon={icon}>
<Box $direction="column" $gap="0.2rem">
{causes &&
causes.map((cause, i) => (
<Text
key={`causes-${i}`}
$theme="danger"
$textAlign="center"
{...textProps}
>
{cause}
</Text>
))}
{!causes && (
<Text $theme="danger" $textAlign="center" {...textProps}>
{defaultMessage || t('Something bad happens, please retry.')}
<Box>
{causes &&
causes.map((cause, i) => (
<Text
key={`causes-${i}`}
$margin={{ top: 'small' }}
$theme="danger"
$textAlign="center"
{...textProps}
>
{cause}
</Text>
)}
</Box>
</Alert>
))}
{!causes && (
<Text
$margin={{ top: 'small' }}
$theme="danger"
$textAlign="center"
{...textProps}
>
{defaultMessage || t('Something bad happens, please retry.')}
</Text>
)}
</Box>
);
};
@@ -1,17 +1,35 @@
import { Loader } from '@openfun/cunningham-react';
import { PropsWithChildren, useEffect } from 'react';
import { usePathname, useRouter } from 'next/navigation';
import { PropsWithChildren, useEffect, useState } from 'react';
import { Box } from '@/components';
import { useAuthStore } from './useAuthStore';
export const Auth = ({ children }: PropsWithChildren) => {
const { authenticated, initAuth } = useAuthStore();
const router = useRouter();
const [isDomainComingSoon, setIsDomainComingSoon] = useState(false);
const domainComingSoon = 'docs.numerique.gouv.fr';
const pathComingSoon = '/coming-soon/';
const pathname = usePathname();
useEffect(() => {
initAuth();
}, [initAuth]);
if (window.location.origin.includes(domainComingSoon)) {
router.push(pathComingSoon);
return;
}
if (!authenticated) {
initAuth();
}, [initAuth, router]);
useEffect(() => {
setIsDomainComingSoon(
window.location.origin.includes(domainComingSoon) &&
pathname === pathComingSoon,
);
}, [pathname]);
if (!authenticated && !isDomainComingSoon) {
return (
<Box $height="100vh" $width="100vw" $align="center" $justify="center">
<Loader />
@@ -8,4 +8,5 @@
export interface User {
id: string;
email: string;
name?: string;
}
+5 -9
View File
@@ -1,15 +1,11 @@
export const baseApiUrl = (apiVersion: string = '1.0') => {
export const baseApiUrl = () => {
const origin =
process.env.NEXT_PUBLIC_API_ORIGIN ||
(typeof window !== 'undefined' ? window.location.origin : '');
return `${origin}/api/v${apiVersion}/`;
return `${origin}${process.env.NEXT_PUBLIC_API_URL}`;
};
export const signalingUrl = (docId: string) => {
const base =
process.env.NEXT_PUBLIC_SIGNALING_URL ||
(typeof window !== 'undefined' ? `wss://${window.location.host}/ws` : '');
return `${base}/${docId}`;
};
export const signalingUrl = () =>
process.env.NEXT_PUBLIC_SIGNALING_URL ||
(typeof window !== 'undefined' ? `wss://${window.location.host}/ws` : '');
@@ -233,7 +233,7 @@ input:-webkit-autofill:focus {
}
.c__datagrid > .c__pagination {
padding-inline: 1rem;
padding-right: 1rem;
justify-content: flex-end;
}
@@ -464,14 +464,3 @@ input:-webkit-autofill:focus {
.c__modal__close .c__button--tertiary-text:focus-visible {
box-shadow: none;
}
.c__modal__close button {
padding: 1.5rem 1rem;
}
/**
* Toast
*/
.c__toast__container {
z-index: 10000;
}
+1 -1
View File
@@ -20,7 +20,7 @@ declare module '*.svg?url' {
namespace NodeJS {
interface ProcessEnv {
NEXT_PUBLIC_API_ORIGIN?: string;
NEXT_PUBLIC_API_URL?: string;
NEXT_PUBLIC_SIGNALING_URL?: string;
NEXT_PUBLIC_SW_DEACTIVATED?: string;
}
}

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