Compare commits

..

3 Commits

Author SHA1 Message Date
Anthony LC
8185badb94 💄(frontend) change color of the app for the demo
Change the color of the app to showcase the
deployment pipeline.
2024-05-24 17:57:02 +02:00
Jacques ROUSSEL
8a8e8ba7d1 💚(CI) improve changelog ci
Disable changelog when the label is not present
2024-05-24 16:41:52 +02:00
Jacques ROUSSEL
5ca762894f 👷(helm) preprod configuration
This PR adds the preprod configuration
for the helm chart.
2024-05-24 16:41:52 +02:00
396 changed files with 11160 additions and 22068 deletions

View File

@@ -12,24 +12,13 @@ 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: 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

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'
@@ -104,35 +82,24 @@ jobs:
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-and-push-y-provider:
build-and-push-y-webrtc-signaling:
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-provider
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'
@@ -143,7 +110,7 @@ jobs:
with:
context: .
file: ./src/frontend/Dockerfile
target: y-provider
target: y-webrtc-signaling
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
@@ -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

View File

@@ -1,12 +1,14 @@
name: Frontend Workflow
name: impress Workflow
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,38 +92,31 @@ 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
test-e2e-chromium:
test-e2e:
runs-on: ubuntu-latest
needs: build-front
timeout-minutes: 20
timeout-minutes: 15
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,29 +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-provider
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: Start Nginx for the frontend
run: |
docker compose up --force-recreate -d nginx-front
- name: Apply DRF migrations
run: |
make migrate
@@ -162,92 +143,15 @@ jobs:
make demo FLUSH_ARGS='--no-input'
- name: Install Playwright Browsers
run: cd src/frontend/apps/e2e && yarn install-playwright chromium
run: cd src/frontend/apps/e2e && yarn install
- name: Run e2e tests
run: cd src/frontend/ && yarn e2e:test --project='chromium'
run: cd src/frontend/ && yarn e2e:test
- uses: actions/upload-artifact@v3
if: always()
with:
name: playwright-chromium-report
name: playwright-report
path: src/frontend/apps/e2e/report/
retention-days: 7
test-e2e-other-browser:
runs-on: ubuntu-latest
needs: build-front
timeout-minutes: 20
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set services env variables
run: |
make data/media
make create-env-files
cat env.d/development/common.e2e.dist >> env.d/development/common
- name: Restore the mail templates
uses: actions/cache@v4
id: mail-templates
with:
path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
- name: Restore the frontend cache
uses: actions/cache@v4
id: front-node_modules
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
- name: Restore the build cache
uses: actions/cache@v4
id: cache-build
with:
path: src/frontend/apps/impress/out/
key: build-front-${{ github.run_id }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build the Docker images
uses: docker/bake-action@v4
with:
targets: |
app-dev
y-provider
load: true
set: |
*.cache-from=type=gha,scope=cached-stage
*.cache-to=type=gha,scope=cached-stage,mode=max
- name: Start Docker services
run: |
make run
- name: Start Nginx for the frontend
run: |
docker compose up --force-recreate -d nginx-front
- name: Apply DRF migrations
run: |
make migrate
- name: Add dummy data
run: |
make demo FLUSH_ARGS='--no-input'
- name: Install Playwright Browsers
run: cd src/frontend/apps/e2e && yarn install-playwright firefox webkit chromium
- name: Run e2e tests
run: cd src/frontend/ && yarn e2e:test --project=firefox --project=webkit
- uses: actions/upload-artifact@v3
if: always()
with:
name: playwright-other-report
path: src/frontend/apps/e2e/report/
retention-days: 7

View File

@@ -1,12 +1,14 @@
name: Main Workflow
name: impress Workflow
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
@@ -37,11 +39,9 @@ jobs:
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,38 +63,22 @@ 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
- name: Persist mails' templates
uses: actions/upload-artifact@v4
with:
path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
name: mails-templates
path: src/backend/core/templates/mail
lint-back:
runs-on: ubuntu-latest
@@ -107,15 +91,16 @@ 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
@@ -161,12 +146,11 @@ jobs:
sudo mkdir -p /data/media && \
sudo mkdir -p /data/static
- name: Restore the mail templates
uses: actions/cache@v4
id: mail-templates
- name: Download mails' templates
uses: actions/download-artifact@v4
with:
path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
name: mails-templates
path: src/backend/core/templates/mail
- name: Start Minio
run: |
@@ -190,7 +174,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]
@@ -198,10 +182,64 @@ jobs:
- name: Install gettext (required to compile messages)
run: |
sudo apt-get update
sudo apt-get install -y gettext pandoc
sudo apt-get install -y gettext
- name: Generate a MO file from strings extracted from the project
run: python manage.py compilemessages
- name: Run tests
run: ~/.local/bin/pytest -n 2
i18n-crowdin:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v2
- name: Install gettext (required to make messages)
run: |
sudo apt-get update
sudo apt-get install -y gettext
- name: Install Python
uses: actions/setup-python@v3
with:
python-version: '3.10'
- name: Install development dependencies
working-directory: src/backend
run: pip install --user .[dev]
- 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'
cache-dependency-path: src/frontend/yarn.lock
- name: Install dependencies
run: cd src/frontend/ && yarn install --frozen-lockfile
- name: Extract the frontend translation
run: make frontend-i18n-extract
- name: Upload files to Crowdin
run: |
docker run \
--rm \
-e CROWDIN_API_TOKEN=$CROWDIN_API_TOKEN \
-e CROWDIN_PROJECT_ID=$CROWDIN_PROJECT_ID \
-e CROWDIN_BASE_PATH=$CROWDIN_BASE_PATH \
-v "${{ github.workspace }}:/app" \
crowdin/cli:3.16.0 \
crowdin upload sources -c /app/crowdin/config.yml

24
.github/workflows/secrets.enc.env vendored Normal file
View File

@@ -0,0 +1,24 @@
SOPS_PRIVATE=ENC[AES256_GCM,data:FK3PweZstvwslF18oRQNnqY2vTAdNNBWiTxRpuULnRnJbtyeula/MU5E08pImMGDvMXZulOgbmuXUHrKb31P6HG2Cz5MBFGhqU8=,iv:gYCDkAtBe1ldjSjVV/jDFYJTceqODpDRr4TRE9pxgb4=,tag:U7B3L4+SOoxVLBGW3GtrDg==,type:str]
CROWDIN_API_TOKEN=ENC[AES256_GCM,data:r0niJ4YBSb+s2Fg9EXkqgegw8JeQIwu27pfDTndjhbcVZW0/tihn5IZjercX3k8TpOuzPYei8k0JtmnjfBMi9NY3pYr80YCWDzUGqUKubyw=,iv:fF7SzhfsoiF53xdMm8BdPy668nYWBTA4r2aIfhUAd1Q=,tag:HskvnLyy5QTQnDv99Jmr1g==,type:str]
CROWDIN_BASE_PATH=ENC[AES256_GCM,data:jC8utvhuMmQ=,iv:VmHB9DX52YnGGWZEm1hD+zeUffypsAhwQQpox4t5png=,tag:cbQ24lWq7g33fJduMgmvuA==,type:str]
CROWDIN_PROJECT_ID=ENC[AES256_GCM,data:xz8mo2fB,iv:FcsLzOVUxxhcibXiIubIhtbdjCUXiIQpuGdBdNpSE8I=,tag:CNKUYvSlok0WFyFaKXR5QA==,type:str]
DOCKER_HUB_PASSWORD=ENC[AES256_GCM,data:R9ktuIb579tbe+M=,iv:nmn3wlOc88VL4kGyKLRIRIuVqUu8BuWKtHUjjex+zRg=,tag:fGNtJmMB2iHVGMeLBz5RwQ==,type:str]
DOCKER_HUB_USER=ENC[AES256_GCM,data:LJzr2mftjw==,iv:iwFvXHttIyydyNU11ZZH97oBp/DwTn5hlLQl7CqRWa0=,tag:qntAkpeNG/wOZim5K/8w7A==,type:str]
ARGOCD_WEBHOOK_URL=ENC[AES256_GCM,data:+dzTPg4mVqDLu6ac9xf2D4eccaKIvAosBBXpwp+QHZwTEeWGNm0GRaVzOx0gU4CjBNU9og0buYdi,iv:mhgVc5dBh1A1TVisGe0c/MO4EnXSb0ZQ2NL85QJzwaI=,tag:cT6Sa/GRJ94ss7yiL9pH2g==,type:str]
ARGOCD_WEBHOOK_SECRET=ENC[AES256_GCM,data:meQqbpT5gx5K4fW/WWmIQ9vlHjrQsVfGbdiVWm8YZf6EIm9xHWmTcflYxBqfvgWWen84NKWqt0uzl3+m1eDnLyE=,iv:wyIp0baJsw9jFu4z09xirr6qSpxK8aO907SEvce98/U=,tag:FaW5+x7r+fj3R9yq8ataTw==,type:str]
ARGOCD_PRODUCTION_WEBHOOK_URL=ENC[AES256_GCM,data:9xN9mA1JSw0L2wYxpVfG3uYiLPGo+OuziZTQ8PAMy3Cd+AmDWXcT0AInbhBMQsw5Og==,iv:8mW3YYhXmP9EqA25jwevIT4ccUxfgJU/B17XBasl6Dk=,tag:EMDk1YQj6eEinoBSgRo+7A==,type:str]
ARGOCD_PRODUCTION_WEBHOOK_SECRET=ENC[AES256_GCM,data:Y3pRbqpxtZOJi4VfRRx8WIZKJQuSaVePG0b1kmZ2UxWhfumFsvll91blpZQQIWp42AEgJhUfFz7lgGXtNZc=,iv:GBG4AYYEo50H+GC6Auzdabsj9XGMKStKp6bfqy0iWkE=,tag:qpjnB/K3Glq/Dziav6OXqg==,type:str]
sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAxMkZsNEovb2xpWjIrdUpG\nUzArWFlLejB1UTBDTHNJOENybzdRSHBkVVJzCmdWeW1VYUtxejBaWkhvMjEySFNm\nWmlJZWVVMVA2azJhUlBXZ0VrbnNsRGsKLS0tIHhTU0hFSmVnWW9GZE1UVGZMUDVw\ndE1RdCs2OEh1U2Q1WjFkYVNDOEVYQjgKxHI1W+DT2yMW1+0QUNDVdbeo6IvRVEig\nK1WrTM1VAmsji9xuvJQW9uKvYxmHo7OFZzkkNTbmLcJ4wBSNYilh+A==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_0__map_recipient=age15fyxdwmg5mvldtqqus87xspuws2u0cpvwheehrtvkexj4tnsqqysw6re2x
sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB3OG05S01xK2J5aklEMitF\nNEtYbSthTVJHMk1oNmxkbjBvUkI0a21heXlrCkNPNjh1ektYYXJNVzVBMWxWKzB6\neHd0blE3U1pQdnpXbVkzZGVOdnh4aFEKLS0tIGUwSmdoZWxwNTdiWDdER3ZNU2lV\nZklBdHVERVkzcHZaZWdoM3pLMHBzSDgKTL1ipaUAFXOtGSu1g+pkfr+W3NlJJXcy\nl/yzxbLzPv2MSR09ZUFS6Km97/aTQDkCodt29paHEvRUDhR+oYCDVg==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_1__map_recipient=age16hnlml8yv4ynwy0seer57g8qww075crd0g7nsundz3pj4wk7m3vqftszg7
sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSByUHRTUkpaaFhZUm1tUFRU\nNU5sZkozcHowTUdoejV5ditibHc1T2V6M3lNCit3OS9TeUx5UTZOTFVibjRaaGR3\nNlQ3WlhKZUNzaUJHNWVLajNnZ2U2RnMKLS0tIG9qdVNFVE5jOHAvSWcvcnVla0hn\nMlg1YTg2b2MreE16Qy85R09pa3ZxbEEKoPB1pOmc5FmSKIwQ017l05Lm+LoNH2KC\ndxSUkmw7n1tVkPKGtgbEcoR04mMm+4ANdXNetu3Goih1bvtjgWvUuQ==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_2__map_recipient=age1plkp8td6zzfcavjusmsfrlk54t9vn8jjxm8zaz7cmnr7kzl2nfnsd54hwg
sops_age__list_3__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBjaDVPTVBFVzVxU3JPc0RM\ncTFlSUVzUXpKKzFyTmQweGNITVZFNUlheENjCkxtOU5QTGRMRmVRZ2hrQkY5SXM3\nTmZNU0NGc3VSZ2xOZlRIaTBXOSt2TXcKLS0tIEQ0bVhYSml0eXFLS2lCOFMxWGpS\nWE1tRTFDektsRWVYSHp6eTF4MVJQU3MKfskxXtc6JI86/xdjMRsVTmG0x+jLx/tq\necUbexvI56TOVFThd1Iv2QYnfD48OVstpH1QEpM42XQTRLsrj07gPA==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_3__map_recipient=age12g6f5fse25tgrwweleh4jls3qs52hey2edh759smulwmk5lnzadslu2cp3
sops_age__list_4__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB1aXh5eTVZR21TNlBIbmxO\nR0FPNXlyNklucFNwbng5eStmMlNCNi9VYTJrCkZsejJqNmtxRmJlekN2czg3ZUls\nVTdKVWd2eWtpQUdBbGUzYWR4bXYwVW8KLS0tIEJnS2hDQU5CM2NVc3RsQjlZL1FE\nVGYyYWJ6K2gydVFCbUhYeWNDN2RiWjAKHD7/sZFiGD3+Xz5O/Yajb/gEVREWQB/l\nAsquVroBF4A89QUgbjZSYsHJcWuZ4JZXBX7fGSZwio+8+nhjvy+EhQ==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_4__map_recipient=age1qy04neuzwpasmvljqrcvhwnf0kz5cpyteze38c8avp0czewskasszv9pyw
sops_lastmodified=2024-05-24T13:55:45Z
sops_mac=ENC[AES256_GCM,data:gJViDK19UzUaOT+3b9cUJ+634dgzSkamqcj4031pyhrjCVb7FtRu2B8T7vpZObY3dB3mSCtfJKzKoJRhCjYDTd8YdASIOJyep+6K4JSWvKtliZ46syDQaSSTgPx7WaeLzVRpEpBq0adt6ngKTttbhIvhYZD7Kc3Tz3TcMCmEQhg=,iv:G9tzca7nZrBCNowEYpUkAiraVGxUv2732xwXCizJ8X0=,tag:yYt3ppmVYR+lba//lRNpdg==,type:str]
sops_unencrypted_suffix=_unencrypted
sops_version=3.8.1

4
.gitignore vendored
View File

@@ -33,6 +33,7 @@ MANIFEST
*.pot
# Environments
.env
.venv
env/
venv/
@@ -49,6 +50,9 @@ node_modules
# Mails
src/backend/core/templates/mail/
# Typescript client
src/frontend/tsclient
# Swagger
**/swagger.json

3
.gitmodules vendored
View File

@@ -1,3 +0,0 @@
[submodule "secrets"]
path = secrets
url = ../secrets

View File

@@ -1,10 +1,13 @@
creation_rules:
- path_regex: ./*
key_groups:
- age:
- age15fyxdwmg5mvldtqqus87xspuws2u0cpvwheehrtvkexj4tnsqqysw6re2x # jacques
- age16hnlml8yv4ynwy0seer57g8qww075crd0g7nsundz3pj4wk7m3vqftszg7 # github-repo
- age1plkp8td6zzfcavjusmsfrlk54t9vn8jjxm8zaz7cmnr7kzl2nfnsd54hwg # Anthony Le-Courric
- age12g6f5fse25tgrwweleh4jls3qs52hey2edh759smulwmk5lnzadslu2cp3 # Antoine Lebaud
- age1hnhuzj96ktkhpyygvmz0x9h8mfvssz7ss6emmukags644mdhf4msajk93r # Samuel Paccoud
# 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

View File

@@ -6,145 +6,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0),
and this project adheres to
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## Added
- ✨(frontend) add copy link button #235
- 🛂(frontend) access public docs without being logged #235
## Changed
- 🚚(frontend) change visibility in share modal #235
## [1.3.0] - 2024-09-05
## Added
- ✨Add image attachments with access control
- ✨(frontend) Upload image to a document #211
- ✨(frontend) Summary #223
- ✨(frontend) update meta title for docs page #231
## Changed
- 💄(frontend) code background darkened on editor #214
- 🔥(frontend) hide markdown button if not text #213
## Fixed
- 🐛 Fix emoticon in pdf export #225
- 🐛 Fix collaboration on document #226
- 🐛 (docker) Fix compatibility with mac #230
## Removed
- 🔥(frontend) remove saving modal #213
## [1.2.1] - 2024-08-23
## Changed
- ♻️ Change ordering docs datagrid #195
- 🔥(helm) use scaleway email #194
## [1.2.0] - 2024-08-22
## Added
- 🎨(frontend) better conversion editor to pdf #151
- ✨Export docx (word) #161
- 🌐Internationalize invitation email #167
- ✨(frontend) White branding #164
- ✨Email invitation when add user to doc #171
- ✨Invitation management #174
## Fixed
- 🐛(y-webrtc) fix prob connection #147
- ⚡️(frontend) improve select share stability #159
- 🐛(backend) enable SSL when sending email #165
## Changed
- 🎨(frontend) stop limit layout height to screen size #158
- ⚡️(CI) only e2e chrome mandatory #177
## Removed
- 🔥(helm) remove htaccess #181
## [1.1.0] - 2024-07-15
## Added
- 🤡(demo) generate dummy documents on dev users #120
- ✨(frontend) create side modal component #134
- ✨(frontend) Doc grid actions (update / delete) #136
- ✨(frontend) Doc editor header information #137
## Changed
- ♻️(frontend) replace docs panel with docs grid #120
- ♻️(frontend) create a doc from a modal #132
- ♻️(frontend) manage members from the share modal #140
## [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.
- Coming Soon page (#67)
- Impress, project to manage your documents easily and collaboratively.
[unreleased]: https://github.com/numerique-gouv/impress/compare/v1.3.0...main
[1.3.0]: https://github.com/numerique-gouv/impress/releases/v1.3.0
[1.2.1]: https://github.com/numerique-gouv/impress/releases/v1.2.1
[1.2.0]: https://github.com/numerique-gouv/impress/releases/v1.2.0
[1.1.0]: https://github.com/numerique-gouv/impress/releases/v1.1.0
[1.0.0]: https://github.com/numerique-gouv/impress/releases/v1.0.0
[unreleased]: https://github.com/numerique-gouv/impress/compare/v0.1.0...main
[0.1.0]: https://github.com/numerique-gouv/impress/releases/v0.1.0

View File

@@ -75,8 +75,6 @@ RUN apt-get update && \
libgdk-pixbuf2.0-0 \
libpango-1.0-0 \
libpangocairo-1.0-0 \
pandoc \
fonts-noto-color-emoji \
shared-mime-info && \
rm -rf /var/lib/apt/lists/*

View File

@@ -49,6 +49,7 @@ WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT
# -- Backend
MANAGE = $(COMPOSE_RUN_APP) python manage.py
MAIL_YARN = $(COMPOSE_RUN) -w /app/src/mail node yarn
TSCLIENT_YARN = $(COMPOSE_RUN) -w /app/src/tsclient node yarn
# -- Frontend
PATH_FRONT = ./src/frontend
@@ -81,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
@@ -103,8 +105,11 @@ 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 y-provider
@$(COMPOSE) up --force-recreate -d keycloak
@$(COMPOSE) up --force-recreate -d y-webrtc-signaling
@echo "Wait for postgresql to be up..."
@$(WAIT_DB)
.PHONY: run
@@ -185,7 +190,7 @@ back-i18n-compile: ## compile the gettext files
.PHONY: back-i18n-compile
back-i18n-generate: ## create the .pot files used for i18n
@$(MANAGE) makemessages -a --keep-pot --all
@$(MANAGE) makemessages -a --keep-pot
.PHONY: back-i18n-generate
shell: ## connect to database shell
@@ -274,6 +279,16 @@ mails-install: ## install the mail generator
@$(MAIL_YARN) install
.PHONY: mails-install
# -- TS client generator
tsclient-install: ## Install the Typescript API client generator
@$(TSCLIENT_YARN) install
.PHONY: tsclient-install
tsclient: tsclient-install ## Generate a Typescript API client
@$(TSCLIENT_YARN) generate:api:client:local ../frontend/tsclient
.PHONY: tsclient-install
# -- Misc
clean: ## restore repository state as it was freshly cloned
git clean -idx
@@ -286,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
@@ -313,13 +332,3 @@ start-tilt: ## start the kubernetes cluster using kind
tilt up -f ./bin/Tiltfile
.PHONY: build-k8s-cluster
VERSION_TYPE ?= minor
bump-packages-version: ## bump the version of the project - VERSION_TYPE can be "major", "minor", "patch"
cd ./src/mail && yarn version --no-git-tag-version --$(VERSION_TYPE)
cd ./src/frontend/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
cd ./src/frontend/apps/e2e/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
cd ./src/frontend/apps/impress/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
cd ./src/frontend/servers/y-provider/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
cd ./src/frontend/packages/eslint-config-impress/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
cd ./src/frontend/packages/i18n/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
.PHONY: bump-packages-version

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

View File

@@ -18,13 +18,13 @@ docker_build(
)
docker_build(
'localhost:5001/impress-y-provider:latest',
'localhost:5001/impress-y-webrtc-signaling:latest',
context='..',
dockerfile='../src/frontend/Dockerfile',
only=['./src/frontend/', './docker/', './.dockerignore'],
target = 'y-provider',
only=['./src/frontend/', './docker/', './dockerignore'],
target = 'y-webrtc-signaling',
live_update=[
sync('../src/frontend/servers/y-provider/src', '/home/frontend/servers/y-provider/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'),

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

2
bin/start-kind.sh Executable file → Normal file
View File

@@ -16,7 +16,7 @@ reg_name='kind-registry'
reg_port='5001'
if [ "$(docker inspect -f '{{.State.Running}}' "${reg_name}" 2>/dev/null || true)" != 'true' ]; then
docker run \
-d --restart=unless-stopped -p "127.0.0.1:${reg_port}:5000" --network bridge --name "${reg_name}" \
-d --restart=always -p "127.0.0.1:${reg_port}:5000" --network bridge --name "${reg_name}" \
registry:2
fi

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'

View File

@@ -65,7 +65,6 @@ services:
- mailcatcher
- redis
- createbuckets
- nginx
celery-dev:
user: ${DOCKER_USER:-1000}
@@ -119,16 +118,9 @@ services:
volumes:
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
depends_on:
- app
- keycloak
nginx-front:
image: nginx:1.25
ports:
- "3000:3000"
volumes:
- ./src/frontend/apps/impress/conf/default.conf:/etc/nginx/conf.d/default.conf
- ./src/frontend/apps/impress/out:/usr/share/nginx/html
dockerize:
image: jwilder/dockerize
@@ -149,37 +141,24 @@ services:
volumes:
- ".:/app"
y-provider:
y-webrtc-signaling:
user: ${DOCKER_USER:-1000}
build:
context: .
dockerfile: ./src/frontend/Dockerfile
target: y-provider
target: y-webrtc-signaling
restart: unless-stopped
ports:
- "4444:4444"
volumes:
- ./src/frontend/servers/y-provider:/home/frontend/servers/y-provider
- /home/frontend/servers/y-provider/node_modules/
- /home/frontend/servers/y-provider/dist/
- ./src/frontend/apps/y-webrtc-signaling:/home/frontend/apps/y-webrtc-signaling
- /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-provider
- celery-dev
kc_postgresql:
image: postgres:14.3
platform: linux/amd64
ports:
- "5433:5432"
env_file:

View File

@@ -1339,21 +1339,6 @@
"jsonType.label": "String"
}
},
{
"id": "qb109597-e31e-46d7-7844-62e5fcf32ac8",
"name": "email sub",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-property-mapper",
"consentRequired": false,
"config": {
"userinfo.token.claim": "true",
"user.attribute": "email",
"id.token.claim": "true",
"access.token.claim": "true",
"claim.name": "sub",
"jsonType.label": "String"
}
},
{
"id": "61c135e5-2447-494b-bc70-9612f383be27",
"name": "email verified",

View File

@@ -4,36 +4,6 @@ server {
server_name localhost;
charset utf-8;
location /media/ {
# Auth request configuration
auth_request /auth;
auth_request_set $authHeader $upstream_http_authorization;
auth_request_set $authDate $upstream_http_x_amz_date;
auth_request_set $authContentSha256 $upstream_http_x_amz_content_sha256;
# Pass specific headers from the auth response
proxy_set_header Authorization $authHeader;
proxy_set_header X-Amz-Date $authDate;
proxy_set_header X-Amz-Content-SHA256 $authContentSha256;
# Get resource from Minio
proxy_pass http://minio:9000/impress-media-storage/;
proxy_set_header Host minio:9000;
}
location /auth {
proxy_pass http://app-dev:8000/api/v1.0/documents/retrieve-auth/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Original-URL $request_uri;
# Prevent the body from being passed
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Original-Method $request_method;
}
location / {
proxy_pass http://keycloak:8080;
proxy_set_header Host $host;

View File

@@ -1,72 +0,0 @@
# Releasing a new version
Whenever we are cooking a new release (e.g. `4.18.1`) we should follow a standard procedure described below:
1. Create a new branch named: `release/4.18.1`.
2. Bump the release number for backend project, frontend projects, and Helm files:
- for backend, update the version number by hand in `pyproject.toml`,
- for each projects (`src/frontend`, `src/frontend/apps/*`, `src/frontend/packages/*`, `src/mail`), run `yarn version --new-version --no-git-tag-version 4.18.1` in their directory. This will update their `package.json` for you,
- for Helm, update Docker image tag in files located at `src/helm/env.d` for both `preprod` and `production` environments:
```yaml
image:
repository: lasuite/impress-backend
pullPolicy: Always
tag: "v4.18.1" # Replace with your new version number, without forgetting the "v" prefix
...
frontend:
image:
repository: lasuite/impress-frontend
pullPolicy: Always
tag: "v4.18.1"
y-provider:
image:
repository: lasuite/impress-y-provider
pullPolicy: Always
tag: "v4.18.1"
```
The new images don't exist _yet_: they will be created automatically later in the process.
3. Update the project's `Changelog` following the [keepachangelog](https://keepachangelog.com/en/0.3.0/) recommendations
4. Commit your changes with the following format: the 🔖 release emoji, the type of release (patch/minor/patch) and the release version:
```text
🔖(minor) bump release to 4.18.0
```
5. Open a pull request, wait for an approval from your peers and merge it.
6. Checkout and pull changes from the `main` branch to ensure you have the latest updates.
7. Tag and push your commit:
```bash
git tag v4.18.1 && git push origin tag v4.18.1
```
Doing this triggers the CI and tells it to build the new Docker image versions that you targeted earlier in the Helm files.
8. Ensure the new [backend](https://hub.docker.com/r/lasuite/impress-frontend/tags) and [frontend](https://hub.docker.com/r/lasuite/impress-frontend/tags) image tags are on Docker Hub.
9. The release is now done!
# Deploying
> [!TIP]
> The `staging` platform is deployed automatically with every update of the `main` branch.
Making a new release doesn't publish it automatically in production.
Deployment is done by ArgoCD. ArgoCD checks for the `production` tag and automatically deploys the production platform with the targeted commit.
To publish, we mark the commit we want with the `production` tag. ArgoCD is then notified that the tag has changed. It then deploys the Docker image tags specified in the Helm files of the targeted commit.
To publish the release you just made:
```bash
git tag --force production v4.18.1
git push --force origin production
```

25
docs/tsclient.md Normal file
View File

@@ -0,0 +1,25 @@
# Api client TypeScript
The backend application can automatically create a TypeScript client to be used in frontend
applications. It is used in the impress front application itself.
This client is made with [openapi-typescript-codegen](https://github.com/ferdikoomen/openapi-typescript-codegen)
and impress's backend OpenAPI schema (available [here](http://localhost:8071/v1.0/swagger/) if you have the backend running).
## Requirements
We'll need the online OpenAPI schema generated by swagger. Therefore you will first need to
install the backend application.
## Install openApiClientJs
```sh
$ cd src/tsclient
$ yarn install
```
## Generate the client
```sh
yarn generate:api:client:local <output_path_for_generated_client>
```

View File

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

Submodule secrets deleted from 2643697e5f

View File

@@ -1,5 +1,4 @@
"""Admin classes and registrations for core app."""
from django.contrib import admin
from django.contrib.auth import admin as auth_admin
from django.utils.translation import gettext_lazy as _
@@ -92,7 +91,7 @@ class DocumentAdmin(admin.ModelAdmin):
"""Document admin interface declaration."""
inlines = (DocumentAccessInline,)
@admin.register(models.Invitation)
class InvitationAdmin(admin.ModelAdmin):
@@ -120,3 +119,4 @@ class InvitationAdmin(admin.ModelAdmin):
def save_model(self, request, obj, form, change):
obj.issuer = request.user
obj.save()

View File

@@ -1,5 +1,4 @@
"""Impress core API endpoints"""
from django.conf import settings
from django.core.exceptions import ValidationError
@@ -17,9 +16,9 @@ def exception_handler(exc, context):
https://gist.github.com/twidi/9d55486c36b6a51bdcb05ce3a763e79f
"""
if isinstance(exc, ValidationError):
detail = exc.message_dict
if hasattr(exc, "message"):
if hasattr(exc, "message_dict"):
detail = exc.message_dict
elif hasattr(exc, "message"):
detail = exc.message
elif hasattr(exc, "messages"):
detail = exc.messages

View File

@@ -1,5 +1,4 @@
"""A JSONField for DRF to handle serialization/deserialization."""
import json
from rest_framework import serializers

View File

@@ -1,5 +1,4 @@
"""Permission handlers for the impress core app."""
from django.core import exceptions
from rest_framework import permissions

View File

@@ -1,12 +1,9 @@
"""Client serializers for the impress core app."""
import mimetypes
from django.conf import settings
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
@@ -14,10 +11,18 @@ from core import models
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):
@@ -95,19 +100,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"]
@@ -139,49 +135,11 @@ class DocumentSerializer(BaseResourceSerializer):
"""Serialize documents."""
content = serializers.CharField(required=False)
accesses = DocumentAccessSerializer(many=True, read_only=True)
class Meta:
model = models.Document
fields = [
"id",
"content",
"title",
"accesses",
"abilities",
"is_public",
"created_at",
"updated_at",
]
read_only_fields = ["id", "accesses", "abilities", "created_at", "updated_at"]
# Suppress the warning about not implementing `create` and `update` methods
# since we don't use a model and only rely on the serializer for validation
# pylint: disable=abstract-method
class FileUploadSerializer(serializers.Serializer):
"""Receive file upload requests."""
file = serializers.FileField()
def validate_file(self, file):
"""Add file size and type constraints as defined in settings."""
# Validate file size
if file.size > settings.DOCUMENT_IMAGE_MAX_SIZE:
max_size = settings.DOCUMENT_IMAGE_MAX_SIZE // (1024 * 1024)
raise serializers.ValidationError(
f"File size exceeds the maximum limit of {max_size:d} MB."
)
# Validate file type
mime_type, _ = mimetypes.guess_type(file.name)
if mime_type not in settings.DOCUMENT_IMAGE_ALLOWED_MIME_TYPES:
mime_types = ", ".join(settings.DOCUMENT_IMAGE_ALLOWED_MIME_TYPES)
raise serializers.ValidationError(
f"File type '{mime_type:s}' is not allowed. Allowed types are: {mime_types:s}"
)
return file
fields = ["id", "content", "title", "accesses", "abilities", "is_public"]
read_only_fields = ["id", "accesses", "abilities"]
class TemplateSerializer(BaseResourceSerializer):
@@ -212,12 +170,6 @@ class DocumentGenerationSerializer(serializers.Serializer):
required=False,
default="html",
)
format = serializers.ChoiceField(
choices=["pdf", "docx"],
label=_("Format"),
required=False,
default="pdf",
)
class InvitationSerializer(serializers.ModelSerializer):
@@ -297,12 +249,3 @@ class InvitationSerializer(serializers.ModelSerializer):
attrs["document_id"] = document_id
attrs["issuer"] = user
return attrs
class DocumentVersionSerializer(serializers.Serializer):
"""Serialize Versions."""
etag = serializers.CharField()
is_latest = serializers.BooleanField()
last_modified = serializers.DateTimeField()
version_id = serializers.CharField()

View File

@@ -1,33 +0,0 @@
"""Util to generate S3 authorization headers for object storage access control"""
from django.core.files.storage import default_storage
import botocore
def generate_s3_authorization_headers(key):
"""
Generate authorization headers for an s3 object.
These headers can be used as an alternative to signed urls with many benefits:
- the urls of our files never expire and can be stored in our documents' content
- we don't leak authorized urls that could be shared (file access can only be done
with cookies)
- access control is truly realtime
- the object storage service does not need to be exposed on internet
"""
url = default_storage.unsigned_connection.meta.client.generate_presigned_url(
"get_object",
ExpiresIn=0,
Params={"Bucket": default_storage.bucket_name, "Key": key},
)
request = botocore.awsrequest.AWSRequest(method="get", url=url)
s3_client = default_storage.connection.meta.client
# pylint: disable=protected-access
credentials = s3_client._request_signer._credentials # noqa: SLF001
frozen_credentials = credentials.get_frozen_credentials()
region = s3_client.meta.region_name
auth = botocore.auth.S3SigV4Auth(frozen_credentials, "s3", region)
auth.add_auth(request)
return request

View File

@@ -1,19 +1,14 @@
"""API endpoints"""
import json
from io import BytesIO
import os
import re
import uuid
from urllib.parse import urlparse
from django.conf import settings
from django.contrib.postgres.aggregates import ArrayAgg
from django.core.files.storage import default_storage
from django.db.models import (
OuterRef,
Q,
Subquery,
)
from django.http import Http404
from django.http import FileResponse, Http404
from botocore.exceptions import ClientError
from rest_framework import (
@@ -30,24 +25,11 @@ from rest_framework import (
)
from core import models
from core.utils import email_invitation
from . import permissions, serializers, utils
ATTACHMENTS_FOLDER = "attachments"
UUID_REGEX = (
r"[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}"
)
FILE_EXT_REGEX = r"\.[a-zA-Z]{3,4}"
MEDIA_URL_PATTERN = re.compile(
f"{settings.MEDIA_URL:s}({UUID_REGEX:s})/"
f"({ATTACHMENTS_FOLDER:s}/{UUID_REGEX:s}{FILE_EXT_REGEX:s})$"
)
from . import permissions, serializers
# pylint: disable=too-many-ancestors
ATTACHMENTS_FOLDER = "attachments"
class NestedGenericViewSet(viewsets.GenericViewSet):
"""
@@ -130,7 +112,8 @@ class Pagination(pagination.PageNumberPagination):
class UserViewSet(
mixins.UpdateModelMixin, viewsets.GenericViewSet, mixins.ListModelMixin
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
"""User ViewSet"""
@@ -138,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"],
@@ -179,7 +142,7 @@ class ResourceViewsetMixin:
"""Mixin with methods common to all resource viewsets that are managed with accesses."""
filter_backends = [filters.OrderingFilter]
ordering_fields = ["created_at", "updated_at", "title"]
ordering_fields = ["created_at"]
ordering = ["-created_at"]
def get_queryset(self):
@@ -284,7 +247,7 @@ class ResourceAccessViewsetMixin:
):
return drf_response.Response(
{"detail": "Cannot delete the last owner access for the resource."},
status=status.HTTP_403_FORBIDDEN,
status=403,
)
return super().destroy(request, *args, **kwargs)
@@ -329,20 +292,6 @@ class DocumentViewSet(
access_model_class = models.DocumentAccess
resource_field_name = "document"
queryset = models.Document.objects.all()
ordering = ["-updated_at"]
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):
@@ -357,18 +306,10 @@ class DocumentViewSet(
Q(user=request.user) | Q(team__in=request.user.get_teams()),
)
)
versions_data = document.get_versions_slice(from_datetime=from_datetime)[
"versions"
]
paginator = pagination.PageNumberPagination()
paginated_versions = paginator.paginate_queryset(versions_data, request)
serialized_versions = serializers.DocumentVersionSerializer(
paginated_versions, many=True
return drf_response.Response(
document.get_versions_slice(from_datetime=from_datetime)
)
return paginator.get_paginated_response(serialized_versions.data)
@decorators.action(
detail=True,
methods=["get", "delete"],
@@ -405,77 +346,9 @@ class DocumentViewSet(
{
"content": response["Body"].read().decode("utf-8"),
"last_modified": response["LastModified"],
"id": version_id,
}
)
@decorators.action(detail=True, methods=["post"], url_path="attachment-upload")
def attachment_upload(self, request, *args, **kwargs):
"""Upload a file related to a given document"""
# Check permissions first
document = self.get_object()
# Validate metadata in payload
serializer = serializers.FileUploadSerializer(data=request.data)
if not serializer.is_valid():
return drf_response.Response(
serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
# Extract the file extension from the original filename
file = serializer.validated_data["file"]
extension = os.path.splitext(file.name)[1]
# Generate a generic yet unique filename to store the image in object storage
file_id = uuid.uuid4()
key = f"{document.key_base}/{ATTACHMENTS_FOLDER:s}/{file_id!s}{extension:s}"
default_storage.save(key, file)
return drf_response.Response(
{"file": f"{settings.MEDIA_URL:s}{key:s}"}, status=status.HTTP_201_CREATED
)
@decorators.action(detail=False, methods=["get"], url_path="retrieve-auth")
def retrieve_auth(self, request, *args, **kwargs):
"""
This view is used by an Nginx subrequest to control access to a document's
attachment file.
The original url is passed by nginx in the "HTTP_X_ORIGINAL_URL" header.
See corresponding ingress configuration in Helm chart and read about the
nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress
is configured to do this.
Based on the original url and the logged in user, we must decide if we authorize Nginx
to let this request go through (by returning a 200 code) or if we block it (by returning
a 403 error). Note that we return 403 errors without any further details for security
reasons.
When we let the request go through, we compute authorization headers that will be added to
the request going through thanks to the nginx.ingress.kubernetes.io/auth-response-headers
annotation. The request will then be proxied to the object storage backend who will
respond with the file after checking the signature included in headers.
"""
original_url = urlparse(request.META.get("HTTP_X_ORIGINAL_URL"))
match = MEDIA_URL_PATTERN.search(original_url.path)
try:
pk, attachment_key = match.groups()
except AttributeError as excpt:
raise exceptions.PermissionDenied() from excpt
# Check permission
try:
document = models.Document.objects.get(pk=pk)
except models.Document.DoesNotExist as excpt:
raise exceptions.PermissionDenied() from excpt
if not document.get_abilities(request.user).get("retrieve", False):
raise exceptions.PermissionDenied()
# Generate authorization headers and return an authorization to proceed with the request
request = utils.generate_s3_authorization_headers(f"{pk:s}/{attachment_key:s}")
return drf_response.Response("authorized", headers=request.headers, status=200)
class DocumentAccessViewSet(
ResourceAccessViewsetMixin,
@@ -495,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>/
@@ -517,13 +390,6 @@ class DocumentAccessViewSet(
resource_field_name = "document"
serializer_class = serializers.DocumentAccessSerializer
def perform_create(self, serializer):
"""Add a new access to the document and send an email to the new added user."""
access = serializer.save()
language = self.request.headers.get("Content-Language", "en-us")
email_invitation(language, access.user.email, access.document.id)
class TemplateViewSet(
ResourceViewsetMixin,
@@ -554,16 +420,7 @@ class TemplateViewSet(
# pylint: disable=unused-argument
def generate_document(self, request, pk=None):
"""
Generate and return a document for this template around the
body passed as argument.
2 types of body are accepted:
- HTML: body_type = "html"
- Markdown: body_type = "markdown"
2 types of documents can be generated:
- PDF: format = "pdf"
- Docx: format = "docx"
Generate and return pdf for this template with the content passed.
"""
serializer = serializers.DocumentGenerationSerializer(data=request.data)
@@ -574,10 +431,13 @@ class TemplateViewSet(
body = serializer.validated_data["body"]
body_type = serializer.validated_data["body_type"]
export_format = serializer.validated_data["format"]
template = self.get_object()
return template.generate_document(body, body_type, export_format)
pdf_content = template.generate_document(body, body_type)
response = FileResponse(BytesIO(pdf_content), content_type="application/pdf")
response["Content-Disposition"] = f"attachment; filename={template.title}.pdf"
return response
class TemplateAccessViewSet(
@@ -598,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>/
@@ -626,7 +486,6 @@ class InvitationViewset(
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.DestroyModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
"""API ViewSet for user invitations to document.
@@ -637,12 +496,11 @@ class InvitationViewset(
POST /api/v1.0/documents/<document_id>/invitations/ with expected data:
- email: str
- role: str [administrator|editor|reader]
- role: str [owner|admin|member]
Return newly created invitation (issuer and document are automatically set)
PATCH /api/v1.0/documents/<document_id>/invitations/:<invitation_id>/ with expected data:
- role: str [owner|admin|editor|reader]
Return partially updated document invitation
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
@@ -696,10 +554,3 @@ class InvitationViewset(
.distinct()
)
return queryset
def perform_create(self, serializer):
"""Save invitation to a document then send an email to the invited user."""
invitation = serializer.save()
language = self.request.headers.get("Content-Language", "en-us")
email_invitation(language, invitation.email, invitation.document.id)

View File

@@ -1,7 +1,6 @@
"""
Core application enums declaration
"""
from django.conf import global_settings, settings
from django.utils.translation import gettext_lazy as _

View File

@@ -2,7 +2,6 @@
"""
Core application factories
"""
from django.conf import settings
from django.contrib.auth.hashers import make_password

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.'),

View File

@@ -0,0 +1,42 @@
# Generated by Django 5.0.3 on 2024-05-12 19:02
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='user',
name='language',
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
),
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=[('member', 'Member'), ('administrator', 'Administrator'), ('owner', 'Owner')], default='member', 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.AddConstraint(
model_name='invitation',
constraint=models.UniqueConstraint(fields=('email', 'document'), name='email_and_document_unique_together'),
),
]

View File

@@ -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;",
),
]

View File

@@ -1,63 +1,62 @@
"""
Declare and configure the models for the impress core application
"""
import hashlib
import tempfile
import json
import smtplib
import textwrap
import uuid
from datetime import timedelta
from io import BytesIO
from logging import getLogger
from django.conf import settings
from django.contrib.auth import models as auth_models
from django.contrib.auth.base_user import AbstractBaseUser
from django.contrib.sites.models import Site
from django.core import exceptions, mail, validators
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
from django.db import models
from django.http import FileResponse
from django.template.base import Template as DjangoTemplate
from django.template.context import Context
from django.template.loader import render_to_string
from django.utils import html, timezone
from django.utils.functional import lazy
from django.utils.translation import gettext_lazy as _
from django.utils.translation import override
import frontmatter
import markdown
import pypandoc
import weasyprint
from botocore.exceptions import ClientError
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."""
if not user.is_authenticated:
return []
try:
roles = resource.user_roles or []
except AttributeError:
teams = user.get_teams()
roles = []
if user.is_authenticated:
try:
roles = resource.accesses.filter(
models.Q(user=user) | models.Q(team__in=teams),
).values_list("role", flat=True)
except (models.ObjectDoesNotExist, IndexError):
roles = []
roles = resource.user_roles or []
except AttributeError:
teams = user.get_teams()
try:
roles = resource.accesses.filter(
models.Q(user=user) | models.Q(team__in=teams),
).values_list("role", flat=True)
except (models.ObjectDoesNotExist, IndexError):
roles = []
return roles
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")
@@ -234,7 +233,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:
@@ -266,20 +265,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:
@@ -290,7 +283,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,
}
@@ -317,49 +309,12 @@ class Document(BaseModel):
def __str__(self):
return self.title
def save(self, *args, **kwargs):
"""Write content to object storage only if _content has changed."""
super().save(*args, **kwargs)
if self._content:
file_key = self.file_key
bytes_content = self._content.encode("utf-8")
# Attempt to directly check if the object exists using the storage client.
try:
response = default_storage.connection.meta.client.head_object(
Bucket=default_storage.bucket_name, Key=file_key
)
except ClientError as excpt:
# If the error is a 404, the object doesn't exist, so we should create it.
if excpt.response["Error"]["Code"] == "404":
has_changed = True
else:
raise
else:
# Compare the existing ETag with the MD5 hash of the new content.
has_changed = (
response["ETag"].strip('"')
!= hashlib.md5(bytes_content).hexdigest() # noqa: S324
)
if has_changed:
content_file = ContentFile(bytes_content)
default_storage.save(file_key, content_file)
@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):
@@ -370,7 +325,7 @@ class Document(BaseModel):
except (FileNotFoundError, ClientError):
pass
else:
self._content = response["Body"].read().decode("utf-8")
self._content = response["Body"].read().decode('utf-8')
return self._content
@content.setter
@@ -378,7 +333,7 @@ class Document(BaseModel):
"""Cache the content, don't write to object storage yet"""
if not isinstance(content, str):
raise ValueError("content should be a string.")
self._content = content
def get_content_response(self, version_id=""):
@@ -387,6 +342,28 @@ class Document(BaseModel):
Bucket=default_storage.bucket_name, Key=self.file_key, VersionId=version_id
)
def save(self, *args, **kwargs):
"""Write content to object storage only if _content has changed."""
super().save(*args, **kwargs)
if self._content:
file_key = self.file_key
bytes_content = self._content.encode("utf-8")
if default_storage.exists(file_key):
response = default_storage.connection.meta.client.head_object(
Bucket=default_storage.bucket_name, Key=file_key
)
has_changed = (
response["ETag"].strip('"')
!= hashlib.md5(bytes_content).hexdigest() # noqa
)
else:
has_changed = True
if has_changed:
content_file = ContentFile(bytes_content)
default_storage.save(file_key, content_file)
def get_versions_slice(
self, from_version_id="", from_datetime=None, page_size=None
):
@@ -404,7 +381,7 @@ class Document(BaseModel):
response = default_storage.connection.meta.client.list_object_versions(
Bucket=default_storage.bucket_name,
Prefix=self.file_key,
MaxKeys=settings.DOCUMENT_VERSIONS_PAGE_SIZE,
MaxKeys=settings.S3_VERSIONS_PAGE_SIZE,
**token,
)
@@ -422,7 +399,7 @@ class Document(BaseModel):
if response["NextVersionIdMarker"]:
return self.get_versions_slice(
from_version_id=response["NextVersionIdMarker"],
page_size=settings.DOCUMENT_VERSIONS_PAGE_SIZE,
page_size=settings.S3_VERSIONS_PAGE_SIZE,
from_datetime=from_datetime,
)
return {
@@ -434,9 +411,9 @@ class Document(BaseModel):
response = default_storage.connection.meta.client.list_object_versions(
Bucket=default_storage.bucket_name,
Prefix=self.file_key,
MaxKeys=min(page_size, settings.DOCUMENT_VERSIONS_PAGE_SIZE)
MaxKeys=min(page_size, settings.S3_VERSIONS_PAGE_SIZE)
if page_size
else settings.DOCUMENT_VERSIONS_PAGE_SIZE,
else settings.S3_VERSIONS_PAGE_SIZE,
**token,
)
return {
@@ -470,20 +447,18 @@ 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)
return {
"destroy": RoleChoices.OWNER in roles,
"attachment_upload": is_owner_or_admin or is_editor,
"manage_accesses": is_owner_or_admin,
"partial_update": is_owner_or_admin or is_editor,
"retrieve": can_get,
"update": is_owner_or_admin or is_editor,
"versions_destroy": is_owner_or_admin,
"versions_list": can_get_versions,
"versions_retrieve": can_get_versions,
"manage_accesses": is_owner_or_admin,
"update": is_owner_or_admin,
"partial_update": is_owner_or_admin,
"retrieve": can_get,
}
@@ -562,102 +537,21 @@ 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,
}
def generate_pdf(self, body_html, metadata):
def generate_document(self, body, body_type):
"""
Generate and return a pdf document wrapped around the current template
"""
document_html = weasyprint.HTML(
string=DjangoTemplate(self.code).render(
Context({"body": html.format_html(body_html), **metadata})
)
)
css = weasyprint.CSS(
string=self.css,
font_config=weasyprint.text.fonts.FontConfiguration(),
)
pdf_content = document_html.write_pdf(stylesheets=[css], zoom=1)
response = FileResponse(BytesIO(pdf_content), content_type="application/pdf")
response["Content-Disposition"] = f"attachment; filename={self.title}.pdf"
return response
def generate_word(self, body_html, metadata):
"""
Generate and return a docx document wrapped around the current template
"""
template_string = DjangoTemplate(self.code).render(
Context({"body": html.format_html(body_html), **metadata})
)
html_string = f"""
<!DOCTYPE html>
<html>
<head>
<style>
{self.css}
</style>
</head>
<body>
{template_string}
</body>
</html>
"""
reference_docx = "core/static/reference.docx"
output = BytesIO()
# Convert the HTML to a temporary docx file
with tempfile.NamedTemporaryFile(suffix=".docx", prefix="docx_") as tmp_file:
output_path = tmp_file.name
pypandoc.convert_text(
html_string,
"docx",
format="html",
outputfile=output_path,
extra_args=["--reference-doc", reference_docx],
)
# Create a BytesIO object to store the output of the temporary docx file
with open(output_path, "rb") as f:
output = BytesIO(f.read())
# Ensure the pointer is at the beginning
output.seek(0)
response = FileResponse(
output,
content_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
)
response["Content-Disposition"] = f"attachment; filename={self.title}.docx"
return response
def generate_document(self, body, body_type, export_format):
"""
Generate and return a document for this template around the
Generate and return a PDF document for this template around the
body passed as argument.
2 types of body are accepted:
- HTML: body_type = "html"
- Markdown: body_type = "markdown"
2 types of documents can be generated:
- PDF: export_format = "pdf"
- Docx: export_format = "docx"
"""
document = frontmatter.loads(body)
metadata = document.metadata
@@ -670,10 +564,16 @@ class Template(BaseModel):
markdown.markdown(textwrap.dedent(strip_body)) if strip_body else ""
)
if export_format == "pdf":
return self.generate_pdf(body_html, metadata)
return self.generate_word(body_html, metadata)
document_html = HTML(
string=DjangoTemplate(self.code).render(
Context({"body": html.format_html(body_html), **metadata})
)
)
css = CSS(
string=self.css,
font_config=FontConfiguration(),
)
return document_html.write_pdf(stylesheets=[css], zoom=1)
class TemplateAccess(BaseAccess):
@@ -731,7 +631,7 @@ class Invitation(BaseModel):
related_name="invitations",
)
role = models.CharField(
max_length=20, choices=RoleChoices.choices, default=RoleChoices.READER
max_length=20, choices=RoleChoices.choices, default=RoleChoices.MEMBER
)
issuer = models.ForeignKey(
User,
@@ -752,6 +652,14 @@ class Invitation(BaseModel):
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()
@@ -774,7 +682,6 @@ class Invitation(BaseModel):
def get_abilities(self, user):
"""Compute and return abilities for a given user."""
can_delete = False
can_update = False
roles = []
if user.is_authenticated:
@@ -793,13 +700,29 @@ class Invitation(BaseModel):
set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
)
can_update = bool(
set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
)
return {
"destroy": can_delete,
"update": can_update,
"partial_update": can_update,
"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)

View File

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View File

@@ -92,12 +92,9 @@ def test_models_oidc_user_getter_invalid_token(django_assert_num_queries, monkey
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with (
django_assert_num_queries(0),
pytest.raises(
SuspiciousOperation,
match="User info contained no recognizable user identification",
),
with django_assert_num_queries(0), pytest.raises(
SuspiciousOperation,
match="User info contained no recognizable user identification",
):
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)

View File

@@ -1,5 +1,4 @@
"""Fixtures for tests in the impress core application"""
from unittest import mock
import pytest

View File

@@ -1,231 +0,0 @@
"""
Test document accesses API endpoints for users in impress's core app.
"""
import random
from django.core import mail
import pytest
from rest_framework.test import APIClient
from core import factories, models
from core.api import serializers
from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
def test_api_document_accesses_create_anonymous():
"""Anonymous users should not be allowed to create document accesses."""
user = factories.UserFactory()
document = factories.DocumentFactory()
response = APIClient().post(
f"/api/v1.0/documents/{document.id!s}/accesses/",
{
"user": str(user.id),
"document": str(document.id),
"role": random.choice(models.RoleChoices.choices)[0],
},
format="json",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
assert models.DocumentAccess.objects.exists() is False
def test_api_document_accesses_create_authenticated_unrelated():
"""
Authenticated users should not be allowed to create document accesses for a document to
which they are not related.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
other_user = factories.UserFactory()
document = factories.DocumentFactory()
response = client.post(
f"/api/v1.0/documents/{document.id!s}/accesses/",
{
"user": str(other_user.id),
},
format="json",
)
assert response.status_code == 403
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."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
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
)
other_user = factories.UserFactory()
for new_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,
},
format="json",
)
assert response.status_code == 403
assert not models.DocumentAccess.objects.filter(user=other_user).exists()
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_create_authenticated_administrator(
via, mock_user_get_teams
):
"""
Administrators of a document should be able to create document accesses
except for the "owner" role.
An email should be sent to the accesses to notify them of the adding.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(
document=document, user=user, role="administrator"
)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="administrator"
)
other_user = factories.UserFactory()
# It should not be allowed to create an owner access
response = client.post(
f"/api/v1.0/documents/{document.id!s}/accesses/",
{
"user": str(other_user.id),
"role": "owner",
},
format="json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "Only owners of a resource can assign other users as owners."
}
# It should be allowed to create a lower access
role = random.choice(
[role[0] for role in models.RoleChoices.choices if role[0] != "owner"]
)
assert len(mail.outbox) == 0
response = client.post(
f"/api/v1.0/documents/{document.id!s}/accesses/",
{
"user_id": str(other_user.id),
"role": role,
},
format="json",
)
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,
}
assert len(mail.outbox) == 1
email = mail.outbox[0]
assert email.to == [other_user["email"]]
email_content = " ".join(email.body.split())
assert "Invitation to join Docs!" in email_content
assert "docs/" + str(document.id) + "/" in email_content
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_create_authenticated_owner(via, mock_user_get_teams):
"""
Owners of a document should be able to create document accesses whatever the role.
An email should be sent to the accesses to notify them of the adding.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="owner"
)
other_user = factories.UserFactory()
role = random.choice([role[0] for role in models.RoleChoices.choices])
assert len(mail.outbox) == 0
response = client.post(
f"/api/v1.0/documents/{document.id!s}/accesses/",
{
"user_id": str(other_user.id),
"role": role,
},
format="json",
)
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,
"team": "",
"role": role,
"abilities": new_document_access.get_abilities(user),
}
assert len(mail.outbox) == 1
email = mail.outbox[0]
assert email.to == [other_user["email"]]
email_content = " ".join(email.body.split())
assert "Invitation to join Docs!" in email_content
assert "docs/" + str(document.id) + "/" in email_content

View File

@@ -1,197 +0,0 @@
"""
Test file uploads API endpoint for users in impress's core app.
"""
import re
import uuid
from django.core.files.base import ContentFile
from django.core.files.uploadedfile import SimpleUploadedFile
import pytest
from rest_framework.test import APIClient
from core import factories
from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
def test_api_documents_attachment_upload_anonymous():
"""Anonymous users can't upload attachments to a document."""
document = factories.DocumentFactory()
file = SimpleUploadedFile("test_file.jpg", b"Dummy content")
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
response = APIClient().post(url, {"file": file}, format="multipart")
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_documents_attachment_upload_authenticated_public():
"""
Users who are not related to a public document should not be allowed to upload an attachment.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(is_public=True)
file = SimpleUploadedFile("test_file.jpg", b"Dummy content")
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
response = client.post(url, {"file": file}, format="multipart")
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
def test_api_documents_attachment_upload_authenticated_private():
"""
Users who are not related to a private document should not be able to upload an attachment.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(is_public=False)
file = SimpleUploadedFile("test_file.jpg", b"Dummy content")
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
response = client.post(url, {"file": file}, format="multipart")
assert response.status_code == 404
assert response.json() == {"detail": "No Document matches the given query."}
@pytest.mark.parametrize("via", VIA)
def test_api_documents_attachment_upload_reader(via, mock_user_get_teams):
"""
Users who are simple readers on a document should not be allowed to upload an attachment.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
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"
)
file = SimpleUploadedFile("test_file.jpg", b"Dummy content")
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
response = client.post(url, {"file": file}, format="multipart")
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
@pytest.mark.parametrize("role", ["editor", "administrator", "owner"])
@pytest.mark.parametrize("via", VIA)
def test_api_documents_attachment_upload_success(via, role, mock_user_get_teams):
"""
Editors, administrators and owners of a document should be able to upload an attachment.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
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
)
file = SimpleUploadedFile("test_file.jpg", b"Dummy content")
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
response = client.post(url, {"file": file}, format="multipart")
assert response.status_code == 201
pattern = re.compile(rf"^/media/{document.id!s}/attachments/(.*)\.jpg")
match = pattern.search(response.json()["file"])
file_id = match.group(1)
# Validate that file_id is a valid UUID
uuid.UUID(file_id)
def test_api_documents_attachment_upload_invalid(client):
"""Attempt to upload without a file should return an explicit error."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[(user, "owner")])
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
response = client.post(url, {}, format="multipart")
assert response.status_code == 400
assert response.json() == {"file": ["No file was submitted."]}
def test_api_documents_attachment_upload_size_limit_exceeded(settings):
"""The uploaded file should not exceeed the maximum size in settings."""
settings.DOCUMENT_IMAGE_MAX_SIZE = 1048576 # 1 MB for test
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[(user, "owner")])
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
# Create a temporary file larger than the allowed size
content = b"a" * (1048576 + 1)
file = ContentFile(content, name="test.jpg")
response = client.post(url, {"file": file}, format="multipart")
assert response.status_code == 400
assert response.json() == {"file": ["File size exceeds the maximum limit of 1 MB."]}
def test_api_documents_attachment_upload_type_not_allowed(settings):
"""The uploaded file should be of a whitelisted type."""
settings.DOCUMENT_IMAGE_ALLOWED_MIME_TYPES = ["image/jpeg", "image/png"]
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[(user, "owner")])
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
# Create a temporary file with a not allowed type (e.g., text file)
file = ContentFile(b"a" * 1048576, name="test.txt")
response = client.post(url, {"file": file}, format="multipart")
assert response.status_code == 400
assert response.json() == {
"file": [
"File type 'text/plain' is not allowed. Allowed types are: image/jpeg, image/png"
]
}

View File

@@ -1,9 +1,6 @@
"""
Tests for Documents API endpoint in impress's core app: create
"""
import uuid
import pytest
from rest_framework.test import APIClient
@@ -48,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()

View File

@@ -1,7 +1,6 @@
"""
Tests for Documents API endpoint in impress's core app: delete
"""
import random
import pytest
@@ -46,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()

View File

@@ -1,18 +1,15 @@
"""
Tests for Documents API endpoint in impress's core app: list
"""
from unittest import mock
import pytest
from faker import Faker
from rest_framework.pagination import PageNumberPagination
from rest_framework.status import HTTP_200_OK
from rest_framework.test import APIClient
from core import factories
fake = Faker()
pytestmark = pytest.mark.django_db
@@ -169,133 +166,57 @@ def test_api_documents_list_authenticated_distinct():
assert content["results"][0]["id"] == str(document.id)
def test_api_documents_order_updated_at_desc_default():
def test_api_documents_order():
"""
Test that the endpoint GET documents is sorted in 'updated_at' descending order by default.
Test that the endpoint GET documents is sorted in 'created_at' descending order by default.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Updated at next year to ensure the order is correct
documents_updated = [
document.updated_at.isoformat().replace("+00:00", "Z")
for document in factories.DocumentFactory.create_batch(
5, is_public=True, updated_at=fake.date_time_this_year(before_now=False)
)
document_ids = [
str(document.id)
for document in factories.DocumentFactory.create_batch(5, is_public=True)
]
documents_updated.sort(reverse=True)
response = APIClient().get(
response = client.get(
"/api/v1.0/documents/",
)
assert response.status_code == 200
response_data = response.json()
response_document_ids = [document["id"] for document in response_data["results"]]
response_document_updated = [
document["updated_at"] for document in response_data["results"]
]
document_ids.reverse()
assert (
response_document_updated == documents_updated
), "updated_at values are not sorted from newest to oldest"
response_document_ids == document_ids
), "created_at values are not sorted from newest to oldest"
@pytest.mark.parametrize(
"ordering_field, factory_field",
[
("-created_at", "created_at"),
("-updated_at", "updated_at"),
("-title", "title"),
],
)
def test_api_documents_ordering_desc(ordering_field, factory_field):
def test_api_documents_order_param():
"""
Test that the specified field is sorted in descending order
Test that the 'created_at' field is sorted in ascending order
when the 'ordering' query parameter is set.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
if factory_field == "title":
documents_field_values = [
factories.DocumentFactory(
is_public=True, title=fake.sentence(nb_words=4)
).title
for _ in range(5)
]
else:
documents_field_values = [
getattr(document, factory_field).isoformat().replace("+00:00", "Z")
for document in factories.DocumentFactory.create_batch(5, is_public=True)
]
documents_ids = [
str(document.id)
for document in factories.DocumentFactory.create_batch(5, is_public=True)
]
documents_field_values.sort(reverse=True)
response = client.get(
f"/api/v1.0/documents/?ordering={ordering_field}"
if ordering_field != "-created_at"
else "/api/v1.0/documents/",
response = APIClient().get(
"/api/v1.0/documents/?ordering=created_at",
)
assert response.status_code == 200
response_data = response.json()
response_documents_field_values = [
document[factory_field] for document in response_data["results"]
]
response_document_ids = [document["id"] for document in response_data["results"]]
assert (
response_documents_field_values == documents_field_values
), f"{factory_field} values are not sorted as expected"
@pytest.mark.parametrize(
"field",
[
("updated_at"),
("title"),
("created_at"),
],
)
def test_api_documents_ordering_asc(field):
"""
Test that the specified field is sorted in ascending order
when the 'ordering' query parameter is set.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
if field == "title":
documents_field_values = [
factories.DocumentFactory(
is_public=True, title=fake.sentence(nb_words=4)
).title
for _ in range(5)
]
else:
documents_field_values = [
getattr(document, field).isoformat().replace("+00:00", "Z")
for document in factories.DocumentFactory.create_batch(5, is_public=True)
]
documents_field_values.sort()
response = client.get(
f"/api/v1.0/documents/?ordering={field}",
)
assert response.status_code == 200
response_data = response.json()
response_documents_field_values = [
document[field] for document in response_data["results"]
]
assert (
response_documents_field_values == documents_field_values
), f"{field} values are not sorted as expected"
response_document_ids == documents_ids
), "created_at values are not sorted from oldest to newest"

View File

@@ -1,12 +1,10 @@
"""
Tests for Documents API endpoint in impress's core app: retrieve
"""
import pytest
from rest_framework.test import APIClient
from core import factories
from core.api import serializers
pytestmark = pytest.mark.django_db
@@ -22,7 +20,6 @@ def test_api_documents_retrieve_anonymous_public():
"id": str(document.id),
"abilities": {
"destroy": False,
"attachment_upload": False,
"manage_accesses": False,
"partial_update": False,
"retrieve": True,
@@ -35,8 +32,6 @@ def test_api_documents_retrieve_anonymous_public():
"title": document.title,
"is_public": True,
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}
@@ -47,7 +42,7 @@ def test_api_documents_retrieve_anonymous_not_public():
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/")
assert response.status_code == 404
assert response.json() == {"detail": "No Document matches the given query."}
assert response.json() == {"detail": "Not found."}
def test_api_documents_retrieve_authenticated_unrelated_public():
@@ -70,7 +65,6 @@ def test_api_documents_retrieve_authenticated_unrelated_public():
"id": str(document.id),
"abilities": {
"destroy": False,
"attachment_upload": False,
"manage_accesses": False,
"partial_update": False,
"retrieve": True,
@@ -83,8 +77,6 @@ def test_api_documents_retrieve_authenticated_unrelated_public():
"title": document.title,
"is_public": True,
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}
@@ -104,7 +96,7 @@ def test_api_documents_retrieve_authenticated_unrelated_not_public():
f"/api/v1.0/documents/{document.id!s}/",
)
assert response.status_code == 404
assert response.json() == {"detail": "No Document matches the given query."}
assert response.json() == {"detail": "Not found."}
def test_api_documents_retrieve_authenticated_related_direct():
@@ -120,32 +112,30 @@ 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),
@@ -153,8 +143,6 @@ def test_api_documents_retrieve_authenticated_related_direct():
"content": document.content,
"abilities": document.get_abilities(user),
"is_public": document.is_public,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}
@@ -173,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"
@@ -187,16 +172,14 @@ def test_api_documents_retrieve_authenticated_related_team_none(mock_user_get_te
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
assert response.status_code == 404
assert response.json() == {"detail": "No Document matches the given query."}
assert response.json() == {"detail": "Not found."}
@pytest.mark.parametrize(
"teams",
[
["readers"],
["unknown", "readers"],
["editors"],
["unknown", "editors"],
["members"],
["unknown", "members"],
],
)
def test_api_documents_retrieve_authenticated_related_team_members(
@@ -215,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"
@@ -231,8 +211,6 @@ def test_api_documents_retrieve_authenticated_related_team_members(
factories.TeamDocumentAccessFactory()
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
# pylint: disable=R0801
assert response.status_code == 200
content = response.json()
expected_abilities = {
@@ -240,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,
},
{
@@ -288,8 +258,6 @@ def test_api_documents_retrieve_authenticated_related_team_members(
"content": document.content,
"abilities": document.get_abilities(user),
"is_public": False,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}
@@ -297,7 +265,7 @@ def test_api_documents_retrieve_authenticated_related_team_members(
"teams",
[
["administrators"],
["editors", "administrators"],
["members", "administrators"],
["unknown", "administrators"],
],
)
@@ -317,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"
@@ -340,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,
},
},
{
@@ -373,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,
},
},
{
@@ -388,7 +338,6 @@ def test_api_documents_retrieve_authenticated_related_team_administrators(
"retrieve": True,
"set_role_to": [],
"update": False,
"partial_update": False,
},
},
{
@@ -407,8 +356,6 @@ def test_api_documents_retrieve_authenticated_related_team_administrators(
"content": document.content,
"abilities": document.get_abilities(user),
"is_public": False,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}
@@ -437,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"
@@ -460,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,
},
},
{
@@ -493,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,
},
},
{
@@ -507,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",
},
},
{
@@ -530,6 +458,4 @@ def test_api_documents_retrieve_authenticated_related_team_owners(
"content": document.content,
"abilities": document.get_abilities(user),
"is_public": False,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}

View File

@@ -1,213 +0,0 @@
"""
Test file uploads API endpoint for users in impress's core app.
"""
import uuid
from io import BytesIO
from urllib.parse import urlparse
from django.conf import settings
from django.core.files.storage import default_storage
from django.utils import timezone
import pytest
import requests
from rest_framework.test import APIClient
from core import factories
from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
def test_api_documents_retrieve_auth_anonymous_public():
"""Anonymous users should be able to retrieve attachments linked to a public document"""
document = factories.DocumentFactory(is_public=True)
filename = f"{uuid.uuid4()!s}.jpg"
key = f"{document.pk!s}/attachments/{filename:s}"
default_storage.connection.meta.client.put_object(
Bucket=default_storage.bucket_name,
Key=key,
Body=BytesIO(b"my prose"),
ContentType="text/plain",
)
original_url = f"http://localhost/media/{key:s}"
response = APIClient().get(
"/api/v1.0/documents/retrieve-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 200
authorization = response["Authorization"]
assert "AWS4-HMAC-SHA256 Credential=" in authorization
assert (
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
in authorization
)
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}"
response = requests.get(
file_url,
headers={
"authorization": authorization,
"x-amz-date": response["x-amz-date"],
"x-amz-content-sha256": response["x-amz-content-sha256"],
"Host": f"{s3_url.hostname:s}:{s3_url.port:d}",
},
timeout=1,
)
assert response.content.decode("utf-8") == "my prose"
def test_api_documents_retrieve_auth_anonymous_not_public():
"""
Anonymous users should not be allowed to retrieve attachments linked to a document
that is not public.
"""
document = factories.DocumentFactory(is_public=False)
filename = f"{uuid.uuid4()!s}.jpg"
media_url = f"http://localhost/media/{document.pk!s}/attachments/{filename:s}"
response = APIClient().get(
"/api/v1.0/documents/retrieve-auth/", HTTP_X_ORIGINAL_URL=media_url
)
assert response.status_code == 403
assert "Authorization" not in response
def test_api_documents_retrieve_auth_authenticated_public():
"""
Authenticated users who are not related to a document should be able to
retrieve attachments linked to a public document.
"""
document = factories.DocumentFactory(is_public=True)
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
filename = f"{uuid.uuid4()!s}.jpg"
key = f"{document.pk!s}/attachments/{filename:s}"
default_storage.connection.meta.client.put_object(
Bucket=default_storage.bucket_name,
Key=key,
Body=BytesIO(b"my prose"),
ContentType="text/plain",
)
original_url = f"http://localhost/media/{key:s}"
response = APIClient().get(
"/api/v1.0/documents/retrieve-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 200
authorization = response["Authorization"]
assert "AWS4-HMAC-SHA256 Credential=" in authorization
assert (
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
in authorization
)
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}"
response = requests.get(
file_url,
headers={
"authorization": authorization,
"x-amz-date": response["x-amz-date"],
"x-amz-content-sha256": response["x-amz-content-sha256"],
"Host": f"{s3_url.hostname:s}:{s3_url.port:d}",
},
timeout=1,
)
assert response.content.decode("utf-8") == "my prose"
def test_api_documents_retrieve_auth_authenticated_not_public():
"""
Authenticated users who are not related to a document should not be allowed to
retrieve attachments linked to a document that is not public.
"""
document = factories.DocumentFactory(is_public=False)
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
filename = f"{uuid.uuid4()!s}.jpg"
media_url = f"http://localhost/media/{document.pk!s}/attachments/{filename:s}"
response = APIClient().get(
"/api/v1.0/documents/retrieve-auth/", HTTP_X_ORIGINAL_URL=media_url
)
assert response.status_code == 403
assert "Authorization" not in response
@pytest.mark.parametrize("is_public", [True, False])
@pytest.mark.parametrize("via", VIA)
def test_api_documents_retrieve_auth_related(via, is_public, mock_user_get_teams):
"""
Users who have a role on a document, whatever the role, should be able to
retrieve related attachments.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(is_public=is_public)
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(document=document, team="lasuite")
filename = f"{uuid.uuid4()!s}.jpg"
key = f"{document.pk!s}/attachments/{filename:s}"
default_storage.connection.meta.client.put_object(
Bucket=default_storage.bucket_name,
Key=key,
Body=BytesIO(b"my prose"),
ContentType="text/plain",
)
original_url = f"http://localhost/media/{key:s}"
response = client.get(
"/api/v1.0/documents/retrieve-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 200
authorization = response["Authorization"]
assert "AWS4-HMAC-SHA256 Credential=" in authorization
assert (
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
in authorization
)
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}"
response = requests.get(
file_url,
headers={
"authorization": authorization,
"x-amz-date": response["x-amz-date"],
"x-amz-content-sha256": response["x-amz-content-sha256"],
"Host": f"{s3_url.hostname:s}:{s3_url.port:d}",
},
timeout=1,
)
assert response.content.decode("utf-8") == "my prose"

View File

@@ -1,7 +1,6 @@
"""
Tests for Documents API endpoint in impress's core app: update
"""
import random
import pytest
@@ -59,7 +58,7 @@ def test_api_documents_update_authenticated_unrelated():
)
assert response.status_code == 404
assert response.json() == {"detail": "No Document matches the given query."}
assert response.json() == {"detail": "Not found."}
document.refresh_from_db()
document_values = serializers.DocumentSerializer(instance=document).data
@@ -67,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()
@@ -79,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
@@ -107,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()
@@ -142,10 +141,8 @@ def test_api_documents_update_authenticated_editor_administrator_or_owner(
document = models.Document.objects.get(pk=document.pk)
document_values = serializers.DocumentSerializer(instance=document).data
for key, value in document_values.items():
if key in ["id", "accesses", "created_at"]:
if key in ["id", "accesses"]:
assert value == old_document_values[key]
elif key == "updated_at":
assert value > old_document_values[key]
else:
assert value == new_document_values[key]
@@ -181,10 +178,8 @@ def test_api_documents_update_authenticated_owners(via, mock_user_get_teams):
document = models.Document.objects.get(pk=document.pk)
document_values = serializers.DocumentSerializer(instance=document).data
for key, value in document_values.items():
if key in ["id", "accesses", "created_at"]:
if key in ["id", "accesses"]:
assert value == old_document_values[key]
elif key == "updated_at":
assert value > old_document_values[key]
else:
assert value == new_document_values[key]

View File

@@ -1,7 +1,6 @@
"""
Test suite for generated openapi schema.
"""
import json
from io import StringIO

View File

@@ -1,7 +1,6 @@
"""
Tests for Templates API endpoint in impress's core app: create
"""
import pytest
from rest_framework.test import APIClient

View File

@@ -1,7 +1,6 @@
"""
Tests for Templates API endpoint in impress's core app: delete
"""
import random
import pytest
@@ -46,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

View File

@@ -1,7 +1,6 @@
"""
Test users API endpoints in the impress core app.
"""
import pytest
from rest_framework.test import APIClient
@@ -45,7 +44,7 @@ def test_api_templates_generate_document_anonymous_not_public():
)
assert response.status_code == 404
assert response.json() == {"detail": "No Template matches the given query."}
assert response.json() == {"detail": "Not found."}
def test_api_templates_generate_document_authenticated_public():
@@ -88,7 +87,7 @@ def test_api_templates_generate_document_authenticated_not_public():
)
assert response.status_code == 404
assert response.json() == {"detail": "No Template matches the given query."}
assert response.json() == {"detail": "Not found."}
@pytest.mark.parametrize("via", VIA)
@@ -98,7 +97,7 @@ def test_api_templates_generate_document_related(via, mock_user_get_teams):
client = APIClient()
client.force_login(user)
access = None
if via == USER:
access = factories.UserTemplateAccessFactory(user=user)
elif via == TEAM:
@@ -179,26 +178,3 @@ def test_api_templates_generate_document_type_unknown():
'"unknown" is not a valid choice.',
]
}
def test_api_templates_generate_document_export_docx():
"""Generate pdf document with the body type html."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory(is_public=True)
data = {"body": "<p>Test body</p>", "body_type": "html", "format": "docx"}
response = client.post(
f"/api/v1.0/templates/{template.id!s}/generate-document/",
data,
format="json",
)
assert response.status_code == 200
assert (
response.headers["content-type"]
== "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
)

View File

@@ -1,7 +1,6 @@
"""
Tests for Templates API endpoint in impress's core app: list
"""
from unittest import mock
import pytest

View File

@@ -1,7 +1,6 @@
"""
Tests for Templates API endpoint in impress's core app: retrieve
"""
import pytest
from rest_framework.test import APIClient
@@ -42,7 +41,7 @@ def test_api_templates_retrieve_anonymous_not_public():
response = APIClient().get(f"/api/v1.0/templates/{template.id!s}/")
assert response.status_code == 404
assert response.json() == {"detail": "No Template matches the given query."}
assert response.json() == {"detail": "Not found."}
def test_api_templates_retrieve_authenticated_unrelated_public():
@@ -95,7 +94,7 @@ def test_api_templates_retrieve_authenticated_unrelated_not_public():
f"/api/v1.0/templates/{template.id!s}/",
)
assert response.status_code == 404
assert response.json() == {"detail": "No Template matches the given query."}
assert response.json() == {"detail": "Not found."}
def test_api_templates_retrieve_authenticated_related_direct():
@@ -161,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"
@@ -175,19 +171,17 @@ def test_api_templates_retrieve_authenticated_related_team_none(mock_user_get_te
response = client.get(f"/api/v1.0/templates/{template.id!s}/")
assert response.status_code == 404
assert response.json() == {"detail": "No Template matches the given query."}
assert response.json() == {"detail": "Not found."}
@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
):
"""
@@ -203,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"
@@ -226,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,
},
{
@@ -302,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"
@@ -324,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,
},
},
{
@@ -357,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,
},
},
{
@@ -372,7 +337,6 @@ def test_api_templates_retrieve_authenticated_related_team_administrators(
"retrieve": True,
"set_role_to": [],
"update": False,
"partial_update": False,
},
},
{
@@ -420,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"
@@ -442,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,
},
},
{
@@ -475,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,
},
},
{
@@ -489,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",
},
},
{

View File

@@ -1,7 +1,6 @@
"""
Tests for Templates API endpoint in impress's core app: update
"""
import random
import pytest
@@ -59,7 +58,7 @@ def test_api_templates_update_authenticated_unrelated():
)
assert response.status_code == 404
assert response.json() == {"detail": "No Template matches the given query."}
assert response.json() == {"detail": "Not found."}
template.refresh_from_db()
template_values = serializers.TemplateSerializer(instance=template).data
@@ -67,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()
@@ -78,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
@@ -106,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."""

View File

@@ -1,7 +1,6 @@
"""
Test document accesses API endpoints for users in impress's core app.
"""
import random
from uuid import uuid4
@@ -68,7 +67,6 @@ def test_api_document_accesses_list_authenticated_related(via, mock_user_get_tea
client.force_login(user)
document = factories.DocumentFactory()
user_access = None
if via == USER:
user_access = models.DocumentAccess.objects.create(
document=document,
@@ -94,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
@@ -104,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),
@@ -118,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),
@@ -175,9 +170,7 @@ def test_api_document_accesses_retrieve_authenticated_unrelated():
)
assert response.status_code == 404
assert response.json() == {
"detail": "No DocumentAccess matches the given query."
}
assert response.json() == {"detail": "Not found."}
@pytest.mark.parametrize("via", VIA)
@@ -204,18 +197,208 @@ 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),
}
def test_api_document_accesses_create_anonymous():
"""Anonymous users should not be allowed to create document accesses."""
user = factories.UserFactory()
document = factories.DocumentFactory()
response = APIClient().post(
f"/api/v1.0/documents/{document.id!s}/accesses/",
{
"user": str(user.id),
"document": str(document.id),
"role": random.choice(models.RoleChoices.choices)[0],
},
format="json",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
assert models.DocumentAccess.objects.exists() is False
def test_api_document_accesses_create_authenticated_unrelated():
"""
Authenticated users should not be allowed to create document accesses for a document to
which they are not related.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
other_user = factories.UserFactory()
document = factories.DocumentFactory()
response = client.post(
f"/api/v1.0/documents/{document.id!s}/accesses/",
{
"user": str(other_user.id),
},
format="json",
)
assert response.status_code == 403
assert not models.DocumentAccess.objects.filter(user=other_user).exists()
@pytest.mark.parametrize("via", VIA)
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()
client.force_login(user)
document = factories.DocumentFactory()
if via == USER:
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="member"
)
other_user = factories.UserFactory()
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": role,
},
format="json",
)
assert response.status_code == 403
assert not models.DocumentAccess.objects.filter(user=other_user).exists()
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_create_authenticated_administrator(
via, mock_user_get_teams
):
"""
Administrators of a document should be able to create document accesses
except for the "owner" role.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(
document=document, user=user, role="administrator"
)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="administrator"
)
other_user = factories.UserFactory()
# It should not be allowed to create an owner access
response = client.post(
f"/api/v1.0/documents/{document.id!s}/accesses/",
{
"user": str(other_user.id),
"role": "owner",
},
format="json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "Only owners of a resource can assign other users as owners."
}
# It should be allowed to create a lower access
role = random.choice(
[role[0] for role in models.RoleChoices.choices if role[0] != "owner"]
)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
)
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()
assert response.json() == {
"abilities": new_document_access.get_abilities(user),
"id": str(new_document_access.id),
"team": "",
"role": role,
"user": str(other_user.id),
}
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_create_authenticated_owner(via, mock_user_get_teams):
"""
Owners of a document should be able to create document accesses whatever the role.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="owner"
)
other_user = factories.UserFactory()
role = random.choice([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": role,
},
format="json",
)
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()
assert response.json() == {
"id": str(new_document_access.id),
"user": str(other_user.id),
"team": "",
"role": role,
"abilities": new_document_access.get_abilities(user),
}
def test_api_document_accesses_update_anonymous():
"""Anonymous users should not be allowed to update a document access."""
access = factories.UserDocumentAccessFactory()
@@ -273,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()
@@ -286,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)
@@ -341,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():
@@ -449,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
@@ -545,7 +725,6 @@ def test_api_document_accesses_update_owner_self(via, mock_user_get_teams):
client.force_login(user)
document = factories.DocumentFactory()
access = None
if via == USER:
access = factories.UserDocumentAccessFactory(
document=document, user=user, role="owner"
@@ -557,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}/",
@@ -574,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",
)
@@ -624,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()
@@ -638,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)
@@ -683,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
@@ -776,7 +948,6 @@ def test_api_document_accesses_delete_owners_last_owner(via, mock_user_get_teams
client.force_login(user)
document = factories.DocumentFactory()
access = None
if via == USER:
access = factories.UserDocumentAccessFactory(
document=document, user=user, role="owner"

View File

@@ -1,12 +1,9 @@
"""
Unit tests for the Invitation model
"""
import random
import time
from django.core import mail
import pytest
from rest_framework import status
from rest_framework.test import APIClient
@@ -60,20 +57,13 @@ def test_api_document_invitations__create__authenticated_outsider():
@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],
["member", "member", False],
["member", "administrator", False],
["member", "owner", False],
["administrator", "member", True],
["administrator", "administrator", True],
["administrator", "owner", False],
["owner", "reader", True],
["owner", "editor", True],
["owner", "member", True],
["owner", "administrator", True],
["owner", "owner", True],
),
@@ -101,8 +91,6 @@ def test_api_document_invitations__create__privileged_members(
"role": invited,
}
assert len(mail.outbox) == 0
client = APIClient()
client.force_login(user)
response = client.post(
@@ -113,92 +101,11 @@ def test_api_document_invitations__create__privileged_members(
if is_allowed:
assert response.status_code == status.HTTP_201_CREATED
assert models.Invitation.objects.count() == 1
assert len(mail.outbox) == 1
email = mail.outbox[0]
assert email.to == ["guest@example.com"]
email_content = " ".join(email.body.split())
assert "Invitation to join Docs!" in email_content
else:
assert response.status_code == status.HTTP_403_FORBIDDEN
assert models.Invitation.objects.exists() is False
def test_api_document_invitations__create__email_from_content_language():
"""
The email generated is from the language set in the Content-Language header
"""
user = factories.UserFactory()
document = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
invitation_values = {
"email": "guest@example.com",
"role": "reader",
}
assert len(mail.outbox) == 0
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id}/invitations/",
invitation_values,
format="json",
headers={"Content-Language": "fr-fr"},
)
assert response.status_code == status.HTTP_201_CREATED
assert response.json()["email"] == "guest@example.com"
assert models.Invitation.objects.count() == 1
assert len(mail.outbox) == 1
email = mail.outbox[0]
assert email.to == ["guest@example.com"]
email_content = " ".join(email.body.split())
assert "Invitation à rejoindre Docs !" in email_content
def test_api_document_invitations__create__email_from_content_language_not_supported():
"""
If the language from the Content-Language is not supported
it will display the default language, English.
"""
user = factories.UserFactory()
document = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
invitation_values = {
"email": "guest@example.com",
"role": "reader",
}
assert len(mail.outbox) == 0
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id}/invitations/",
invitation_values,
format="json",
headers={"Content-Language": "not-supported"},
)
assert response.status_code == status.HTTP_201_CREATED
assert response.json()["email"] == "guest@example.com"
assert models.Invitation.objects.count() == 1
assert len(mail.outbox) == 1
email = mail.outbox[0]
assert email.to == ["guest@example.com"]
email_content = " ".join(email.body.split())
assert "Invitation to join Docs!" in email_content
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()
@@ -239,7 +146,7 @@ def test_api_document_invitations__create__cannot_duplicate_invitation():
# 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"]),
"role": random.choice(["administrator", "member"]),
}
client = APIClient()
@@ -251,7 +158,7 @@ def test_api_document_invitations__create__cannot_duplicate_invitation():
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json() == [
assert response.json()["__all__"] == [
"Document invitation with this Email address and Document already exists."
]
@@ -279,7 +186,9 @@ def test_api_document_invitations__create__cannot_invite_existing_users():
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json() == ["This email is already associated to a registered user."]
assert response.json()["email"] == [
"This email is already associated to a registered user."
]
def test_api_document_invitations__list__anonymous_user():
@@ -313,12 +222,12 @@ def test_api_document_invitations__list__authenticated(
document=document, role="administrator", issuer=user
)
other_invitations = factories.InvitationFactory.create_batch(
2, document=document, role="reader", issuer=other_user
2, document=document, role="member", 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")
factories.InvitationFactory.create_batch(2, document=other_document, role="member")
client = APIClient()
client.force_login(user)
@@ -340,8 +249,8 @@ def test_api_document_invitations__list__authenticated(
"is_expired": False,
"abilities": {
"destroy": role in ["administrator", "owner"],
"update": role in ["administrator", "owner"],
"partial_update": role in ["administrator", "owner"],
"update": False,
"partial_update": False,
"retrieve": True,
},
}
@@ -366,7 +275,7 @@ def test_api_document_invitations__list__expired_invitations_still_listed(settin
settings.INVITATION_VALIDITY_DURATION = 1 # second
expired_invitation = factories.InvitationFactory(
document=document,
role="reader",
role="member",
issuer=user,
)
time.sleep(1)
@@ -392,8 +301,8 @@ def test_api_document_invitations__list__expired_invitations_still_listed(settin
"is_expired": True,
"abilities": {
"destroy": True,
"update": True,
"partial_update": True,
"update": False,
"partial_update": False,
"retrieve": True,
},
},
@@ -467,87 +376,19 @@ def test_api_document_invitations__retrieve__document_member(via, mock_user_get_
"is_expired": False,
"abilities": {
"destroy": role in ["administrator", "owner"],
"update": role in ["administrator", "owner"],
"partial_update": role in ["administrator", "owner"],
"update": False,
"partial_update": False,
"retrieve": True,
},
}
@pytest.mark.parametrize("via", VIA)
def test_api_document_invitations__put_authenticated(via, mock_user_get_teams):
"""
Authenticated user can put invitations.
"""
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}/"
response = client.patch(url, {"email": "test@test.test"}, format="json")
assert response.status_code == status.HTTP_200_OK
invitation.refresh_from_db()
assert invitation.email == "test@test.test"
@pytest.mark.parametrize("via", VIA)
def test_api_document_invitations__patch_authenticated(via, mock_user_get_teams):
"""
Authenticated user can patch invitations.
"""
user = factories.UserFactory()
invitation = factories.InvitationFactory(role="owner")
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"
)
assert invitation.role == "owner"
client = APIClient()
client.force_login(user)
url = f"/api/v1.0/documents/{invitation.document.id}/invitations/{invitation.id}/"
response = client.patch(
url,
{"role": "reader"},
format="json",
)
assert response.status_code == status.HTTP_200_OK
invitation.refresh_from_db()
assert invitation.role == "reader"
@pytest.mark.parametrize("via", VIA)
@pytest.mark.parametrize(
"method",
["put", "patch"],
)
@pytest.mark.parametrize(
"role",
["editor", "reader"],
)
def test_api_document_invitations__update__forbidden__not_authenticated(
method, via, role, mock_user_get_teams
):
def test_api_document_invitations__update__forbidden(method, via, mock_user_get_teams):
"""
Update of invitations is currently forbidden.
"""
@@ -555,28 +396,24 @@ def test_api_document_invitations__update__forbidden__not_authenticated(
invitation = factories.InvitationFactory()
if via == USER:
factories.UserDocumentAccessFactory(
document=invitation.document, user=user, role=role
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=role
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}/"
response = client.put(url)
if method == "put":
response = client.put(url)
if method == "patch":
response = client.patch(url)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert (
response.json()["detail"]
== "You do not have permission to perform this action."
)
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():
@@ -630,20 +467,17 @@ def test_api_document_invitations__delete__privileged_members(
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."""
def test_api_document_invitations__delete__members(via, mock_user_get_teams):
"""Member should not be able to cancel invitation."""
user = factories.UserFactory()
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"
)
invitation = factories.InvitationFactory(document=document)

View File

@@ -1,7 +1,6 @@
"""
Test document versions API endpoints for users in impress's core app.
"""
import random
import time
@@ -39,7 +38,7 @@ def test_api_document_versions_list_anonymous_private():
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/versions/")
assert response.status_code == 404
assert response.json() == {"detail": "No Document matches the given query."}
assert response.json() == {"detail": "Not found."}
def test_api_document_versions_list_authenticated_unrelated_public():
@@ -87,7 +86,7 @@ def test_api_document_versions_list_authenticated_unrelated_private():
f"/api/v1.0/documents/{document.id!s}/versions/",
)
assert response.status_code == 404
assert response.json() == {"detail": "No Document matches the given query."}
assert response.json() == {"detail": "Not found."}
@pytest.mark.parametrize("via", VIA)
@@ -126,8 +125,7 @@ def test_api_document_versions_list_authenticated_related(via, mock_user_get_tea
assert response.status_code == 200
content = response.json()
assert len(content["results"]) == 0
assert content["count"] == 0
assert len(content["versions"]) == 0
# Add a new version to the document
document.content = "new content"
@@ -139,8 +137,9 @@ def test_api_document_versions_list_authenticated_related(via, mock_user_get_tea
assert response.status_code == 200
content = response.json()
assert len(content["results"]) == 1
assert content["count"] == 1
assert len(content["versions"]) == 1
assert content["next_version_id_marker"] == ""
assert content["is_truncated"] is False
def test_api_document_versions_retrieve_anonymous_public():
@@ -170,7 +169,7 @@ def test_api_document_versions_retrieve_anonymous_private():
response = APIClient().get(url)
assert response.status_code == 404
assert response.json() == {"detail": "No Document matches the given query."}
assert response.json() == {"detail": "Not found."}
def test_api_document_versions_retrieve_authenticated_unrelated_public():
@@ -212,7 +211,7 @@ def test_api_document_versions_retrieve_authenticated_unrelated_private():
f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/",
)
assert response.status_code == 404
assert response.json() == {"detail": "No Document matches the given query."}
assert response.json() == {"detail": "Not found."}
@pytest.mark.parametrize("via", VIA)
@@ -435,15 +434,14 @@ def test_api_document_versions_delete_authenticated_private():
)
assert response.status_code == 404
assert response.json() == {"detail": "No Document matches the given query."}
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()
@@ -452,11 +450,11 @@ 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

View File

@@ -1,7 +1,6 @@
"""
Test template accesses API endpoints for users in impress's core app.
"""
import random
from uuid import uuid4
@@ -68,7 +67,6 @@ def test_api_template_accesses_list_authenticated_related(via, mock_user_get_tea
client.force_login(user)
template = factories.TemplateFactory()
user_access = None
if via == USER:
user_access = models.TemplateAccess.objects.create(
template=template,
@@ -172,9 +170,7 @@ def test_api_template_accesses_retrieve_authenticated_unrelated():
)
assert response.status_code == 404
assert response.json() == {
"detail": "No TemplateAccess matches the given query."
}
assert response.json() == {"detail": "Not found."}
@pytest.mark.parametrize("via", VIA)
@@ -258,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()
@@ -271,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",
)
@@ -463,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()
@@ -476,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)
@@ -531,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():
@@ -639,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
@@ -735,7 +725,6 @@ def test_api_template_accesses_update_owner_self(via, mock_user_get_teams):
client.force_login(user)
template = factories.TemplateFactory()
access = None
if via == USER:
access = factories.UserTemplateAccessFactory(
template=template, user=user, role="owner"
@@ -747,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}/",
@@ -808,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()
@@ -822,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)
@@ -867,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
@@ -960,7 +948,6 @@ def test_api_template_accesses_delete_owners_last_owner(via, mock_user_get_teams
client.force_login(user)
template = factories.TemplateFactory()
access = None
if via == USER:
access = factories.UserTemplateAccessFactory(
template=template, user=user, role="owner"

View File

@@ -1,7 +1,6 @@
"""
Test users API endpoints in the impress core app.
"""
import pytest
from rest_framework.test import APIClient
@@ -16,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()
@@ -35,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():
@@ -119,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,
}
@@ -180,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
@@ -202,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
@@ -378,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
@@ -394,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

View File

@@ -1,7 +1,6 @@
"""
Unit tests for the DocumentAccess model
"""
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ValidationError
@@ -18,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():
@@ -88,7 +87,6 @@ def test_models_document_access_get_abilities_anonymous():
"destroy": False,
"retrieve": False,
"update": False,
"partial_update": False,
"set_role_to": [],
}
@@ -102,7 +100,6 @@ def test_models_document_access_get_abilities_authenticated():
"destroy": False,
"retrieve": False,
"update": False,
"partial_update": False,
"set_role_to": [],
}
@@ -122,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"],
}
@@ -137,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": [],
}
@@ -154,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"],
}
@@ -171,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"
@@ -188,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"],
}
@@ -225,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": [],
}
@@ -242,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"
@@ -259,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(
django_assert_num_queries,
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):
@@ -335,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)
@@ -414,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": [],
}

View File

@@ -1,7 +1,6 @@
"""
Unit tests for the Document model
"""
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ValidationError
from django.core.files.storage import default_storage
@@ -48,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
@@ -63,11 +56,10 @@ def test_models_documents_get_abilities_anonymous_public():
abilities = document.get_abilities(AnonymousUser())
assert abilities == {
"destroy": False,
"attachment_upload": False,
"manage_accesses": False,
"partial_update": False,
"retrieve": True,
"update": False,
"manage_accesses": False,
"partial_update": False,
"versions_destroy": False,
"versions_list": False,
"versions_retrieve": False,
@@ -80,11 +72,10 @@ def test_models_documents_get_abilities_anonymous_not_public():
abilities = document.get_abilities(AnonymousUser())
assert abilities == {
"destroy": False,
"attachment_upload": False,
"manage_accesses": False,
"partial_update": False,
"retrieve": False,
"update": False,
"manage_accesses": False,
"partial_update": False,
"versions_destroy": False,
"versions_list": False,
"versions_retrieve": False,
@@ -97,11 +88,10 @@ def test_models_documents_get_abilities_authenticated_unrelated_public():
abilities = document.get_abilities(factories.UserFactory())
assert abilities == {
"destroy": False,
"attachment_upload": False,
"manage_accesses": False,
"partial_update": False,
"retrieve": True,
"update": False,
"manage_accesses": False,
"partial_update": False,
"versions_destroy": False,
"versions_list": False,
"versions_retrieve": False,
@@ -114,11 +104,10 @@ def test_models_documents_get_abilities_authenticated_unrelated_not_public():
abilities = document.get_abilities(factories.UserFactory())
assert abilities == {
"destroy": False,
"attachment_upload": False,
"manage_accesses": False,
"partial_update": False,
"retrieve": False,
"update": False,
"manage_accesses": False,
"partial_update": False,
"versions_destroy": False,
"versions_list": False,
"versions_retrieve": False,
@@ -132,11 +121,10 @@ def test_models_documents_get_abilities_owner():
abilities = access.document.get_abilities(access.user)
assert abilities == {
"destroy": True,
"attachment_upload": True,
"manage_accesses": True,
"partial_update": True,
"retrieve": True,
"update": True,
"manage_accesses": True,
"partial_update": True,
"versions_destroy": True,
"versions_list": True,
"versions_retrieve": True,
@@ -149,51 +137,29 @@ def test_models_documents_get_abilities_administrator():
abilities = access.document.get_abilities(access.user)
assert abilities == {
"destroy": False,
"attachment_upload": True,
"manage_accesses": True,
"partial_update": True,
"retrieve": True,
"update": True,
"manage_accesses": True,
"partial_update": True,
"versions_destroy": True,
"versions_list": True,
"versions_retrieve": True,
}
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")
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)
assert abilities == {
"destroy": False,
"attachment_upload": True,
"manage_accesses": False,
"partial_update": True,
"retrieve": True,
"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")
with django_assert_num_queries(1):
abilities = access.document.get_abilities(access.user)
assert abilities == {
"destroy": False,
"attachment_upload": False,
"manage_accesses": False,
"partial_update": False,
"retrieve": True,
"update": False,
"manage_accesses": False,
"partial_update": False,
"versions_destroy": False,
"versions_list": True,
"versions_retrieve": True,
@@ -202,19 +168,18 @@ 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)
assert abilities == {
"destroy": False,
"attachment_upload": False,
"manage_accesses": False,
"partial_update": False,
"retrieve": True,
"update": False,
"manage_accesses": False,
"partial_update": False,
"versions_destroy": False,
"versions_list": True,
"versions_retrieve": True,
@@ -226,7 +191,7 @@ def test_models_documents_get_versions_slice(settings):
The "get_versions_slice" method should allow navigating all versions of
the document with pagination.
"""
settings.DOCUMENT_VERSIONS_PAGE_SIZE = 4
settings.S3_VERSIONS_PAGE_SIZE = 4
# Create a document with 7 versions
document = factories.DocumentFactory()

View File

@@ -2,10 +2,13 @@
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
from django.core import exceptions, mail
import pytest
from faker import Faker
@@ -20,6 +23,13 @@ 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"):
@@ -158,6 +168,67 @@ def test_models_invitation__new_user__user_creation_constant_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="member")
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="member")
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
@@ -211,48 +282,23 @@ def test_models_document_invitations_get_abilities_privileged_member(
assert abilities == {
"destroy": True,
"retrieve": True,
"partial_update": True,
"update": 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."""
def test_models_document_invitations_get_abilities_member(via, mock_user_get_teams):
"""Check abilities for a document member with 'member' role."""
user = factories.UserFactory()
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"
)
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"
document=document, team="lasuite", role="member"
)
invitation = factories.InvitationFactory(document=document)

View File

@@ -1,7 +1,6 @@
"""
Unit tests for the TemplateAccess model
"""
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ValidationError
@@ -18,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():
@@ -88,7 +87,6 @@ def test_models_template_access_get_abilities_anonymous():
"destroy": False,
"retrieve": False,
"update": False,
"partial_update": False,
"set_role_to": [],
}
@@ -102,7 +100,6 @@ def test_models_template_access_get_abilities_authenticated():
"destroy": False,
"retrieve": False,
"update": False,
"partial_update": False,
"set_role_to": [],
}
@@ -122,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"],
}
@@ -137,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": [],
}
@@ -154,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"],
}
@@ -171,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"
@@ -188,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"],
}
@@ -225,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": [],
}
@@ -242,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"
@@ -259,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(
django_assert_num_queries,
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):
@@ -335,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)
@@ -414,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": [],
}

View File

@@ -1,10 +1,6 @@
"""
Unit tests for the Template model
"""
import os
from unittest import mock
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ValidationError
@@ -138,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)
@@ -174,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)
@@ -188,30 +167,3 @@ def test_models_templates_get_abilities_preset_role(django_assert_num_queries):
"partial_update": False,
"generate_document": True,
}
def test_models_templates__generate_word():
"""Generate word document and assert no tmp files are left in /tmp folder."""
template = factories.TemplateFactory()
response = template.generate_word("<p>Test body</p>", {})
assert response.status_code == 200
assert len([f for f in os.listdir("/tmp") if f.startswith("docx_")]) == 0
@mock.patch(
"pypandoc.convert_text",
side_effect=RuntimeError("Conversion failed"),
)
def test_models_templates__generate_word__raise_error(_mock_send_mail):
"""
Generate word document and assert no tmp files are left in /tmp folder
even when the conversion fails.
"""
template = factories.TemplateFactory()
try:
template.generate_word("<p>Test body</p>", {})
except RuntimeError as e:
assert str(e) == "Conversion failed"
assert len([f for f in os.listdir("/tmp") if f.startswith("docx_")]) == 0

View File

@@ -1,7 +1,6 @@
"""
Unit tests for the User model
"""
from unittest import mock
from django.core.exceptions import ValidationError

View File

@@ -1,87 +0,0 @@
"""
Unit tests for the Invitation model
"""
import smtplib
from logging import Logger
from unittest import mock
from django.core import mail
import pytest
from core.utils import email_invitation
pytestmark = pytest.mark.django_db
def test_utils__email_invitation_success():
"""
The email invitation is sent successfully.
"""
# pylint: disable-next=no-member
assert len(mail.outbox) == 0
email_invitation("en", "guest@example.com", "123-456-789")
# pylint: disable-next=no-member
assert len(mail.outbox) == 1
# pylint: disable-next=no-member
email = mail.outbox[0]
assert email.to == ["guest@example.com"]
email_content = " ".join(email.body.split())
assert "Invitation to join Docs!" in email_content
assert "docs/123-456-789/" in email_content
def test_utils__email_invitation_success_fr():
"""
The email invitation is sent successfully in french.
"""
# pylint: disable-next=no-member
assert len(mail.outbox) == 0
email_invitation("fr-fr", "guest@example.com", "123-456-789")
# pylint: disable-next=no-member
assert len(mail.outbox) == 1
# pylint: disable-next=no-member
email = mail.outbox[0]
assert email.to == ["guest@example.com"]
email_content = " ".join(email.body.split())
assert "Invitation à rejoindre Docs !" in email_content
assert "docs/123-456-789/" in email_content
@mock.patch(
"core.utils.send_mail",
side_effect=smtplib.SMTPException("Error SMTPException"),
)
@mock.patch.object(Logger, "error")
def test_utils__email_invitation_failed(mock_logger, _mock_send_mail):
"""Check mail behavior when an SMTP error occurs when sent an email invitation."""
# pylint: disable-next=no-member
assert len(mail.outbox) == 0
email_invitation("en", "guest@example.com", "123-456-789")
# No email has been sent
# pylint: disable-next=no-member
assert len(mail.outbox) == 0
# Logger should be called
mock_logger.assert_called_once()
(
_,
email,
exception,
) = mock_logger.call_args.args
assert email == "guest@example.com"
assert isinstance(exception, smtplib.SMTPException)

View File

@@ -1,5 +1,4 @@
"""URL configuration for the core app."""
from django.conf import settings
from django.urls import include, path, re_path

View File

@@ -1,40 +0,0 @@
"""
Utilities for the core app.
"""
import smtplib
from logging import getLogger
from django.conf import settings
from django.contrib.sites.models import Site
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.utils.translation import gettext_lazy as _
from django.utils.translation import override
logger = getLogger(__name__)
def email_invitation(language, email, document_id):
"""Send email invitation."""
try:
with override(language):
title = _("Invitation to join Docs!")
template_vars = {
"title": title,
"site": Site.objects.get_current(),
"document_id": document_id,
}
msg_html = render_to_string("mail/html/invitation.html", template_vars)
msg_plain = render_to_string("mail/text/invitation.txt", template_vars)
send_mail(
title,
msg_plain,
settings.EMAIL_FROM,
[email],
html_message=msg_html,
fail_silently=False,
)
except smtplib.SMTPException as exception:
logger.error("invitation to %s was not sent: %s", email, exception)

View File

@@ -1,10 +1,21 @@
<page size="A4">
<div class="header">
<img width="200"
src="https://impress-staging.beta.numerique.gouv.fr/assets/logo-gouv.png"
/>
<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>

View File

@@ -1,20 +1,55 @@
body {
background: white;
font-family: arial;
font-family: arial
}
.header img {
img {
width: 5cm;
margin-left: -0.4cm;
}
.body{
margin-top: 1.5rem;
.header {
display: flex;
justify-content: space-between;
}
img {
max-width: 100%;
}
[custom-style="center"] {
text-align: center;
}
[custom-style="right"] {
.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
}
h1 {
font-size: 18px;
}
h2 {
font-size: 14px;
}
p {
text-align: justify;
ligne-height: 0.8;
}

View File

@@ -1,23 +0,0 @@
"""Parameters that define how the demo site will be built."""
NB_OBJECTS = {
"users": 50,
"docs": 50,
"max_users_per_document": 50,
}
DEV_USERS = [
{
"username": "impress",
"email": "impress@impress.world",
},
{
"username": "user-e2e-webkit",
"email": "user@webkit.e2e",
},
{
"username": "user-e2e-firefox",
"email": "user@firefox.e2e",
},
{"username": "user-e2e-chromium", "email": "user@chromium.e2e"},
]

View File

@@ -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,94 +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 documents"):
for _ in range(defaults.NB_OBJECTS["docs"]):
queue.push(
models.Document(
title=fake.sentence(nb_words=4),
is_public=random_true_with_probability(0.5),
)
)
queue.flush()
with Timeit(stdout, "Creating docs accesses"):
docs_ids = list(models.Document.objects.values_list("id", flat=True))
users_ids = list(models.User.objects.values_list("id", flat=True))
for doc_id in docs_ids:
for user_id in random.sample(
users_ids,
random.randint(1, defaults.NB_OBJECTS["max_users_per_document"]),
):
role = random.choice(models.RoleChoices.choices)
queue.push(
models.DocumentAccess(
document_id=doc_id, user_id=user_id, role=role[0]
)
)
queue.flush()
with Timeit(stdout, "Creating development users"):
for dev_user in defaults.DEV_USERS:
queue.push(
models.User(
admin_email=dev_user["email"],
email=dev_user["email"],
sub=dev_user["email"],
password="!",
is_superuser=False,
is_active=True,
is_staff=False,
language=random.choice(settings.LANGUAGES)[0],
)
)
queue.flush()
with Timeit(stdout, "Creating docs accesses on development users"):
for dev_user in defaults.DEV_USERS:
docs_ids = list(models.Document.objects.values_list("id", flat=True))
user_id = models.User.objects.get(email=dev_user["email"]).id
for doc_id in docs_ids:
role = random.choice(models.RoleChoices.choices)
queue.push(
models.DocumentAccess(
document_id=doc_id, user_id=user_id, role=role[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,

View File

@@ -1,5 +1,4 @@
"""Management user to create a superuser."""
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand

View File

@@ -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
@@ -16,16 +18,3 @@ def test_commands_create_demo():
call_command("create_demo")
assert models.Template.objects.count() == 1
assert models.User.objects.count() >= 50
assert models.Document.objects.count() >= 50
assert models.DocumentAccess.objects.count() > 50
# assert dev users have doc accesses
user = models.User.objects.get(email="impress@impress.world")
assert models.DocumentAccess.objects.filter(user=user).exists()
user = models.User.objects.get(email="user@webkit.e2e")
assert models.DocumentAccess.objects.filter(user=user).exists()
user = models.User.objects.get(email="user@firefox.e2e")
assert models.DocumentAccess.objects.filter(user=user).exists()
user = models.User.objects.get(email="user@chromium.e2e")
assert models.DocumentAccess.objects.filter(user=user).exists()

View File

@@ -1,5 +1,4 @@
"""Impress celery configuration file."""
import os
from celery import Celery

View File

@@ -9,7 +9,6 @@ https://docs.djangoproject.com/en/3.1/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/3.1/ref/settings/
"""
import json
import os
@@ -138,24 +137,7 @@ class Base(Configuration):
environ_prefix=None,
)
# Document images
DOCUMENT_IMAGE_MAX_SIZE = values.Value(
10 * (2**20), # 10MB
environ_name="DOCUMENT_IMAGE_MAX_SIZE",
environ_prefix=None,
)
DOCUMENT_IMAGE_ALLOWED_MIME_TYPES = [
"image/bmp",
"image/gif",
"image/jpeg",
"image/png",
"image/svg+xml",
"image/tiff",
"image/webp",
]
# Document versions
DOCUMENT_VERSIONS_PAGE_SIZE = 50
S3_VERSIONS_PAGE_SIZE = 50
# Internationalization
# https://docs.djangoproject.com/en/3.1/topics/i18n/
@@ -296,7 +278,6 @@ class Base(Configuration):
EMAIL_HOST_PASSWORD = values.Value(None)
EMAIL_PORT = values.PositiveIntegerValue(None)
EMAIL_USE_TLS = values.BooleanValue(False)
EMAIL_USE_SSL = values.BooleanValue(False)
EMAIL_FROM = values.Value("from@example.com")
AUTH_USER_MODEL = "core.User"
@@ -314,7 +295,6 @@ class Base(Configuration):
# Easy thumbnails
THUMBNAIL_EXTENSION = "webp"
THUMBNAIL_TRANSPARENCY_EXTENSION = "webp"
THUMBNAIL_DEFAULT_STORAGE_ALIAS = "default"
THUMBNAIL_ALIASES = {}
# Celery

View File

@@ -1,563 +1,208 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: lasuite-people\n"
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-08-14 12:43+0000\n"
"PO-Revision-Date: 2024-08-14 12:48\n"
"Last-Translator: \n"
"Language-Team: English\n"
"Language: en_US\n"
"POT-Creation-Date: 2024-04-03 10:31+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Crowdin-Project: lasuite-people\n"
"X-Crowdin-Project-ID: 637934\n"
"X-Crowdin-Language: en\n"
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 8\n"
#: build/lib/build/lib/build/lib/build/lib/core/admin.py:31
#: build/lib/build/lib/build/lib/core/admin.py:31
#: build/lib/build/lib/core/admin.py:31 build/lib/core/admin.py:31
#: core/admin.py:31
msgid "Personal info"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/admin.py:33
#: build/lib/build/lib/build/lib/core/admin.py:33
#: build/lib/build/lib/core/admin.py:33 build/lib/core/admin.py:33
#: core/admin.py:33
msgid "Permissions"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/admin.py:45
#: build/lib/build/lib/build/lib/core/admin.py:45
#: build/lib/build/lib/core/admin.py:45 build/lib/core/admin.py:45
#: core/admin.py:45
msgid "Important dates"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/api/serializers.py:176
#: build/lib/build/lib/build/lib/core/api/serializers.py:176
#: build/lib/build/lib/core/api/serializers.py:176
#: build/lib/core/api/serializers.py:176 core/api/serializers.py:176
msgid "Body"
#: core/api/serializers.py:128
msgid "Markdown Body"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/api/serializers.py:179
#: build/lib/build/lib/build/lib/core/api/serializers.py:179
#: build/lib/build/lib/core/api/serializers.py:179
#: build/lib/core/api/serializers.py:179 core/api/serializers.py:179
msgid "Body type"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/authentication/backends.py:71
#: build/lib/build/lib/build/lib/core/authentication/backends.py:71
#: build/lib/build/lib/core/authentication/backends.py:71
#: build/lib/core/authentication/backends.py:71
#: core/authentication/backends.py:71
#: core/authentication.py:71
msgid "User info contained no recognizable user identification"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/authentication/backends.py:91
#: build/lib/build/lib/build/lib/core/authentication/backends.py:91
#: build/lib/build/lib/core/authentication/backends.py:91
#: build/lib/core/authentication/backends.py:91
#: core/authentication/backends.py:91
#: core/authentication.py:91
msgid "Claims contained no recognizable user identification"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:60
#: build/lib/build/lib/build/lib/core/models.py:60
#: build/lib/build/lib/core/models.py:60 build/lib/core/models.py:60
#: core/models.py:61
msgid "Reader"
#: core/models.py:27
msgid "Member"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:61
#: build/lib/build/lib/build/lib/core/models.py:61
#: build/lib/build/lib/core/models.py:61 build/lib/core/models.py:61
#: core/models.py:62
msgid "Editor"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:62
#: build/lib/build/lib/build/lib/core/models.py:62
#: build/lib/build/lib/core/models.py:62 build/lib/core/models.py:62
#: core/models.py:63
#: core/models.py:28
msgid "Administrator"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:63
#: build/lib/build/lib/build/lib/core/models.py:63
#: build/lib/build/lib/core/models.py:63 build/lib/core/models.py:63
#: core/models.py:64
#: core/models.py:29
msgid "Owner"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:75
#: build/lib/build/lib/build/lib/core/models.py:75
#: build/lib/build/lib/core/models.py:75 build/lib/core/models.py:75
#: core/models.py:76
#: core/models.py:41
msgid "id"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:76
#: build/lib/build/lib/build/lib/core/models.py:76
#: build/lib/build/lib/core/models.py:76 build/lib/core/models.py:76
#: core/models.py:77
#: core/models.py:42
msgid "primary key for the record as UUID"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:82
#: build/lib/build/lib/build/lib/core/models.py:82
#: build/lib/build/lib/core/models.py:82 build/lib/core/models.py:82
#: core/models.py:83
#: core/models.py:48
msgid "created on"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:83
#: build/lib/build/lib/build/lib/core/models.py:83
#: build/lib/build/lib/core/models.py:83 build/lib/core/models.py:83
#: core/models.py:84
#: core/models.py:49
msgid "date and time at which a record was created"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:88
#: build/lib/build/lib/build/lib/core/models.py:88
#: build/lib/build/lib/core/models.py:88 build/lib/core/models.py:88
#: core/models.py:89
#: core/models.py:54
msgid "updated on"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:89
#: build/lib/build/lib/build/lib/core/models.py:89
#: build/lib/build/lib/core/models.py:89 build/lib/core/models.py:89
#: core/models.py:90
#: core/models.py:55
msgid "date and time at which a record was last updated"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:109
#: build/lib/build/lib/build/lib/core/models.py:109
#: build/lib/build/lib/core/models.py:109 build/lib/core/models.py:109
#: core/models.py:110
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters."
#: core/models.py:75
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:115
#: build/lib/build/lib/build/lib/core/models.py:115
#: build/lib/build/lib/core/models.py:115 build/lib/core/models.py:115
#: core/models.py:116
#: core/models.py:81
msgid "sub"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:117
#: build/lib/build/lib/build/lib/core/models.py:117
#: build/lib/build/lib/core/models.py:117 build/lib/core/models.py:117
#: core/models.py:118
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
#: core/models.py:83
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:125
#: build/lib/build/lib/build/lib/core/models.py:125
#: build/lib/build/lib/core/models.py:125 build/lib/core/models.py:125
#: core/models.py:126
#: core/models.py:91
msgid "identity email address"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:130
#: build/lib/build/lib/build/lib/core/models.py:130
#: build/lib/build/lib/core/models.py:130 build/lib/core/models.py:130
#: core/models.py:131
#: core/models.py:96
msgid "admin email address"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:137
#: build/lib/build/lib/build/lib/core/models.py:137
#: build/lib/build/lib/core/models.py:137 build/lib/core/models.py:137
#: core/models.py:138
#: core/models.py:103
msgid "language"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:138
#: build/lib/build/lib/build/lib/core/models.py:138
#: build/lib/build/lib/core/models.py:138 build/lib/core/models.py:138
#: core/models.py:139
#: core/models.py:104
msgid "The language in which the user wants to see the interface."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:144
#: build/lib/build/lib/build/lib/core/models.py:144
#: build/lib/build/lib/core/models.py:144 build/lib/core/models.py:144
#: core/models.py:145
#: core/models.py:110
msgid "The timezone in which the user wants to see times."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:147
#: build/lib/build/lib/build/lib/core/models.py:147
#: build/lib/build/lib/core/models.py:147 build/lib/core/models.py:147
#: core/models.py:148
#: core/models.py:113
msgid "device"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:149
#: build/lib/build/lib/build/lib/core/models.py:149
#: build/lib/build/lib/core/models.py:149 build/lib/core/models.py:149
#: core/models.py:150
#: core/models.py:115
msgid "Whether the user is a device or a real user."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:152
#: build/lib/build/lib/build/lib/core/models.py:152
#: build/lib/build/lib/core/models.py:152 build/lib/core/models.py:152
#: core/models.py:153
#: core/models.py:118
msgid "staff status"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:154
#: build/lib/build/lib/build/lib/core/models.py:154
#: build/lib/build/lib/core/models.py:154 build/lib/core/models.py:154
#: core/models.py:155
#: core/models.py:120
msgid "Whether the user can log into this admin site."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:157
#: build/lib/build/lib/build/lib/core/models.py:157
#: build/lib/build/lib/core/models.py:157 build/lib/core/models.py:157
#: core/models.py:158
#: core/models.py:123
msgid "active"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:160
#: build/lib/build/lib/build/lib/core/models.py:160
#: build/lib/build/lib/core/models.py:160 build/lib/core/models.py:160
#: core/models.py:161
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
#: core/models.py:126
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:172
#: build/lib/build/lib/build/lib/core/models.py:172
#: build/lib/build/lib/core/models.py:172 build/lib/core/models.py:172
#: core/models.py:173
#: core/models.py:138
msgid "user"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:173
#: build/lib/build/lib/build/lib/core/models.py:173
#: build/lib/build/lib/core/models.py:173 build/lib/core/models.py:173
#: core/models.py:174
#: core/models.py:139
msgid "users"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:304
#: build/lib/build/lib/build/lib/build/lib/core/models.py:531
#: build/lib/build/lib/build/lib/core/models.py:304
#: build/lib/build/lib/build/lib/core/models.py:531
#: build/lib/build/lib/core/models.py:304
#: build/lib/build/lib/core/models.py:531 build/lib/core/models.py:304
#: build/lib/core/models.py:531 core/models.py:305 core/models.py:532
#: core/models.py:161
msgid "title"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:306
#: build/lib/build/lib/build/lib/build/lib/core/models.py:536
#: build/lib/build/lib/build/lib/core/models.py:306
#: build/lib/build/lib/build/lib/core/models.py:536
#: build/lib/build/lib/core/models.py:306
#: build/lib/build/lib/core/models.py:536 build/lib/core/models.py:306
#: build/lib/core/models.py:536 core/models.py:307 core/models.py:537
msgid "public"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:308
#: build/lib/build/lib/build/lib/core/models.py:308
#: build/lib/build/lib/core/models.py:308 build/lib/core/models.py:308
#: core/models.py:309
msgid "Whether this document is public for anyone to use."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:316
#: build/lib/build/lib/build/lib/core/models.py:316
#: build/lib/build/lib/core/models.py:316 build/lib/core/models.py:316
#: core/models.py:317
msgid "Document"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:317
#: build/lib/build/lib/build/lib/core/models.py:317
#: build/lib/build/lib/core/models.py:317 build/lib/core/models.py:317
#: core/models.py:318
msgid "Documents"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:495
#: build/lib/build/lib/build/lib/core/models.py:495
#: build/lib/build/lib/core/models.py:495 build/lib/core/models.py:495
#: core/models.py:496
msgid "Document/user relation"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:496
#: build/lib/build/lib/build/lib/core/models.py:496
#: build/lib/build/lib/core/models.py:496 build/lib/core/models.py:496
#: core/models.py:497
msgid "Document/user relations"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:502
#: build/lib/build/lib/build/lib/core/models.py:502
#: build/lib/build/lib/core/models.py:502 build/lib/core/models.py:502
#: core/models.py:503
msgid "This user is already in this document."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:508
#: build/lib/build/lib/build/lib/core/models.py:508
#: build/lib/build/lib/core/models.py:508 build/lib/core/models.py:508
#: core/models.py:509
msgid "This team is already in this document."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:514
#: build/lib/build/lib/build/lib/build/lib/core/models.py:691
#: build/lib/build/lib/build/lib/core/models.py:514
#: build/lib/build/lib/build/lib/core/models.py:691
#: build/lib/build/lib/core/models.py:514
#: build/lib/build/lib/core/models.py:691 build/lib/core/models.py:514
#: build/lib/core/models.py:691 core/models.py:515 core/models.py:704
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:532
#: build/lib/build/lib/build/lib/core/models.py:532
#: build/lib/build/lib/core/models.py:532 build/lib/core/models.py:532
#: core/models.py:533
#: core/models.py:162
msgid "description"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:533
#: build/lib/build/lib/build/lib/core/models.py:533
#: build/lib/build/lib/core/models.py:533 build/lib/core/models.py:533
#: core/models.py:534
#: core/models.py:163
msgid "code"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:534
#: build/lib/build/lib/build/lib/core/models.py:534
#: build/lib/build/lib/core/models.py:534 build/lib/core/models.py:534
#: core/models.py:535
#: core/models.py:164
msgid "css"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:538
#: build/lib/build/lib/build/lib/core/models.py:538
#: build/lib/build/lib/core/models.py:538 build/lib/core/models.py:538
#: core/models.py:539
#: core/models.py:166
msgid "public"
msgstr ""
#: core/models.py:168
msgid "Whether this template is public for anyone to use."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:544
#: build/lib/build/lib/build/lib/core/models.py:544
#: build/lib/build/lib/core/models.py:544 build/lib/core/models.py:544
#: core/models.py:545
#: core/models.py:174
msgid "Template"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:545
#: build/lib/build/lib/build/lib/core/models.py:545
#: build/lib/build/lib/core/models.py:545 build/lib/core/models.py:545
#: core/models.py:546
#: core/models.py:175
msgid "Templates"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:672
#: build/lib/build/lib/build/lib/core/models.py:672
#: build/lib/build/lib/core/models.py:672 build/lib/core/models.py:672
#: core/models.py:685
#: core/models.py:256
msgid "Template/user relation"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:673
#: build/lib/build/lib/build/lib/core/models.py:673
#: build/lib/build/lib/core/models.py:673 build/lib/core/models.py:673
#: core/models.py:686
#: core/models.py:257
msgid "Template/user relations"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:679
#: build/lib/build/lib/build/lib/core/models.py:679
#: build/lib/build/lib/core/models.py:679 build/lib/core/models.py:679
#: core/models.py:692
#: core/models.py:263
msgid "This user is already in this template."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:685
#: build/lib/build/lib/build/lib/core/models.py:685
#: build/lib/build/lib/core/models.py:685 build/lib/core/models.py:685
#: core/models.py:698
#: core/models.py:269
msgid "This team is already in this template."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:708
#: build/lib/build/lib/build/lib/core/models.py:708
#: build/lib/build/lib/core/models.py:708 build/lib/core/models.py:708
#: core/models.py:721
msgid "email address"
#: core/models.py:275
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:725
#: build/lib/build/lib/build/lib/core/models.py:725
#: build/lib/build/lib/core/models.py:725 build/lib/core/models.py:725
#: core/models.py:738
msgid "Document invitation"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:726
#: build/lib/build/lib/build/lib/core/models.py:726
#: build/lib/build/lib/core/models.py:726 build/lib/core/models.py:726
#: core/models.py:739
msgid "Document invitations"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:751
#: build/lib/build/lib/build/lib/core/models.py:751
#: build/lib/build/lib/core/models.py:751 build/lib/core/models.py:751
#: core/models.py:764
msgid "This email is already associated to a registered user."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:795
#: build/lib/build/lib/build/lib/core/models.py:795
#: build/lib/build/lib/core/models.py:795 build/lib/core/models.py:795
msgid "Invitation to join Impress!"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/impress/settings.py:158
#: build/lib/build/lib/build/lib/impress/settings.py:158
#: build/lib/build/lib/impress/settings.py:158
#: build/lib/impress/settings.py:158 impress/settings.py:158
#: impress/settings.py:134
msgid "English"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/impress/settings.py:159
#: build/lib/build/lib/build/lib/impress/settings.py:159
#: build/lib/build/lib/impress/settings.py:159
#: build/lib/impress/settings.py:159 impress/settings.py:159
#: impress/settings.py:135
msgid "French"
msgstr ""
#: build/lib/build/lib/core/api/serializers.py:185
#: build/lib/core/api/serializers.py:185 core/api/serializers.py:185
msgid "Format"
msgstr ""
#: core/models.py:808
msgid "Invitation to join Docs!"
msgstr ""
#: core/templates/mail/html/hello.html:159 core/templates/mail/text/hello.txt:3
msgid "Company logo"
msgstr ""
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
#, python-format
msgid "Hello %(name)s"
msgstr ""
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
msgid "Hello"
msgstr ""
#: core/templates/mail/html/hello.html:189 core/templates/mail/text/hello.txt:6
msgid "Thank you very much for your visit!"
msgstr ""
#: core/templates/mail/html/hello.html:221
#, python-format
msgid "This mail has been sent to %(email)s by <a href=\"%(href)s\">%(name)s</a>"
msgstr ""
#: core/templates/mail/html/invitation.html:160
#: core/templates/mail/text/invitation.txt:3
msgid "La Suite Numérique"
msgstr ""
#: core/templates/mail/html/invitation.html:190
#: core/templates/mail/text/invitation.txt:5
msgid "Invitation to join a document !"
msgstr ""
#: core/templates/mail/html/invitation.html:198
msgid "Welcome to <strong>Docs!</strong>"
msgstr ""
#: core/templates/mail/html/invitation.html:213
#: core/templates/mail/text/invitation.txt:12
msgid "We are delighted to welcome you to our community on Docs, your new companion to collaborate on documents efficiently, intuitively, and securely."
msgstr ""
#: core/templates/mail/html/invitation.html:218
#: core/templates/mail/text/invitation.txt:13
msgid "Our application is designed to help you organize, collaborate, and manage permissions."
msgstr ""
#: core/templates/mail/html/invitation.html:223
#: core/templates/mail/text/invitation.txt:14
msgid "With Docs, you will be able to:"
msgstr ""
#: core/templates/mail/html/invitation.html:224
#: core/templates/mail/text/invitation.txt:15
msgid "Create documents."
msgstr ""
#: core/templates/mail/html/invitation.html:225
#: core/templates/mail/text/invitation.txt:16
msgid "Work offline."
msgstr ""
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:17
msgid "Invite members of your community to your document in just a few clicks."
msgstr ""
#: core/templates/mail/html/invitation.html:237
#: core/templates/mail/text/invitation.txt:19
msgid "Visit Docs"
msgstr ""
#: core/templates/mail/html/invitation.html:246
#: core/templates/mail/text/invitation.txt:21
msgid "We are confident that Docs will help you increase efficiency and productivity while strengthening the bond among members."
msgstr ""
#: core/templates/mail/html/invitation.html:251
#: core/templates/mail/text/invitation.txt:22
msgid "Feel free to explore all the features of the application and share your feedback and suggestions with us. Your feedback is valuable to us and will enable us to continually improve our service."
msgstr ""
#: core/templates/mail/html/invitation.html:256
#: core/templates/mail/text/invitation.txt:23
msgid "Once again, welcome aboard! We are eager to accompany you on your collaboration adventure."
msgstr ""
#: core/templates/mail/html/invitation.html:263
#: core/templates/mail/text/invitation.txt:25
msgid "Sincerely,"
msgstr ""
#: core/templates/mail/html/invitation.html:264
#: core/templates/mail/text/invitation.txt:27
msgid "The La Suite Numérique Team"
msgstr ""
#: core/templates/mail/text/hello.txt:8
#, python-format
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
msgstr ""
#: core/templates/mail/text/invitation.txt:8
msgid "Welcome to Docs!"
msgstr ""

View File

@@ -1,563 +1,208 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: lasuite-people\n"
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-08-14 12:43+0000\n"
"PO-Revision-Date: 2024-08-14 12:48\n"
"Last-Translator: \n"
"Language-Team: French\n"
"Language: fr_FR\n"
"POT-Creation-Date: 2024-04-03 10:31+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
"X-Crowdin-Project: lasuite-people\n"
"X-Crowdin-Project-ID: 637934\n"
"X-Crowdin-Language: fr\n"
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 8\n"
#: build/lib/build/lib/build/lib/build/lib/core/admin.py:31
#: build/lib/build/lib/build/lib/core/admin.py:31
#: build/lib/build/lib/core/admin.py:31 build/lib/core/admin.py:31
#: core/admin.py:31
msgid "Personal info"
msgstr "Infos Personnelles"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/admin.py:33
#: build/lib/build/lib/build/lib/core/admin.py:33
#: build/lib/build/lib/core/admin.py:33 build/lib/core/admin.py:33
#: core/admin.py:33
msgid "Permissions"
msgstr "Permissions"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/admin.py:45
#: build/lib/build/lib/build/lib/core/admin.py:45
#: build/lib/build/lib/core/admin.py:45 build/lib/core/admin.py:45
#: core/admin.py:45
msgid "Important dates"
msgstr "Dates importantes"
#: build/lib/build/lib/build/lib/build/lib/core/api/serializers.py:176
#: build/lib/build/lib/build/lib/core/api/serializers.py:176
#: build/lib/build/lib/core/api/serializers.py:176
#: build/lib/core/api/serializers.py:176 core/api/serializers.py:176
msgid "Body"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/api/serializers.py:179
#: build/lib/build/lib/build/lib/core/api/serializers.py:179
#: build/lib/build/lib/core/api/serializers.py:179
#: build/lib/core/api/serializers.py:179 core/api/serializers.py:179
msgid "Body type"
#: core/api/serializers.py:128
msgid "Markdown Body"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/authentication/backends.py:71
#: build/lib/build/lib/build/lib/core/authentication/backends.py:71
#: build/lib/build/lib/core/authentication/backends.py:71
#: build/lib/core/authentication/backends.py:71
#: core/authentication/backends.py:71
#: core/authentication.py:71
msgid "User info contained no recognizable user identification"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/authentication/backends.py:91
#: build/lib/build/lib/build/lib/core/authentication/backends.py:91
#: build/lib/build/lib/core/authentication/backends.py:91
#: build/lib/core/authentication/backends.py:91
#: core/authentication/backends.py:91
#: core/authentication.py:91
msgid "Claims contained no recognizable user identification"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:60
#: build/lib/build/lib/build/lib/core/models.py:60
#: build/lib/build/lib/core/models.py:60 build/lib/core/models.py:60
#: core/models.py:61
msgid "Reader"
#: core/models.py:27
msgid "Member"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:61
#: build/lib/build/lib/build/lib/core/models.py:61
#: build/lib/build/lib/core/models.py:61 build/lib/core/models.py:61
#: core/models.py:62
msgid "Editor"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:62
#: build/lib/build/lib/build/lib/core/models.py:62
#: build/lib/build/lib/core/models.py:62 build/lib/core/models.py:62
#: core/models.py:63
#: core/models.py:28
msgid "Administrator"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:63
#: build/lib/build/lib/build/lib/core/models.py:63
#: build/lib/build/lib/core/models.py:63 build/lib/core/models.py:63
#: core/models.py:64
#: core/models.py:29
msgid "Owner"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:75
#: build/lib/build/lib/build/lib/core/models.py:75
#: build/lib/build/lib/core/models.py:75 build/lib/core/models.py:75
#: core/models.py:76
#: core/models.py:41
msgid "id"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:76
#: build/lib/build/lib/build/lib/core/models.py:76
#: build/lib/build/lib/core/models.py:76 build/lib/core/models.py:76
#: core/models.py:77
#: core/models.py:42
msgid "primary key for the record as UUID"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:82
#: build/lib/build/lib/build/lib/core/models.py:82
#: build/lib/build/lib/core/models.py:82 build/lib/core/models.py:82
#: core/models.py:83
#: core/models.py:48
msgid "created on"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:83
#: build/lib/build/lib/build/lib/core/models.py:83
#: build/lib/build/lib/core/models.py:83 build/lib/core/models.py:83
#: core/models.py:84
#: core/models.py:49
msgid "date and time at which a record was created"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:88
#: build/lib/build/lib/build/lib/core/models.py:88
#: build/lib/build/lib/core/models.py:88 build/lib/core/models.py:88
#: core/models.py:89
#: core/models.py:54
msgid "updated on"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:89
#: build/lib/build/lib/build/lib/core/models.py:89
#: build/lib/build/lib/core/models.py:89 build/lib/core/models.py:89
#: core/models.py:90
#: core/models.py:55
msgid "date and time at which a record was last updated"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:109
#: build/lib/build/lib/build/lib/core/models.py:109
#: build/lib/build/lib/core/models.py:109 build/lib/core/models.py:109
#: core/models.py:110
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters."
#: core/models.py:75
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:115
#: build/lib/build/lib/build/lib/core/models.py:115
#: build/lib/build/lib/core/models.py:115 build/lib/core/models.py:115
#: core/models.py:116
#: core/models.py:81
msgid "sub"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:117
#: build/lib/build/lib/build/lib/core/models.py:117
#: build/lib/build/lib/core/models.py:117 build/lib/core/models.py:117
#: core/models.py:118
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
#: core/models.py:83
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:125
#: build/lib/build/lib/build/lib/core/models.py:125
#: build/lib/build/lib/core/models.py:125 build/lib/core/models.py:125
#: core/models.py:126
#: core/models.py:91
msgid "identity email address"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:130
#: build/lib/build/lib/build/lib/core/models.py:130
#: build/lib/build/lib/core/models.py:130 build/lib/core/models.py:130
#: core/models.py:131
#: core/models.py:96
msgid "admin email address"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:137
#: build/lib/build/lib/build/lib/core/models.py:137
#: build/lib/build/lib/core/models.py:137 build/lib/core/models.py:137
#: core/models.py:138
#: core/models.py:103
msgid "language"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:138
#: build/lib/build/lib/build/lib/core/models.py:138
#: build/lib/build/lib/core/models.py:138 build/lib/core/models.py:138
#: core/models.py:139
#: core/models.py:104
msgid "The language in which the user wants to see the interface."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:144
#: build/lib/build/lib/build/lib/core/models.py:144
#: build/lib/build/lib/core/models.py:144 build/lib/core/models.py:144
#: core/models.py:145
#: core/models.py:110
msgid "The timezone in which the user wants to see times."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:147
#: build/lib/build/lib/build/lib/core/models.py:147
#: build/lib/build/lib/core/models.py:147 build/lib/core/models.py:147
#: core/models.py:148
#: core/models.py:113
msgid "device"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:149
#: build/lib/build/lib/build/lib/core/models.py:149
#: build/lib/build/lib/core/models.py:149 build/lib/core/models.py:149
#: core/models.py:150
#: core/models.py:115
msgid "Whether the user is a device or a real user."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:152
#: build/lib/build/lib/build/lib/core/models.py:152
#: build/lib/build/lib/core/models.py:152 build/lib/core/models.py:152
#: core/models.py:153
#: core/models.py:118
msgid "staff status"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:154
#: build/lib/build/lib/build/lib/core/models.py:154
#: build/lib/build/lib/core/models.py:154 build/lib/core/models.py:154
#: core/models.py:155
#: core/models.py:120
msgid "Whether the user can log into this admin site."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:157
#: build/lib/build/lib/build/lib/core/models.py:157
#: build/lib/build/lib/core/models.py:157 build/lib/core/models.py:157
#: core/models.py:158
#: core/models.py:123
msgid "active"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:160
#: build/lib/build/lib/build/lib/core/models.py:160
#: build/lib/build/lib/core/models.py:160 build/lib/core/models.py:160
#: core/models.py:161
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
#: core/models.py:126
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:172
#: build/lib/build/lib/build/lib/core/models.py:172
#: build/lib/build/lib/core/models.py:172 build/lib/core/models.py:172
#: core/models.py:173
#: core/models.py:138
msgid "user"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:173
#: build/lib/build/lib/build/lib/core/models.py:173
#: build/lib/build/lib/core/models.py:173 build/lib/core/models.py:173
#: core/models.py:174
#: core/models.py:139
msgid "users"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:304
#: build/lib/build/lib/build/lib/build/lib/core/models.py:531
#: build/lib/build/lib/build/lib/core/models.py:304
#: build/lib/build/lib/build/lib/core/models.py:531
#: build/lib/build/lib/core/models.py:304
#: build/lib/build/lib/core/models.py:531 build/lib/core/models.py:304
#: build/lib/core/models.py:531 core/models.py:305 core/models.py:532
#: core/models.py:161
msgid "title"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:306
#: build/lib/build/lib/build/lib/build/lib/core/models.py:536
#: build/lib/build/lib/build/lib/core/models.py:306
#: build/lib/build/lib/build/lib/core/models.py:536
#: build/lib/build/lib/core/models.py:306
#: build/lib/build/lib/core/models.py:536 build/lib/core/models.py:306
#: build/lib/core/models.py:536 core/models.py:307 core/models.py:537
msgid "public"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:308
#: build/lib/build/lib/build/lib/core/models.py:308
#: build/lib/build/lib/core/models.py:308 build/lib/core/models.py:308
#: core/models.py:309
msgid "Whether this document is public for anyone to use."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:316
#: build/lib/build/lib/build/lib/core/models.py:316
#: build/lib/build/lib/core/models.py:316 build/lib/core/models.py:316
#: core/models.py:317
msgid "Document"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:317
#: build/lib/build/lib/build/lib/core/models.py:317
#: build/lib/build/lib/core/models.py:317 build/lib/core/models.py:317
#: core/models.py:318
msgid "Documents"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:495
#: build/lib/build/lib/build/lib/core/models.py:495
#: build/lib/build/lib/core/models.py:495 build/lib/core/models.py:495
#: core/models.py:496
msgid "Document/user relation"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:496
#: build/lib/build/lib/build/lib/core/models.py:496
#: build/lib/build/lib/core/models.py:496 build/lib/core/models.py:496
#: core/models.py:497
msgid "Document/user relations"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:502
#: build/lib/build/lib/build/lib/core/models.py:502
#: build/lib/build/lib/core/models.py:502 build/lib/core/models.py:502
#: core/models.py:503
msgid "This user is already in this document."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:508
#: build/lib/build/lib/build/lib/core/models.py:508
#: build/lib/build/lib/core/models.py:508 build/lib/core/models.py:508
#: core/models.py:509
msgid "This team is already in this document."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:514
#: build/lib/build/lib/build/lib/build/lib/core/models.py:691
#: build/lib/build/lib/build/lib/core/models.py:514
#: build/lib/build/lib/build/lib/core/models.py:691
#: build/lib/build/lib/core/models.py:514
#: build/lib/build/lib/core/models.py:691 build/lib/core/models.py:514
#: build/lib/core/models.py:691 core/models.py:515 core/models.py:704
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:532
#: build/lib/build/lib/build/lib/core/models.py:532
#: build/lib/build/lib/core/models.py:532 build/lib/core/models.py:532
#: core/models.py:533
#: core/models.py:162
msgid "description"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:533
#: build/lib/build/lib/build/lib/core/models.py:533
#: build/lib/build/lib/core/models.py:533 build/lib/core/models.py:533
#: core/models.py:534
#: core/models.py:163
msgid "code"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:534
#: build/lib/build/lib/build/lib/core/models.py:534
#: build/lib/build/lib/core/models.py:534 build/lib/core/models.py:534
#: core/models.py:535
#: core/models.py:164
msgid "css"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:538
#: build/lib/build/lib/build/lib/core/models.py:538
#: build/lib/build/lib/core/models.py:538 build/lib/core/models.py:538
#: core/models.py:539
#: core/models.py:166
msgid "public"
msgstr ""
#: core/models.py:168
msgid "Whether this template is public for anyone to use."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:544
#: build/lib/build/lib/build/lib/core/models.py:544
#: build/lib/build/lib/core/models.py:544 build/lib/core/models.py:544
#: core/models.py:545
#: core/models.py:174
msgid "Template"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:545
#: build/lib/build/lib/build/lib/core/models.py:545
#: build/lib/build/lib/core/models.py:545 build/lib/core/models.py:545
#: core/models.py:546
#: core/models.py:175
msgid "Templates"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:672
#: build/lib/build/lib/build/lib/core/models.py:672
#: build/lib/build/lib/core/models.py:672 build/lib/core/models.py:672
#: core/models.py:685
#: core/models.py:256
msgid "Template/user relation"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:673
#: build/lib/build/lib/build/lib/core/models.py:673
#: build/lib/build/lib/core/models.py:673 build/lib/core/models.py:673
#: core/models.py:686
#: core/models.py:257
msgid "Template/user relations"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:679
#: build/lib/build/lib/build/lib/core/models.py:679
#: build/lib/build/lib/core/models.py:679 build/lib/core/models.py:679
#: core/models.py:692
#: core/models.py:263
msgid "This user is already in this template."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:685
#: build/lib/build/lib/build/lib/core/models.py:685
#: build/lib/build/lib/core/models.py:685 build/lib/core/models.py:685
#: core/models.py:698
#: core/models.py:269
msgid "This team is already in this template."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:708
#: build/lib/build/lib/build/lib/core/models.py:708
#: build/lib/build/lib/core/models.py:708 build/lib/core/models.py:708
#: core/models.py:721
msgid "email address"
#: core/models.py:275
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:725
#: build/lib/build/lib/build/lib/core/models.py:725
#: build/lib/build/lib/core/models.py:725 build/lib/core/models.py:725
#: core/models.py:738
msgid "Document invitation"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:726
#: build/lib/build/lib/build/lib/core/models.py:726
#: build/lib/build/lib/core/models.py:726 build/lib/core/models.py:726
#: core/models.py:739
msgid "Document invitations"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:751
#: build/lib/build/lib/build/lib/core/models.py:751
#: build/lib/build/lib/core/models.py:751 build/lib/core/models.py:751
#: core/models.py:764
msgid "This email is already associated to a registered user."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:795
#: build/lib/build/lib/build/lib/core/models.py:795
#: build/lib/build/lib/core/models.py:795 build/lib/core/models.py:795
msgid "Invitation to join Impress!"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/impress/settings.py:158
#: build/lib/build/lib/build/lib/impress/settings.py:158
#: build/lib/build/lib/impress/settings.py:158
#: build/lib/impress/settings.py:158 impress/settings.py:158
#: impress/settings.py:134
msgid "English"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/impress/settings.py:159
#: build/lib/build/lib/build/lib/impress/settings.py:159
#: build/lib/build/lib/impress/settings.py:159
#: build/lib/impress/settings.py:159 impress/settings.py:159
#: impress/settings.py:135
msgid "French"
msgstr ""
#: build/lib/build/lib/core/api/serializers.py:185
#: build/lib/core/api/serializers.py:185 core/api/serializers.py:185
msgid "Format"
msgstr ""
#: core/models.py:808
msgid "Invitation to join Docs!"
msgstr "Invitation à rejoindre Docs !"
#: core/templates/mail/html/hello.html:159 core/templates/mail/text/hello.txt:3
msgid "Company logo"
msgstr ""
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
#, python-format
msgid "Hello %(name)s"
msgstr ""
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
msgid "Hello"
msgstr ""
#: core/templates/mail/html/hello.html:189 core/templates/mail/text/hello.txt:6
msgid "Thank you very much for your visit!"
msgstr ""
#: core/templates/mail/html/hello.html:221
#, python-format
msgid "This mail has been sent to %(email)s by <a href=\"%(href)s\">%(name)s</a>"
msgstr ""
#: core/templates/mail/html/invitation.html:160
#: core/templates/mail/text/invitation.txt:3
msgid "La Suite Numérique"
msgstr ""
#: core/templates/mail/html/invitation.html:190
#: core/templates/mail/text/invitation.txt:5
msgid "Invitation to join a document !"
msgstr "Invitation à rejoindre un document !"
#: core/templates/mail/html/invitation.html:198
msgid "Welcome to <strong>Docs!</strong>"
msgstr "Bienvenue sur <strong>Docs !</strong>"
#: core/templates/mail/html/invitation.html:213
#: core/templates/mail/text/invitation.txt:12
msgid "We are delighted to welcome you to our community on Docs, your new companion to collaborate on documents efficiently, intuitively, and securely."
msgstr "Nous sommes heureux de vous accueillir dans notre communauté sur Docs, votre nouveau compagnon pour collaborer sur des documents efficacement, intuitivement, et en toute sécurité."
#: core/templates/mail/html/invitation.html:218
#: core/templates/mail/text/invitation.txt:13
msgid "Our application is designed to help you organize, collaborate, and manage permissions."
msgstr "Notre application est conçue pour vous aider à organiser, collaborer et gérer vos permissions."
#: core/templates/mail/html/invitation.html:223
#: core/templates/mail/text/invitation.txt:14
msgid "With Docs, you will be able to:"
msgstr "Avec Docs, vous serez capable de :"
#: core/templates/mail/html/invitation.html:224
#: core/templates/mail/text/invitation.txt:15
msgid "Create documents."
msgstr "Créez des documents."
#: core/templates/mail/html/invitation.html:225
#: core/templates/mail/text/invitation.txt:16
msgid "Work offline."
msgstr "Travailler hors ligne."
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:17
msgid "Invite members of your community to your document in just a few clicks."
msgstr "Invitez des membres de votre communauté sur votre document en quelques clics."
#: core/templates/mail/html/invitation.html:237
#: core/templates/mail/text/invitation.txt:19
msgid "Visit Docs"
msgstr "Visitez Docs"
#: core/templates/mail/html/invitation.html:246
#: core/templates/mail/text/invitation.txt:21
msgid "We are confident that Docs will help you increase efficiency and productivity while strengthening the bond among members."
msgstr "Nous sommes persuadés que Docs vous aidera à améliorer votre efficacité et votre productivité tout en renforçant les liens entre vos membres."
#: core/templates/mail/html/invitation.html:251
#: core/templates/mail/text/invitation.txt:22
msgid "Feel free to explore all the features of the application and share your feedback and suggestions with us. Your feedback is valuable to us and will enable us to continually improve our service."
msgstr "N'hésitez pas à explorer toutes les fonctionnalités de l'application et à nous faire part de vos commentaires et suggestions. Vos commentaires nous sont précieux et nous permettront d'améliorer continuellement notre service."
#: core/templates/mail/html/invitation.html:256
#: core/templates/mail/text/invitation.txt:23
msgid "Once again, welcome aboard! We are eager to accompany you on your collaboration adventure."
msgstr "Encore une fois, bienvenue à bord ! Nous sommes impatients de vous accompagner dans votre aventure collaborative."
#: core/templates/mail/html/invitation.html:263
#: core/templates/mail/text/invitation.txt:25
msgid "Sincerely,"
msgstr "Sincèrement,"
#: core/templates/mail/html/invitation.html:264
#: core/templates/mail/text/invitation.txt:27
msgid "The La Suite Numérique Team"
msgstr "L'équipe La Suite Numérique"
#: core/templates/mail/text/hello.txt:8
#, python-format
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
msgstr ""
#: core/templates/mail/text/invitation.txt:8
msgid "Welcome to Docs!"
msgstr "Bienvenue sur Docs !"

View File

@@ -2,7 +2,6 @@
"""
impress's sandbox management script.
"""
import os
import sys

View File

@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "impress"
version = "1.3.0"
version = "0.1.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -25,38 +25,37 @@ license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"boto3==1.35.10",
"boto3==1.33.6",
"Brotli==1.1.0",
"celery[redis]==5.4.0",
"django-configurations==2.5.1",
"django-cors-headers==4.4.0",
"django-countries==7.6.1",
"celery[redis]==5.3.6",
"django-configurations==2.5",
"django-cors-headers==4.3.1",
"django-countries==7.5.1",
"django-parler==2.3",
"redis==5.0.8",
"redis==5.0.3",
"django-redis==5.4.0",
"django-storages[s3]==1.14.4",
"django-storages[s3]==1.14.2",
"django-timezone-field>=5.1",
"django==5.1",
"djangorestframework==3.15.2",
"drf_spectacular==0.27.2",
"dockerflow==2024.4.2",
"easy_thumbnails==2.9",
"factory_boy==3.3.1",
"freezegun==1.5.1",
"gunicorn==23.0.0",
"jsonschema==4.23.0",
"markdown==3.7",
"django==5.0.3",
"djangorestframework==3.14.0",
"drf_spectacular==0.26.5",
"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",
"nested-multipart-parser==1.5.0",
"psycopg[binary]==3.2.1",
"PyJWT==2.9.0",
"pypandoc==1.13",
"python-frontmatter==1.1.0",
"requests==2.32.3",
"sentry-sdk==2.13.0",
"psycopg[binary]==3.1.14",
"PyJWT==2.8.0",
"python-frontmatter==1.0.1",
"requests==2.31.0",
"sentry-sdk==1.38.0",
"url-normalize==1.4.3",
"WeasyPrint>=60.2",
"whitenoise==6.7.0",
"mozilla-django-oidc==4.0.1",
"whitenoise==6.6.0",
"mozilla-django-oidc==4.0.0",
]
[project.urls]
@@ -68,20 +67,20 @@ dependencies = [
[project.optional-dependencies]
dev = [
"django-extensions==3.2.3",
"drf-spectacular-sidecar==2024.7.1",
"drf-spectacular-sidecar==2023.12.1",
"ipdb==0.13.13",
"ipython==8.27.0",
"pyfakefs==5.6.0",
"ipython==8.18.1",
"pyfakefs==5.3.2",
"pylint-django==2.5.5",
"pylint==3.2.7",
"pytest-cov==5.0.0",
"pytest-django==4.9.0",
"pytest==8.3.2",
"pytest-icdiff==0.9",
"pytest-xdist==3.6.1",
"responses==0.25.3",
"ruff==0.6.3",
"types-requests==2.32.0.20240712",
"pylint==3.0.3",
"pytest-cov==4.1.0",
"pytest-django==4.7.0",
"pytest==7.4.3",
"pytest-icdiff==0.8",
"pytest-xdist==3.5.0",
"responses==0.24.1",
"ruff==0.1.6",
"types-requests==2.31.0.10",
]
[tool.setuptools]
@@ -100,11 +99,11 @@ exclude = [
"__pycache__",
"*/migrations/*",
]
ignore= ["DJ001", "PLR2004"]
line-length = 88
[tool.ruff.lint]
ignore = ["DJ001", "PLR2004"]
select = [
"B", # flake8-bugbear
"BLE", # flake8-blind-except
@@ -126,7 +125,7 @@ select = [
section-order = ["future","standard-library","django","third-party","impress","first-party","local-folder"]
sections = { impress=["core"], django=["django"] }
[tool.ruff.lint.per-file-ignores]
[tool.ruff.per-file-ignores]
"**/tests/*" = ["S", "SLF"]
[tool.pytest.ini_options]

View File

@@ -1,10 +1,10 @@
FROM node:20-alpine as frontend-deps-y-provider
FROM node:20-alpine as frontend-deps-y-webrtc-signaling
WORKDIR /home/frontend/
COPY ./src/frontend/package.json ./package.json
COPY ./src/frontend/yarn.lock ./yarn.lock
COPY ./src/frontend/servers/y-provider/package.json ./servers/y-provider/package.json
COPY ./src/frontend/apps/y-webrtc-signaling/package.json ./apps/y-webrtc-signaling/package.json
COPY ./src/frontend/packages/eslint-config-impress/package.json ./packages/eslint-config-impress/package.json
RUN yarn install
@@ -14,10 +14,10 @@ COPY ./src/frontend/ .
# Copy entrypoint
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
# ---- y-provider ----
FROM frontend-deps-y-provider as y-provider
# ---- y-webrtc-signaling ----
FROM frontend-deps-y-webrtc-signaling as y-webrtc-signaling
WORKDIR /home/frontend/servers/y-provider
WORKDIR /home/frontend/apps/y-webrtc-signaling
RUN yarn build
# Un-privileged user running the application
@@ -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,29 +46,12 @@ 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
WORKDIR /home/frontend/apps/impress
ARG FRONTEND_THEME
ENV NEXT_PUBLIC_THEME=${FRONTEND_THEME}
ARG Y_PROVIDER_URL
ENV NEXT_PUBLIC_Y_PROVIDER_URL=${Y_PROVIDER_URL}
ARG API_ORIGIN
ENV NEXT_PUBLIC_API_ORIGIN=${API_ORIGIN}
RUN yarn build
# ---- Front-end image ----
@@ -88,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;"]

View File

@@ -2,5 +2,4 @@
test-results/
report/
blob-report/
playwright/.auth/
playwright/.cache/

View File

@@ -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');
});

View File

@@ -1,27 +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` });
});
setup('authenticate-firefox', async ({ page }) => {
await page.goto('/');
await keyCloakSignIn(page, 'firefox');
await page
.context()
.storageState({ path: `playwright/.auth/user-firefox.json` });
});

View File

@@ -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,72 +23,70 @@ 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 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++) {
const header = page.locator('header').first();
await header.locator('h2').getByText('Docs').click();
const buttonCreateHomepage = page.getByRole('button', {
name: 'Create a new document',
});
await buttonCreateHomepage.click();
// Fill input
await page
.getByRole('textbox', {
name: 'Document name',
})
.fill(randomDocs[i]);
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(page.locator('h2').getByText(randomDocs[i])).toBeVisible();
if (isPublic) {
await page.getByRole('button', { name: 'Share' }).click();
await page.getByText('Doc private').click();
await page.locator('.c__modal__backdrop').click({
position: { x: 0, y: 0 },
});
await expect(
page
.getByLabel('It is the card information about the document.')
.getByText('Public'),
).toBeVisible();
}
await expect(panel.locator('li').getByText(randomPads[i])).toBeVisible();
}
return randomDocs;
return randomPads;
};
export const createTemplate = async (
page: Page,
templateName: string,
browserName: string,
length: number,
) => {
const menu = page.locator('menu').first();
await menu.getByLabel(`Template button`).click();
const panel = page.getByLabel('Templates panel').first();
const buttonCreate = page.getByRole('button', {
name: 'Create the template',
});
const randomTemplates = randomName(templateName, browserName, length);
for (let i = 0; i < randomTemplates.length; i++) {
await panel.getByRole('button', { name: 'Add a template' }).click();
await page.getByText('Template name').fill(randomTemplates[i]);
await expect(buttonCreate).toBeEnabled();
await buttonCreate.click();
await expect(
panel.locator('li').getByText(randomTemplates[i]),
).toBeVisible();
}
return randomTemplates;
};
export const addNewMember = async (
page: Page,
index: number,
role: 'Administrator' | 'Owner' | 'Member' | 'Editor' | 'Reader',
fillText: string = 'user',
role: 'Admin' | 'Owner' | 'Member',
fillText: string = 'test',
) => {
const responsePromiseSearchUser = page.waitForResponse(
(response) =>
response.url().includes(`/users/?q=${fillText}`) &&
response.status() === 200,
);
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);
@@ -99,86 +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('combobox', { name: /Choose a role/ }).click();
await page.getByRole('option', { name: role }).click();
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;
};
interface GoToGridDocOptions {
nthRow?: number;
title?: string;
}
export const goToGridDoc = async (
page: Page,
{ nthRow = 1, title }: GoToGridDocOptions = {},
) => {
const header = page.locator('header').first();
await header.locator('h2').getByText('Docs').click();
const datagrid = page
.getByLabel('Datagrid of the documents page 1')
.getByRole('table');
await expect(datagrid.getByLabel('Loading data')).toBeHidden();
const rows = datagrid.getByRole('row');
const row = title
? rows.filter({
hasText: title,
})
: rows.nth(nthRow);
const docTitleCell = row.getByRole('cell').nth(1);
const docTitle = await docTitleCell.textContent();
expect(docTitle).toBeDefined();
await docTitleCell.click();
return docTitle as string;
};
export const mockedDocument = async (page: Page, json: object) => {
await page.route('**/documents/**/', async (route) => {
const request = route.request();
if (request.method().includes('GET') && !request.url().includes('page=')) {
await route.fulfill({
json: {
id: 'mocked-document-id',
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,
created_at: '2021-09-01T09:00:00Z',
...json,
},
});
} else {
await route.continue();
}
});
return users[index].name;
};

View File

@@ -1,65 +0,0 @@
import { expect, test } from '@playwright/test';
import { createDoc } from './common';
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.getByRole('dialog').first();
await expect(
card.locator('h2').getByText('Create a new document'),
).toBeVisible();
await expect(card.getByLabel('Document name')).toBeVisible();
await expect(
card.getByRole('button', {
name: 'Create the document',
}),
).toBeVisible();
await expect(card.getByLabel('Close the modal')).toBeVisible();
});
test('checks the cancel button interaction', async ({ page }) => {
const buttonCreateHomepage = page.getByRole('button', {
name: 'Create a new document',
});
await buttonCreateHomepage.click();
await expect(buttonCreateHomepage).toBeHidden();
const card = page.getByRole('dialog').first();
await card.getByLabel('Close the modal').click();
await expect(buttonCreateHomepage).toBeVisible();
});
test('it creates a doc', async ({ page, browserName }) => {
const [docTitle] = await createDoc(page, 'My new doc', browserName, 1);
expect(await page.locator('title').textContent()).toMatch(
/My new doc - Docs/,
);
const header = page.locator('header').first();
await header.locator('h2').getByText('Docs').click();
const datagrid = page
.getByLabel('Datagrid of the documents page 1')
.getByRole('table');
await expect(datagrid.getByLabel('Loading data')).toBeHidden();
await expect(datagrid.getByText(docTitle)).toBeVisible();
});
});

View File

@@ -1,182 +0,0 @@
import path from 'path';
import { expect, test } from '@playwright/test';
import { createDoc, goToGridDoc, mockedDocument } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Doc Editor', () => {
test('checks the Doc is connected to the provider 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;
expect(framesent.payload).not.toBeNull();
});
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,
}) => {
// Check the first doc
const firstDoc = await goToGridDoc(page);
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
const secondDoc = await goToGridDoc(page, {
nthRow: 2,
});
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 goToGridDoc(page, {
title: firstDoc,
});
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 }) => {
// Check the first doc
const doc = await goToGridDoc(page);
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();
const secondDoc = await goToGridDoc(page, {
nthRow: 2,
});
await expect(page.locator('h2').getByText(secondDoc)).toBeVisible();
await goToGridDoc(page, {
title: doc,
});
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');
// Check the first doc
const doc = await goToGridDoc(page);
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 goToGridDoc(page, {
title: doc,
});
await expect(page.getByText('Hello World Doc persisted 2')).toBeVisible();
});
test('it cannot edit if viewer', async ({ page }) => {
await mockedDocument(page, {
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,
},
});
await goToGridDoc(page);
await expect(
page.getByText('Read only, you cannot edit this document.'),
).toBeVisible();
});
test('it adds an image to the doc editor', async ({ page }) => {
await goToGridDoc(page);
const fileChooserPromise = page.waitForEvent('filechooser');
await page.locator('.bn-block-outer').last().fill('Hello World');
await page.keyboard.press('Enter');
await page.locator('.bn-block-outer').last().fill('/');
await page.getByText('Resizable image with caption').click();
await page.getByText('Upload image').click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(
path.join(__dirname, 'assets/logo-suite-numerique.png'),
);
const image = page.getByRole('img', { name: 'logo-suite-numerique.png' });
await expect(image).toBeVisible();
// Check src of image
expect(await image.getAttribute('src')).toMatch(
/http:\/\/localhost:8083\/media\/.*\/attachments\/.*.png/,
);
});
});

View File

@@ -1,233 +0,0 @@
import { expect, test } from '@playwright/test';
import cs from 'convert-stream';
import jsdom from 'jsdom';
import pdf from 'pdf-parse';
import { createDoc } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Doc Export', () => {
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: 'Export',
})
.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 doc to docx 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}.docx`);
});
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: 'Export',
})
.click();
await page.getByText('Docx').click();
await page
.getByRole('button', {
name: 'Download',
})
.click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe(`${randomDoc}.docx`);
});
test('it converts the blocknote json in correct html for the export', async ({
page,
browserName,
}) => {
test.slow();
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 expect(page.getByText('Break')).toBeVisible();
// Center the text
await page.getByText('Break').dblclick();
await page.locator('button[data-test="alignTextCenter"]').click();
// Change the background color
await page.locator('button[data-test="colors"]').click();
await page.locator('button[data-test="background-color-brown"]').click();
// Change the text color
await page.getByText('Break').dblclick();
await page.locator('button[data-test="colors"]').click();
await page.locator('button[data-test="text-color-orange"]').click();
// Add a list
await page.locator('.bn-block-outer').last().click();
await page.keyboard.press('Enter');
await page.locator('.bn-block-outer').last().fill('/');
await page.getByText('Bullet List').click();
await page
.locator('.bn-block-content[data-content-type="bulletListItem"] p')
.last()
.fill('Test List 1');
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(300);
await page.keyboard.press('Enter');
await page
.locator('.bn-block-content[data-content-type="bulletListItem"] p')
.last()
.fill('Test List 2');
await page.keyboard.press('Enter');
await page
.locator('.bn-block-content[data-content-type="bulletListItem"] p')
.last()
.fill('Test List 3');
await page.keyboard.press('Enter');
await page.keyboard.press('Backspace');
// Add a number list
await page.locator('.bn-block-outer').last().click();
await page.keyboard.press('Enter');
await page.locator('.bn-block-outer').last().fill('/');
await page.getByText('Numbered List').click();
await page
.locator('.bn-block-content[data-content-type="numberedListItem"] p')
.last()
.fill('Test Number 1');
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(300);
await page.keyboard.press('Enter');
await page
.locator('.bn-block-content[data-content-type="numberedListItem"] p')
.last()
.fill('Test Number 2');
await page.keyboard.press('Enter');
await page
.locator('.bn-block-content[data-content-type="numberedListItem"] p')
.last()
.fill('Test Number 3');
// Add img
await page.locator('.bn-block-outer').last().click();
await page.keyboard.press('Enter');
await page.locator('.bn-block-outer').last().fill('/');
await page
.getByRole('option', {
name: 'Image',
})
.click();
await page
.getByRole('tab', {
name: 'Embed',
})
.click();
await page
.getByPlaceholder('Enter URL')
.fill('https://example.com/image.jpg');
await page
.getByRole('button', {
name: 'Embed image',
})
.click();
// Download
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
name: 'Export',
})
.click();
await page
.getByRole('button', {
name: 'Download',
})
.click();
// Empty paragraph should be replaced by a <br/>
expect(body.match(/<br>/g)?.length).toBeGreaterThanOrEqual(2);
expect(body).toContain('style="color: orange;"');
expect(body).toContain('custom-style="center"');
expect(body).toContain('style="background-color: brown;"');
const { JSDOM } = jsdom;
const DOMParser = new JSDOM().window.DOMParser;
const parser = new DOMParser();
const html = parser.parseFromString(body, 'text/html');
const ulLis = html.querySelectorAll('ul li');
expect(ulLis.length).toBe(3);
expect(ulLis[0].textContent).toBe('Test List 1');
expect(ulLis[1].textContent).toBe('Test List 2');
expect(ulLis[2].textContent).toBe('Test List 3');
const olLis = html.querySelectorAll('ol li');
expect(olLis.length).toBe(3);
expect(olLis[0].textContent).toBe('Test Number 1');
expect(olLis[1].textContent).toBe('Test Number 2');
expect(olLis[2].textContent).toBe('Test Number 3');
const img = html.querySelectorAll('img');
expect(img.length).toBe(1);
expect(img[0].src).toBe('https://example.com/image.jpg');
});
});

View File

@@ -1,268 +0,0 @@
import { expect, test } from '@playwright/test';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Documents Grid', () => {
test('checks all the elements are visible', async ({ page }) => {
await expect(page.locator('h2').getByText('Documents')).toBeVisible();
const datagrid = page
.getByLabel('Datagrid of the documents page 1')
.getByRole('table');
const thead = datagrid.locator('thead');
await expect(thead.getByText(/Document name/i)).toBeVisible();
await expect(thead.getByText(/Created at/i)).toBeVisible();
await expect(thead.getByText(/Updated at/i)).toBeVisible();
await expect(thead.getByText(/Your role/i)).toBeVisible();
await expect(thead.getByText(/Members/i)).toBeVisible();
const row1 = datagrid.getByRole('row').nth(1).getByRole('cell');
const docName = await row1.nth(1).textContent();
expect(docName).toBeDefined();
const docCreatedAt = await row1.nth(2).textContent();
expect(docCreatedAt).toBeDefined();
const docUpdatedAt = await row1.nth(3).textContent();
expect(docUpdatedAt).toBeDefined();
const docRole = await row1.nth(4).textContent();
expect(
docRole &&
['Administrator', 'Owner', 'Reader', 'Editor'].includes(docRole),
).toBeTruthy();
const docUserNumber = await row1.nth(5).textContent();
expect(docUserNumber).toBeDefined();
// Open the document
await row1.nth(1).click();
await expect(page.locator('h2').getByText(docName!)).toBeVisible();
});
[
{
nameColumn: 'Document name',
ordering: 'title',
cellNumber: 1,
orderDefault: '',
orderDesc: '&ordering=-title',
orderAsc: '&ordering=title',
},
{
nameColumn: 'Created at',
ordering: 'created_at',
cellNumber: 2,
orderDefault: '',
orderDesc: '&ordering=-created_at',
orderAsc: '&ordering=created_at',
},
{
nameColumn: 'Updated at',
ordering: 'updated_at',
cellNumber: 3,
orderDefault: '&ordering=-updated_at',
orderDesc: '&ordering=updated_at',
orderAsc: '',
},
].forEach(
({
nameColumn,
ordering,
cellNumber,
orderDefault,
orderDesc,
orderAsc,
}) => {
test(`checks datagrid ordering ${ordering}`, async ({ page }) => {
const responsePromise = page.waitForResponse(
(response) =>
response.url().includes(`/documents/?page=1${orderDefault}`) &&
response.status() === 200,
);
const responsePromiseOrderingDesc = page.waitForResponse(
(response) =>
response.url().includes(`/documents/?page=1${orderDesc}`) &&
response.status() === 200,
);
const responsePromiseOrderingAsc = page.waitForResponse(
(response) =>
response.url().includes(`/documents/?page=1${orderAsc}`) &&
response.status() === 200,
);
// Checks the initial state
const datagrid = page
.getByLabel('Datagrid of the documents page 1')
.getByRole('table');
const thead = datagrid.locator('thead');
const response = await responsePromise;
expect(response.ok()).toBeTruthy();
const docNameRow1 = datagrid
.getByRole('row')
.nth(1)
.getByRole('cell')
.nth(cellNumber);
const docNameRow2 = datagrid
.getByRole('row')
.nth(2)
.getByRole('cell')
.nth(cellNumber);
await expect(datagrid.getByLabel('Loading data')).toBeHidden();
// Initial state
await expect(docNameRow1).toHaveText(/.*/);
await expect(docNameRow2).toHaveText(/.*/);
const initialDocNameRow1 = await docNameRow1.textContent();
const initialDocNameRow2 = await docNameRow2.textContent();
expect(initialDocNameRow1).toBeDefined();
expect(initialDocNameRow2).toBeDefined();
// Ordering ASC
await thead.getByText(nameColumn).click();
const responseOrderingAsc = await responsePromiseOrderingAsc;
expect(responseOrderingAsc.ok()).toBeTruthy();
await expect(datagrid.getByLabel('Loading data')).toBeHidden();
await expect(docNameRow1).toHaveText(/.*/);
await expect(docNameRow2).toHaveText(/.*/);
const textDocNameRow1Asc = await docNameRow1.textContent();
const textDocNameRow2Asc = await docNameRow2.textContent();
expect(
textDocNameRow1Asc &&
textDocNameRow2Asc &&
textDocNameRow1Asc.localeCompare(textDocNameRow2Asc, 'en', {
caseFirst: 'false',
ignorePunctuation: true,
}) <= 0,
).toBeTruthy();
// Ordering Desc
await thead.getByText(nameColumn).click();
const responseOrderingDesc = await responsePromiseOrderingDesc;
expect(responseOrderingDesc.ok()).toBeTruthy();
await expect(datagrid.getByLabel('Loading data')).toBeHidden();
await expect(docNameRow1).toHaveText(/.*/);
await expect(docNameRow2).toHaveText(/.*/);
const textDocNameRow1Desc = await docNameRow1.textContent();
const textDocNameRow2Desc = await docNameRow2.textContent();
expect(
textDocNameRow1Desc &&
textDocNameRow2Desc &&
textDocNameRow1Desc.localeCompare(textDocNameRow2Desc, 'en', {
caseFirst: 'false',
ignorePunctuation: true,
}) >= 0,
).toBeTruthy();
});
},
);
test('checks the pagination', async ({ page }) => {
const responsePromisePage1 = page.waitForResponse(
(response) =>
response.url().includes(`/documents/?page=1`) &&
response.status() === 200,
);
const responsePromisePage2 = page.waitForResponse(
(response) =>
response.url().includes(`/documents/?page=2`) &&
response.status() === 200,
);
const datagridPage1 = page
.getByLabel('Datagrid of the documents page 1')
.getByRole('table');
const responsePage1 = await responsePromisePage1;
expect(responsePage1.ok()).toBeTruthy();
await expect(
datagridPage1.getByRole('row').nth(1).getByRole('cell').nth(1),
).toHaveText(/.*/);
await page.getByLabel('Go to page 2').click();
const datagridPage2 = page
.getByLabel('Datagrid of the documents page 2')
.getByRole('table');
const responsePage2 = await responsePromisePage2;
expect(responsePage2.ok()).toBeTruthy();
await expect(
datagridPage2.getByRole('row').nth(1).getByRole('cell').nth(1),
).toHaveText(/.*/);
});
test('it updates document', async ({ page }) => {
const datagrid = page
.getByLabel('Datagrid of the documents page 1')
.getByRole('table');
const docRow = datagrid.getByRole('row').nth(1).getByRole('cell');
const docName = await docRow.nth(1).textContent();
await docRow.getByLabel('Open the document options').click();
await page.getByText('Update document').click();
await page.getByLabel('Document name').fill(`${docName} updated`);
await page.getByText('Validate the modification').click();
await expect(datagrid.getByText(`${docName} updated`)).toBeVisible();
});
test('it deletes the document', async ({ page }) => {
const datagrid = page
.getByLabel('Datagrid of the documents page 1')
.getByRole('table');
const docRow = datagrid.getByRole('row').nth(1).getByRole('cell');
const docName = await docRow.nth(1).textContent();
await docRow.getByLabel('Open the document options').click();
await page
.getByRole('button', {
name: 'Delete document',
})
.click();
await expect(
page.locator('h2').getByText(`Deleting the document "${docName}"`),
).toBeVisible();
await page
.getByRole('button', {
name: 'Confirm deletion',
})
.click();
await expect(
page.getByText('The document has been deleted.'),
).toBeVisible();
await expect(datagrid.getByText(docName!)).toBeHidden();
});
});

View File

@@ -1,245 +0,0 @@
import { expect, test } from '@playwright/test';
import { createDoc, goToGridDoc, mockedDocument } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Doc Header', () => {
test('it checks the element are correctly displayed', async ({ page }) => {
await mockedDocument(page, {
accesses: [
{
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
role: 'owner',
user: {
email: 'super@owner.com',
},
},
{
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
role: 'admin',
user: {
email: 'super@admin.com',
},
},
{
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
role: 'owner',
user: {
email: 'super2@owner.com',
},
},
],
abilities: {
destroy: true, // Means owner
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
manage_accesses: true,
update: true,
partial_update: true,
retrieve: true,
},
is_public: true,
created_at: '2021-09-01T09:00:00Z',
});
await goToGridDoc(page);
const card = page.getByLabel(
'It is the card information about the document.',
);
await expect(card.locator('a').getByText('home')).toBeVisible();
await expect(card.locator('h2').getByText('Mocked document')).toBeVisible();
await expect(card.getByText('Public')).toBeVisible();
await expect(
card.getByText('Created at 09/01/2021, 11:00 AM'),
).toBeVisible();
await expect(
card.getByText('Owners: super@owner.com / super2@owner.com'),
).toBeVisible();
await expect(card.getByText('Your role: Owner')).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
});
test('it updates the doc', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-update', browserName, 1);
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await page.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 page.getByText('Document name').fill(`${randomDoc}-updated`);
await page
.getByRole('button', {
name: 'Validate the modification',
})
.click();
await expect(
page.getByText('The document has been updated.'),
).toBeVisible();
const docTitle = await goToGridDoc(page, {
title: `${randomDoc}-updated`,
});
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
name: 'Update document',
})
.click();
await expect(
page.getByRole('textbox', { name: 'Document name' }),
).toHaveValue(`${randomDoc}-updated`);
});
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 row = page
.getByLabel('Datagrid of the documents page 1')
.getByRole('table')
.getByRole('row')
.filter({
hasText: randomDoc,
});
expect(await row.count()).toBe(0);
});
test('it checks the options available if administrator', async ({ page }) => {
await mockedDocument(page, {
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,
},
});
await goToGridDoc(page);
await expect(page.locator('h2').getByText('Mocked document')).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
await page.getByLabel('Open the document options').click();
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
await expect(
page.getByRole('button', { name: 'Update document' }),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Delete document' }),
).toBeHidden();
});
test('it checks the options available if editor', async ({ page }) => {
await mockedDocument(page, {
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,
},
});
await goToGridDoc(page);
await expect(page.locator('h2').getByText('Mocked document')).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeHidden();
await page.getByLabel('Open the document options').click();
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
await expect(
page.getByRole('button', { name: 'Update document' }),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Delete document' }),
).toBeHidden();
});
test('it checks the options available if reader', async ({ page }) => {
await mockedDocument(page, {
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,
},
});
await goToGridDoc(page);
await expect(page.locator('h2').getByText('Mocked document')).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeHidden();
await page.getByLabel('Open the document options').click();
await expect(page.getByRole('button', { name: 'Share' })).toBeHidden();
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
await expect(
page.getByRole('button', { name: 'Update document' }),
).toBeHidden();
await expect(
page.getByRole('button', { name: 'Delete document' }),
).toBeHidden();
});
});

View File

@@ -1,310 +0,0 @@
import { expect, test } from '@playwright/test';
import { createDoc, randomName } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Document create member', () => {
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.getByRole('button', { name: 'Share' }).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 page.getByRole('combobox', { name: /Choose a role/ }).click();
await expect(page.getByRole('option', { name: 'Reader' })).toBeVisible();
await expect(page.getByRole('option', { name: 'Editor' })).toBeVisible();
await expect(
page.getByRole('option', { name: 'Administrator' }),
).toBeVisible();
await expect(page.getByRole('option', { name: 'Owner' })).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.getByRole('button', { name: 'Share' }).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('combobox', { name: /Choose a role/ }).click();
await page.getByRole('option', { 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();
expect(
responseCreateInvitation.request().headers()['content-language'],
).toBe('en-us');
// Check user added
await expect(
page.getByText(`User ${user.email} added to the document.`),
).toBeVisible();
const responseAddUser = await responsePromiseAddUser;
expect(responseAddUser.ok()).toBeTruthy();
expect(responseAddUser.request().headers()['content-language']).toBe(
'en-us',
);
const listInvitation = page.getByLabel('List invitation card');
await expect(listInvitation.locator('li').getByText(email)).toBeVisible();
await expect(
listInvitation.locator('li').getByText('Invited'),
).toBeVisible();
const listMember = page.getByLabel('List members card');
await expect(listMember.locator('li').getByText(user.email)).toBeVisible();
});
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.getByRole('button', { name: 'Share' }).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('combobox', { name: /Choose a role/ }).click();
await page.getByRole('option', { 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 inputSearch.fill('user');
await expect(page.getByText('Loading...')).toBeHidden();
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.getByRole('button', { name: 'Share' }).click();
const inputSearch = page.getByLabel(/Find a member to add to the document/);
const [email] = randomName('test@test.fr', browserName, 1);
await inputSearch.fill(email);
await page.getByRole('option', { name: email }).click();
// Choose a role
await page.getByRole('combobox', { name: /Choose a role/ }).click();
await page.getByRole('option', { 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 inputSearch.fill(email);
await page.getByRole('option', { name: email }).click();
// Choose a role
await page.getByRole('combobox', { name: /Choose a role/ }).click();
await page.getByRole('option', { name: 'Owner' }).click();
const responsePromiseCreateInvitationFail = page.waitForResponse(
(response) =>
response.url().includes('/invitations/') && response.status() === 400,
);
await page.getByRole('button', { name: 'Validate' }).click();
await expect(
page.getByText(`"${email}" is already invited to the document.`),
).toBeVisible();
const responseCreateInvitationFail =
await responsePromiseCreateInvitationFail;
expect(responseCreateInvitationFail.ok()).toBeFalsy();
});
test('The invitation endpoint get the language of the website', async ({
page,
browserName,
}) => {
await createDoc(page, 'user-invitation', browserName, 1);
const header = page.locator('header').first();
await header.getByRole('combobox').getByText('EN').click();
await header.getByRole('option', { name: 'FR' }).click();
await page.getByRole('button', { name: 'Partager' }).click();
const inputSearch = page.getByLabel(
/Trouver un membre à ajouter au 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('combobox', { name: /Choisissez un rôle/ }).click();
await page.getByRole('option', { name: 'Administrateur' }).click();
const responsePromiseCreateInvitation = page.waitForResponse(
(response) =>
response.url().includes('/invitations/') && response.status() === 201,
);
await page.getByRole('button', { name: 'Valider' }).click();
// Check invitation sent
await expect(page.getByText(`Invitation envoyée à ${email}`)).toBeVisible();
const responseCreateInvitation = await responsePromiseCreateInvitation;
expect(responseCreateInvitation.ok()).toBeTruthy();
expect(
responseCreateInvitation.request().headers()['content-language'],
).toBe('fr-fr');
});
test('it manages invitation', async ({ page, browserName }) => {
await createDoc(page, 'user-invitation', browserName, 1);
await page.getByRole('button', { name: 'Share' }).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('combobox', { name: /Choose a role/ }).click();
await page.getByRole('option', { name: 'Administrator' }).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();
const listInvitation = page.getByLabel('List invitation card');
const li = listInvitation.locator('li').filter({
hasText: email,
});
await expect(li.getByText(email)).toBeVisible();
await li.getByRole('combobox', { name: /Role/ }).click();
await li.getByRole('option', { name: 'Reader' }).click();
await expect(page.getByText(`The role has been updated.`)).toBeVisible();
await li.getByText('delete').click();
await expect(
page.getByText(`The invitation has been removed.`),
).toBeVisible();
await expect(listInvitation.locator('li').getByText(email)).toBeHidden();
});
});

View File

@@ -1,164 +0,0 @@
import { expect, test } from '@playwright/test';
import { waitForElementCount } from '../helpers';
import { addNewMember, createDoc, goToGridDoc } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Document list members', () => {
test('it checks a big list of members', async ({ page }) => {
await page.route(
/.*\/documents\/.*\/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: 'http://anything/?page=2',
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: 'editor',
abilities: {
destroy: false,
partial_update: true,
set_role_to: [],
},
})),
};
if (request.method().includes('GET')) {
await route.fulfill({
json: accesses,
});
} else {
await route.continue();
}
},
);
await goToGridDoc(page);
await page.getByRole('button', { name: 'Share' }).click();
const list = page.getByLabel('List members card').locator('ul');
await expect(list.locator('li')).toHaveCount(20);
await list.getByText(`impress@impress.world-page-${1}-18`).hover();
await page.mouse.wheel(0, 10);
await waitForElementCount(list.locator('li'), 21, 10000);
expect(await list.locator('li').count()).toBeGreaterThan(20);
await expect(
list.getByText(`impress@impress.world-page-1-16`),
).toBeVisible();
await expect(
list.getByText(`impress@impress.world-page-2-15`),
).toBeVisible();
});
test('it checks the role rules', async ({ page, browserName }) => {
const [docTitle] = await createDoc(page, 'Doc role rules', browserName, 1);
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
const list = page.getByLabel('List members card').locator('ul');
await expect(list.getByText(`user@${browserName}.e2e`)).toBeVisible();
const soleOwner = list.getByText(
`You are the sole owner of this group, make another member the group owner before you can change your own role or be removed from your document.`,
);
await expect(soleOwner).toBeVisible();
const username = await addNewMember(page, 0, 'Owner');
await expect(list.getByText(username)).toBeVisible();
await expect(soleOwner).toBeHidden();
const otherOwner = list.getByText(
`You cannot update the role or remove other owner.`,
);
await expect(otherOwner).toBeVisible();
const SelectRoleCurrentUser = list
.locator('li')
.filter({
hasText: `user@${browserName}.e2e`,
})
.getByRole('combobox', { name: 'Role' });
await SelectRoleCurrentUser.click();
await page.getByRole('option', { name: 'Administrator' }).click();
await expect(page.getByText('The role has been updated')).toBeVisible();
// Admin still have the right to share
await expect(page.locator('h3').getByText('Share')).toBeVisible();
await SelectRoleCurrentUser.click();
await page.getByRole('option', { name: 'Reader' }).click();
await expect(page.getByText('The role has been updated')).toBeVisible();
// Reader does not have the right to share
await expect(page.locator('h3').getByText('Share')).toBeHidden();
});
test('it checks the delete members', async ({ page, browserName }) => {
const [docTitle] = await createDoc(page, 'Doc role rules', browserName, 1);
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
const list = page.getByLabel('List members card').locator('ul');
const nameMyself = `user@${browserName}.e2e`;
await expect(list.getByText(nameMyself)).toBeVisible();
const userOwner = await addNewMember(page, 0, 'Owner');
await expect(list.getByText(userOwner)).toBeVisible();
const userReader = await addNewMember(page, 0, 'Reader');
await expect(list.getByText(userReader)).toBeVisible();
await list
.locator('li')
.filter({
hasText: userReader,
})
.getByText('delete')
.click();
await expect(list.getByText(userReader)).toBeHidden();
await list
.locator('li')
.filter({
hasText: nameMyself,
})
.getByText('delete')
.click();
await expect(list.getByText(nameMyself)).toBeHidden();
await expect(
page.getByText('The member has been removed from the document').first(),
).toBeVisible();
await expect(page.getByText('Share')).toBeHidden();
});
});

View File

@@ -1,59 +0,0 @@
import { expect, test } from '@playwright/test';
import { keyCloakSignIn } from './common';
test.describe('Doc Routing', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
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.describe('Doc Routing: Not loggued', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('checks redirect to a doc after login', async ({
page,
browserName,
}) => {
await page.goto('/docs/mocked-document-id/');
await keyCloakSignIn(page, browserName);
await expect(page).toHaveURL(/\/docs\/mocked-document-id\/$/);
});
test('The homepage redirects to login.', async ({ page }) => {
await page.goto('/');
await expect(
page.getByRole('button', {
name: 'Sign In',
}),
).toBeVisible();
});
});

View File

@@ -1,64 +0,0 @@
import { expect, test } from '@playwright/test';
import { createDoc } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Doc Summary', () => {
test('it checks the doc summary', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-summary', browserName, 1);
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
name: 'Summary',
})
.click();
const panel = page.getByLabel('Document panel');
const editor = page.locator('.ProseMirror');
await editor.locator('.bn-block-outer').last().fill('/');
await page.getByText('Heading 1').click();
await page.keyboard.type('Hello World');
await page.locator('.bn-block-outer').last().click();
// Create space to fill the viewport
for (let i = 0; i < 6; i++) {
await page.keyboard.press('Enter');
}
await editor.locator('.bn-block-outer').last().fill('/');
await page.getByText('Heading 2').click();
await page.keyboard.type('Super World');
await page.locator('.bn-block-outer').last().click();
// Create space to fill the viewport
for (let i = 0; i < 4; i++) {
await page.keyboard.press('Enter');
}
await editor.locator('.bn-block-outer').last().fill('/');
await page.getByText('Heading 3').click();
await page.keyboard.type('Another World');
await expect(panel.getByText('Hello World')).toBeVisible();
await expect(panel.getByText('Super World')).toBeVisible();
await panel.getByText('Another World').click();
await expect(editor.getByText('Hello World')).not.toBeInViewport();
await panel.getByText('Back to top').click();
await expect(editor.getByText('Hello World')).toBeInViewport();
await panel.getByText('Go to bottom').click();
await expect(editor.getByText('Hello World')).not.toBeInViewport();
});
});

View File

@@ -1,96 +0,0 @@
import { expect, test } from '@playwright/test';
import { createDoc, keyCloakSignIn } from './common';
test.describe('Doc Visibility', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('Make a public doc', async ({ page, browserName }) => {
const [docTitle] = await createDoc(
page,
'My new doc',
browserName,
1,
true,
);
const header = page.locator('header').first();
await header.locator('h2').getByText('Docs').click();
const datagrid = page
.getByLabel('Datagrid of the documents page 1')
.getByRole('table');
await expect(datagrid.getByLabel('Loading data')).toBeHidden();
await expect(datagrid.getByText(docTitle)).toBeVisible();
const row = datagrid.getByRole('row').filter({
hasText: docTitle,
});
await expect(row.getByRole('cell').nth(0)).toHaveText('Public');
});
test('It checks the copy link button', async ({ page, browserName }) => {
// eslint-disable-next-line playwright/no-skipped-test
test.skip(
browserName === 'webkit',
'navigator.clipboard is not working with webkit and playwright',
);
await createDoc(page, 'My button copy doc', browserName, 1);
await page.getByRole('button', { name: 'Share' }).click();
await page.getByRole('button', { name: 'Copy link' }).click();
await expect(page.getByText('Link Copied !')).toBeVisible();
const handle = await page.evaluateHandle(() =>
navigator.clipboard.readText(),
);
const clipboardContent = await handle.jsonValue();
expect(clipboardContent).toMatch(page.url());
});
});
test.describe('Doc Visibility: Not loggued', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('A public doc is accessible even when not authentified.', async ({
page,
browserName,
}) => {
await page.goto('/');
await keyCloakSignIn(page, browserName);
const [docTitle] = await createDoc(
page,
'My new doc',
browserName,
1,
true,
);
await expect(
page.getByText('The document visiblitity has been updated.'),
).toBeVisible();
const urlDoc = page.url();
await page
.getByRole('button', {
name: 'Logout',
})
.click();
await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible();
await page.goto(urlDoc);
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
});
});

View File

@@ -1,14 +1,21 @@
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', () => {
test('checks all the elements are visible', async ({ page }) => {
const footer = page.locator('footer').first();
await expect(footer.getByAltText('Gouvernement Logo')).toBeVisible();
await expect(footer.getByAltText('Marianne Logo')).toBeVisible();
await expect(
footer.getByAltText('Freedom Equality Fraternity Logo'),
).toBeVisible();
await expect(
footer.getByRole('link', { name: 'legifrance.gouv.fr' }),

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