Compare commits

..

1 Commits

Author SHA1 Message Date
Anthony LC
ab2028c624 📸(helm) production-example
We add a "production-example" environment to the
helm chart. We have the "dev" environment already,
but this one can be mistaken for a production,
so we add a "production-example" to make it clear.
"dev" is for development, it is used by our Tilt
stack.
2024-12-24 10:58:05 +01:00
429 changed files with 11274 additions and 26375 deletions

View File

@@ -1,77 +0,0 @@
name: Download translations from Crowdin
on:
workflow_dispatch:
push:
branches:
- 'release/**'
jobs:
install-dependencies:
uses: ./.github/workflows/dependencies.yml
with:
node_version: '20.x'
with-front-dependencies-installation: true
synchronize-with-crowdin:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Create empty source files
run: |
touch src/backend/locale/django.pot
mkdir -p src/frontend/packages/i18n/locales/impress/
touch src/frontend/packages/i18n/locales/impress/translations-crowdin.json
# crowdin workflow
- name: crowdin action
uses: crowdin/github-action@v2
with:
config: crowdin/config.yml
upload_sources: false
upload_translations: false
download_translations: true
create_pull_request: false
push_translations: false
push_sources: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# A numeric ID, found at https://crowdin.com/project/<projectName>/tools/api
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
# Visit https://crowdin.com/settings#api-key to create this token
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
CROWDIN_BASE_PATH: "../src/"
# frontend i18n
- name: Restore the frontend cache
uses: actions/cache@v4
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
fail-on-cache-miss: true
- name: generate translations files
working-directory: src/frontend
run: yarn i18n:deploy
# Create a new PR
- name: Create a new Pull Request with new translated strings
uses: peter-evans/create-pull-request@v7
with:
commit-message: |
🌐(i18n) update translated strings
Update translated files with new translations
title: 🌐(i18n) update translated strings
body: |
## Purpose
update translated strings
## Proposal
- [x] update translated strings
branch: i18n/update-translations
labels: i18n

View File

@@ -1,76 +0,0 @@
name: Update crowdin sources
on:
workflow_dispatch:
push:
branches:
- main
jobs:
install-dependencies:
uses: ./.github/workflows/dependencies.yml
with:
node_version: '20.x'
with-front-dependencies-installation: true
with-build_mails: true
synchronize-with-crowdin:
needs: install-dependencies
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
# Backend i18n
- name: Install Python
uses: actions/setup-python@v3
with:
python-version: "3.12.6"
- name: Upgrade pip and setuptools
run: pip install --upgrade pip setuptools
- name: Install development dependencies
run: pip install --user .
working-directory: src/backend
- 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') }}
fail-on-cache-miss: true
- name: Install gettext
run: |
sudo apt-get update
sudo apt-get install -y gettext pandoc
- name: generate pot files
working-directory: src/backend
run: |
DJANGO_CONFIGURATION=Build python manage.py makemessages -a --keep-pot
# frontend i18n
- name: Restore the frontend cache
uses: actions/cache@v4
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
fail-on-cache-miss: true
- name: generate source translation file
working-directory: src/frontend
run: yarn i18n:extract
# crowdin workflow
- name: crowdin action
uses: crowdin/github-action@v2
with:
config: crowdin/config.yml
upload_sources: true
upload_translations: false
download_translations: false
create_pull_request: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# A numeric ID, found at https://crowdin.com/project/<projectName>/tools/api
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
# Visit https://crowdin.com/settings#api-key to create this token
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
CROWDIN_BASE_PATH: "../src/"

View File

@@ -1,85 +0,0 @@
name: Dependency reusable workflow
on:
workflow_call:
inputs:
node_version:
required: false
default: '20.x'
type: string
with-front-dependencies-installation:
type: boolean
default: false
with-build_mails:
type: boolean
default: false
jobs:
front-dependencies-installation:
if: ${{ inputs.with-front-dependencies-installation == true }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Restore the frontend cache
uses: actions/cache@v4
id: front-node_modules
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
- name: Setup Node.js
if: steps.front-node_modules.outputs.cache-hit != 'true'
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node_version }}
- name: Install dependencies
if: steps.front-node_modules.outputs.cache-hit != 'true'
run: cd src/frontend/ && yarn install --frozen-lockfile
- name: Cache install frontend
if: steps.front-node_modules.outputs.cache-hit != 'true'
uses: actions/cache@v4
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
build-mails:
if: ${{ inputs.with-build_mails == true }}
runs-on: ubuntu-latest
defaults:
run:
working-directory: src/mail
steps:
- name: Checkout repository
uses: actions/checkout@v4
- 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: Setup Node.js
if: steps.mail-templates.outputs.cache-hit != 'true'
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node_version }}
- name: Install yarn
if: steps.mail-templates.outputs.cache-hit != 'true'
run: npm install -g yarn
- name: Install node dependencies
if: steps.mail-templates.outputs.cache-hit != 'true'
run: yarn install --frozen-lockfile
- name: Build mails
if: steps.mail-templates.outputs.cache-hit != 'true'
run: yarn build
- name: Cache mail templates
if: steps.mail-templates.outputs.cache-hit != 'true'
uses: actions/cache@v4
with:
path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }}

View File

@@ -19,9 +19,26 @@ jobs:
build-and-push-backend:
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@v4
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: Docker meta
id: meta
@@ -31,7 +48,7 @@ jobs:
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
@@ -53,9 +70,26 @@ jobs:
build-and-push-frontend:
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@v4
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: Docker meta
id: meta
@@ -65,7 +99,7 @@ jobs:
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
@@ -88,9 +122,26 @@ jobs:
build-and-push-y-provider:
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@v4
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: Docker meta
id: meta
@@ -100,7 +151,7 @@ jobs:
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
@@ -128,12 +179,29 @@ jobs:
if: |
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@v4
uses: actions/checkout@v2
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
-
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/impress/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
-
name: Call argocd github webhook
run: |
data='{"ref": "'$GITHUB_REF'","repository": {"html_url":"'$GITHUB_SERVER_URL'/'$GITHUB_REPOSITORY'"}}'
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${{ secrets.ARGOCD_PREPROD_WEBHOOK_SECRET}}'' | awk '{print "X-Hub-Signature: sha1="$2}')
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" ${{ vars.ARGOCD_PREPROD_WEBHOOK_URL }}
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${ARGOCD_WEBHOOK_SECRET}'' | awk '{print "X-Hub-Signature: sha1="$2}')
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" $ARGOCD_WEBHOOK_URL

View File

@@ -2,7 +2,6 @@ name: Helmfile lint
run-name: Helmfile lint
on:
push:
pull_request:
branches:
- 'main'
@@ -13,18 +12,11 @@ jobs:
container:
image: ghcr.io/helmfile/helmfile:latest
steps:
-
name: Checkout repository
uses: actions/checkout@v4
-
name: Helmfile lint
shell: bash
run: |
set -e
HELMFILE=src/helm/helmfile.yaml
environments=$(awk '/environments:/ {flag=1; next} flag && NF {print} !NF {flag=0}' "$HELMFILE" | grep -E '^[[:space:]]{2}[a-zA-Z]+' | sed 's/^[[:space:]]*//;s/:.*//')
for env in $environments; do
echo "################### $env lint ###################"
helmfile -e $env -f $HELMFILE lint || exit 1
echo -e "\n"
done
-
uses: numerique-gouv/action-helmfile-lint@main
with:
app-id: ${{ secrets.APP_ID }}
age-key: ${{ secrets.SOPS_PRIVATE }}
private-key: ${{ secrets.PRIVATE_KEY }}
helmfile-src: "src/helm"
repositories: "impress,secrets"

View File

@@ -9,16 +9,9 @@ on:
- "*"
jobs:
install-dependencies:
uses: ./.github/workflows/dependencies.yml
with:
node_version: '20.x'
with-front-dependencies-installation: true
test-front:
needs: install-dependencies
install-front:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -30,54 +23,74 @@ jobs:
- 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: Install dependencies
if: steps.front-node_modules.outputs.cache-hit != 'true'
run: cd src/frontend/ && yarn install --frozen-lockfile
- name: Cache install frontend
if: steps.front-node_modules.outputs.cache-hit != 'true'
uses: actions/cache@v4
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
test-front:
runs-on: ubuntu-latest
needs: install-front
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20.x"
- name: Restore the frontend cache
uses: actions/cache@v4
id: front-node_modules
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
fail-on-cache-miss: true
- name: Test App
run: cd src/frontend/ && yarn test
lint-front:
runs-on: ubuntu-latest
needs: install-dependencies
needs: install-front
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20.x"
- name: Restore the frontend cache
uses: actions/cache@v4
id: front-node_modules
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
fail-on-cache-miss: true
- name: Check linting
run: cd src/frontend/ && yarn lint
test-e2e-chromium:
runs-on: ubuntu-latest
needs: install-dependencies
timeout-minutes: 20
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20.x"
- name: Restore the frontend cache
uses: actions/cache@v4
id: front-node_modules
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
fail-on-cache-miss: true
- name: Set e2e env variables
run: cat env.d/development/common.e2e.dist >> env.d/development/common.dist
@@ -88,6 +101,28 @@ jobs:
- name: Start Docker services
run: make bootstrap FLUSH_ARGS='--no-input' cache=
# Tool to wait for a service to be ready
- name: Install Dockerize
run: |
curl -sSL https://github.com/jwilder/dockerize/releases/download/v0.8.0/dockerize-linux-amd64-v0.8.0.tar.gz | sudo tar -C /usr/local/bin -xzv
- name: Wait for services to be ready
run: |
printf "Minio check...\n"
dockerize -wait tcp://localhost:9000 -timeout 20s
printf "Keyclock check...\n"
dockerize -wait tcp://localhost:8080 -timeout 20s
printf "Server collaboration check...\n"
dockerize -wait tcp://localhost:4444 -timeout 20s
printf "Ngnix check...\n"
dockerize -wait tcp://localhost:8083 -timeout 20s
printf "DRF check...\n"
dockerize -wait tcp://localhost:8071 -timeout 20s
printf "Postgres Keyclock check...\n"
dockerize -wait tcp://localhost:5433 -timeout 20s
printf "Postgres back check...\n"
dockerize -wait tcp://localhost:15432 -timeout 20s
- name: Run e2e tests
run: cd src/frontend/ && yarn e2e:test --project='chromium'
@@ -106,17 +141,12 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20.x"
- name: Restore the frontend cache
uses: actions/cache@v4
id: front-node_modules
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
fail-on-cache-miss: true
- name: Set e2e env variables
run: cat env.d/development/common.e2e.dist >> env.d/development/common.dist

View File

@@ -9,11 +9,6 @@ on:
- "*"
jobs:
install-dependencies:
uses: ./.github/workflows/dependencies.yml
with:
with-build_mails: true
lint-git:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' # Makes sense only for pull requests
@@ -61,6 +56,46 @@ jobs:
exit 1
fi
build-mails:
runs-on: ubuntu-latest
defaults:
run:
working-directory: src/mail
steps:
- name: Checkout repository
uses: actions/checkout@v4
- 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') }}
- name: Install yarn
if: steps.mail-templates.outputs.cache-hit != 'true'
run: npm install -g yarn
- name: Install node dependencies
if: steps.mail-templates.outputs.cache-hit != 'true'
run: yarn install --frozen-lockfile
- name: Build mails
if: steps.mail-templates.outputs.cache-hit != 'true'
run: yarn build
- name: Cache mail templates
if: steps.mail-templates.outputs.cache-hit != 'true'
uses: actions/cache@v4
with:
path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
lint-back:
runs-on: ubuntu-latest
defaults:
@@ -86,7 +121,7 @@ jobs:
test-back:
runs-on: ubuntu-latest
needs: install-dependencies
needs: build-mails
defaults:
run:
@@ -134,7 +169,6 @@ jobs:
with:
path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
fail-on-cache-miss: true
- name: Start MinIO
run: |
@@ -172,11 +206,10 @@ jobs:
- name: Install development dependencies
run: pip install --user .[dev]
- name: Install gettext (required to compile messages) and MIME support
- name: Install gettext (required to compile messages)
run: |
sudo apt-get update
sudo apt-get install -y gettext pandoc shared-mime-info
sudo wget https://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types -O /etc/mime.types
sudo apt-get install -y gettext pandoc
- name: Generate a MO file from strings extracted from the project
run: python manage.py compilemessages

View File

@@ -1,34 +0,0 @@
name: Release Chart
run-name: Release Chart
on:
push:
paths:
- src/helm/impress/**
jobs:
release:
# depending on default permission settings for your org (contents being read-only or read-write for workloads), you will have to add permissions
# see: https://docs.github.com/en/actions/security-guides/automatic-token-authentication#modifying-the-permissions-for-the-github_token
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Cleanup
run: rm -rf ./src/helm/extra
- name: Install Helm
uses: azure/setup-helm@v4
env:
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
- name: Publish Helm charts
uses: numerique-gouv/helm-gh-pages@add-overwrite-option
with:
charts_dir: ./src/helm
token: ${{ secrets.GITHUB_TOKEN }}

1
.gitignore vendored
View File

@@ -30,7 +30,6 @@ MANIFEST
.next/
# Translations # Translations
*.mo
*.pot
# Environments

3
.gitmodules vendored
View File

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

View File

@@ -6,106 +6,14 @@ 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 error pages #643
🔧(helm) add option to disable default tls setting by @dominikkaminski #519
📸(helm) production-example #529
## Changed
- 🛂(frontend) Restore version visibility #629
- 📝(doc) minor README.md formatting and wording enhancements
-Stop setting a default title on doc creation #634
- ♻️(frontend) misc ui improvements #644
## Fixed
- ♻️(frontend) improve table pdf rendering
## [2.2.0] - 2025-02-10
## Added
- 📝(doc) Add security.md and codeofconduct.md #604
- ✨(frontend) add home page #608
- ✨(frontend) cursor display on activity #609
- ✨(frontend) Add export page break #623
## Changed
- 🔧(backend) make AI feature reach configurable #628
## Fixed
- 🌐(CI) Fix email partially translated #616
- 🐛(frontend) fix cursor breakline #609
- 🐛(frontend) fix style pdf export #609
## [2.1.0] - 2025-01-29
## Added
- ✨(backend) add soft delete and restore API endpoints to documents #516
- ✨(backend) allow organizing documents in a tree structure #516
- ✨(backend) add "excerpt" field to document list serializer #516
- ✨(backend) add github actions to manage Crowdin workflow #559 & #563
- 📈Integrate Posthog #540
- 🏷️(backend) add content-type to uploaded files #552
- ✨(frontend) export pdf docx front side #537
## Changed
- 💄(frontend) add abilities on doc row #581
- 💄(frontend) improve DocsGridItem responsive padding #582
- 🔧(backend) Bump maximum page size to 200 #516
- 📝(doc) Improve Read me #558
## Fixed
- 🐛Fix invitations #575
## Removed
- 🔥(backend) remove "content" field from list serializer # 516
## [2.0.1] - 2025-01-17
## Fixed
-🐛(frontend) share modal is shown when you don't have the abilities #557
-🐛(frontend) title copy break app #564
## [2.0.0] - 2025-01-13
## Added
- 🔧(backend) add option to configure list of essential OIDC claims #525 & #531
- 🔧(helm) add option to disable default tls setting by @dominikkaminski #519
- 💄(frontend) Add left panel #420
- 💄(frontend) add filtering to left panel #475
- ✨(frontend) new share modal ui #489
- ✨(frontend) add favorite feature #515
- 📝(documentation) Documentation about self-hosted installation #530
- ✨(helm) helm versioning #530
## Changed
- 🏗️(yjs-server) organize yjs server #528
- ♻️(frontend) better separation collaboration process #528
- 💄(frontend) updating the header and leftpanel for responsive #421
- 💄(frontend) update DocsGrid component #431
- 💄(frontend) update DocsGridOptions component #432
- 💄(frontend) update DocHeader ui #448
- 💄(frontend) update doc versioning ui #463
- 💄(frontend) update doc summary ui #473
- 📝(doc) update readme.md to match V2 changes #558 & #572
## Fixed
- 🐛(backend) fix create document via s2s if sub unknown but email found #543
- 🐛(frontend) hide search and create doc button if not authenticated #555
- 🐛(backend) race condition creation issue #556
## [1.10.0] - 2024-12-17
@@ -123,10 +31,6 @@ and this project adheres to
- ⚡️(e2e) reduce flakiness on e2e tests #511
## Fixed
- 🐛(frontend) update doc editor height #481
- 💄(frontend) add doc search #485
## [1.9.0] - 2024-12-11
@@ -149,18 +53,21 @@ and this project adheres to
- 🐛(frontend) Fix hidden menu on Firefox #468
- 🐛(backend) fix sanitize problem IA #490
## [1.8.2] - 2024-11-28
## Changed
- ♻️(SW) change strategy html caching #460
## [1.8.1] - 2024-11-27
## Fixed
- 🐛(frontend) link not clickable and flickering firefox #457
## [1.8.0] - 2024-11-25
## Added
@@ -189,6 +96,7 @@ and this project adheres to
- 🐛(frontend) users have view access when revoked #387
- 🐛(frontend) fix placeholder editable when double clicks #454
## [1.7.0] - 2024-10-24
## Added
@@ -216,6 +124,7 @@ and this project adheres to
- 🔥(helm) remove infra related codes #366
## [1.6.0] - 2024-10-17
## Added
@@ -238,6 +147,7 @@ and this project adheres to
- 🐛(backend) fix nginx docker container #340
- 🐛(frontend) fix copy paste firefox #353
## [1.5.1] - 2024-10-10
## Fixed
@@ -272,6 +182,7 @@ and this project adheres to
- 🔧(backend) fix configuration to avoid different ssl warning #297
- 🐛(frontend) fix editor break line not working #302
## [1.4.0] - 2024-09-17
## Added
@@ -292,6 +203,7 @@ and this project adheres to
- 🐛(backend) Fix forcing ID when creating a document via API endpoint #234
- 🐛 Rebuild frontend dev container from makefile #248
## [1.3.0] - 2024-09-05
## Added
@@ -316,6 +228,7 @@ and this project adheres to
- 🔥(frontend) remove saving modal #213
## [1.2.1] - 2024-08-23
## Changed
@@ -323,6 +236,7 @@ and this project adheres to
- ♻️ Change ordering docs datagrid #195
- 🔥(helm) use scaleway email #194
## [1.2.0] - 2024-08-22
## Added
@@ -346,14 +260,14 @@ and this project adheres to
- ⚡️(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
- 🤡(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
@@ -364,11 +278,12 @@ and this project adheres to
- ♻️(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) Manage the document's right (#75)
- ✨(frontend) Update document (#68)
- ✨(frontend) Remove document (#68)
- 🐳(docker) dockerize dev frontend (#63)
@@ -402,6 +317,7 @@ and this project adheres to
- 💚(CI) Remove trigger workflow on push tags on CI (#68)
- 🔥(frontend) Remove coming soon page (#121)
## [0.1.0] - 2024-05-24
## Added
@@ -409,11 +325,8 @@ and this project adheres to
- ✨(frontend) Coming Soon page (#67)
- 🚀 Impress, project to manage your documents easily and collaboratively.
[unreleased]: https://github.com/numerique-gouv/impress/compare/v2.2.0...main
[v2.2.0]: https://github.com/numerique-gouv/impress/releases/v2.2.0
[v2.1.0]: https://github.com/numerique-gouv/impress/releases/v2.1.0
[v2.0.1]: https://github.com/numerique-gouv/impress/releases/v2.0.1
[v2.0.0]: https://github.com/numerique-gouv/impress/releases/v2.0.0
[unreleased]: https://github.com/numerique-gouv/impress/compare/v1.10.0...main
[v1.10.0]: https://github.com/numerique-gouv/impress/releases/v1.10.0
[v1.9.0]: https://github.com/numerique-gouv/impress/releases/v1.9.0
[v1.8.2]: https://github.com/numerique-gouv/impress/releases/v1.8.2

View File

@@ -1,75 +0,0 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our community include:
- Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
- Focusing on what is best not just for us as individuals, but for the overall community
Examples of unacceptable behavior include:
- The use of sexualized language or imagery, and sexual attention or advances of any kind
- Trolling, insulting or derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or email address, without their explicit permission
- Other conduct which could reasonably be considered inappropriate in a professional setting
## Enforcement Responsibilities
- Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
- Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.
## Scope
- This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
## Enforcement
- Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at docs@numerique.gouv.fr.
- All complaints will be reviewed and investigated promptly and fairly.
- All community leaders are obligated to respect the privacy and security of the reporter of any incident.
## Enforcement Guidelines
- Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this
## Code of Conduct:
1. Correction
Community Impact: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
Consequence: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
2. Warning
Community Impact: A violation through a single incident or series of actions.
Consequence: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
3. Temporary Ban
Community Impact: A serious violation of community standards, including sustained inappropriate behavior.
Consequence: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
4. Permanent Ban
Community Impact: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
Consequence: A permanent ban from any sort of public interaction within the community.
Attribution
This Code of Conduct is adapted from the Contributor Covenant, version 2.1, available at https://www.contributor-covenant.org/version/2/1/code_of_conduct.html.
Community Impact Guidelines were inspired by Mozilla's code of conduct enforcement ladder.
For answers to common questions about this code of conduct, see the FAQ at https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations.

View File

@@ -2,14 +2,7 @@
Thank you for taking the time to contribute! Please follow these guidelines to ensure a smooth and productive workflow. 🚀🚀🚀
To get started with the project, please refer to the [README.md](https://github.com/suitenumerique/docs/blob/main/README.md) for detailed instructions.
Please also check out our [dev handbook](https://suitenumerique.gitbook.io/handbook) to learn our best practices.
## Help us with translations
You can help us with translations on [Crowdin](https://crowdin.com/project/lasuite-docs).
Your language is not there? Request it on our Crowdin page 😊.
To get started with the project, please refer to the [README.md](https://github.com/numerique-gouv/impress/blob/main/README.md) for detailed instructions.
## Creating an Issue

View File

@@ -51,7 +51,7 @@ COPY ./src/backend /app/
WORKDIR /app
# collectstatic
RUN DJANGO_CONFIGURATION=Build \
RUN DJANGO_CONFIGURATION=Build DJANGO_JWT_PRIVATE_SIGNING_KEY=Dummy \
python manage.py collectstatic --noinput
# Replace duplicated file by a symlink to decrease the overall size of the
@@ -72,11 +72,10 @@ RUN apk add \
gettext \
gdk-pixbuf \
libffi-dev \
pandoc \
pango \
shared-mime-info
RUN wget https://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types -O /etc/mime.types
# Copy entrypoint
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
@@ -93,11 +92,6 @@ COPY ./src/backend /app/
WORKDIR /app
# Generate compiled translation messages
RUN DJANGO_CONFIGURATION=Build \
python manage.py compilemessages
# We wrap commands run in this container by the following entrypoint that
# creates a user on-the-fly with the container user ID (see USER) and root group
# ID.

View File

@@ -44,6 +44,7 @@ COMPOSE_EXEC_APP = $(COMPOSE_EXEC) app-dev
COMPOSE_RUN = $(COMPOSE) run --rm
COMPOSE_RUN_APP = $(COMPOSE_RUN) app-dev
COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT) -timeout 60s
# -- Backend
MANAGE = $(COMPOSE_RUN_APP) python manage.py
@@ -80,12 +81,12 @@ bootstrap: \
data/static \
create-env-files \
build \
run-with-frontend \
migrate \
demo \
back-i18n-compile \
mails-install \
mails-build \
run
mails-build
.PHONY: bootstrap
# -- Docker/compose
@@ -108,7 +109,7 @@ build-yjs-provider: ## build the y-provider container
build-frontend: cache ?=
build-frontend: ## build the frontend container
@$(COMPOSE) build frontend $(cache)
@$(COMPOSE) build frontend-dev $(cache)
.PHONY: build-frontend
down: ## stop and remove containers, networks, images, and volumes
@@ -119,18 +120,19 @@ logs: ## display app-dev logs (follow mode)
@$(COMPOSE) logs -f app-dev
.PHONY: logs
run-backend: ## Start only the backend application and all needed services
run: ## start the wsgi (production) and development server
@$(COMPOSE) up --force-recreate -d celery-dev
@$(COMPOSE) up --force-recreate -d y-provider
@$(COMPOSE) up --force-recreate -d nginx
.PHONY: run-backend
run: ## start the wsgi (production) and development server
run:
@$(MAKE) run-backend
@$(COMPOSE) up --force-recreate -d frontend
@echo "Wait for postgresql to be up..."
@$(WAIT_DB)
.PHONY: run
run-with-frontend: ## Start all the containers needed (backend to frontend)
@$(MAKE) run
@$(COMPOSE) up --force-recreate -d frontend-dev
.PHONY: run-with-frontend
status: ## an alias for "docker compose ps"
@$(COMPOSE) ps
.PHONY: status
@@ -186,12 +188,14 @@ test-back-parallel: ## run all back-end tests in parallel
makemigrations: ## run django makemigrations for the impress project.
@echo "$(BOLD)Running makemigrations$(RESET)"
@$(COMPOSE) up -d postgresql
@$(WAIT_DB)
@$(MANAGE) makemigrations
.PHONY: makemigrations
migrate: ## run django migrations for the impress project.
@echo "$(BOLD)Running migrations$(RESET)"
@$(COMPOSE) up -d postgresql
@$(WAIT_DB)
@$(MANAGE) migrate
.PHONY: migrate
@@ -306,16 +310,16 @@ help:
.PHONY: help
# Front
frontend-development-install: ## install the frontend locally
frontend-install: ## install the frontend locally
cd $(PATH_FRONT_IMPRESS) && yarn
.PHONY: frontend-development-install
.PHONY: frontend-install
frontend-lint: ## run the frontend linter
cd $(PATH_FRONT) && yarn lint
.PHONY: frontend-lint
run-frontend-development: ## Run the frontend in development mode
@$(COMPOSE) stop frontend
@$(COMPOSE) stop frontend-dev
cd $(PATH_FRONT_IMPRESS) && yarn dev
.PHONY: run-frontend-development

191
README.md
View File

@@ -1,200 +1,113 @@
<p align="center">
<a href="https://github.com/suitenumerique/docs">
<img alt="Docs" src="/docs/assets/docs-logo.png" width="300" />
</a>
</p>
# Impress
<p align="center">
Welcome to Docs! The open source document editor where your notes can become knowledge through live collaboration
</p>
Impress is a web application for real-time collaborative text editing with user and role based access rights.
Features include :
- User authentication through OIDC
- BlocNote.js text editing experience (markdown support, dynamic conversion, block structure, slash commands for block creation)
- Document export to pdf and docx from predefined templates
- Granular document permissions
- Public link sharing
- Offline mode
<p align="center">
<a href="https://matrix.to/#/#docs-official:matrix.org">
Chat on Matrix
</a> - <a href="/docs/">
Documentation
</a> - <a href="#getting-started-">
Getting started
</a> - <a href="mailto:docs@numerique.gouv.fr">
Reach out
</a>
</p>
Impress is built on top of [Django Rest Framework](https://www.django-rest-framework.org/), [Next.js](https://nextjs.org/) and [BlocNote.js](https://www.blocknotejs.org/)
<img src="/docs/assets/docs_live_collaboration_light.gif" width="100%" align="center"/>
## Getting started
## Why use Docs ❓
### Prerequisite
Docs is a collaborative text editor designed to address common challenges in knowledge building and sharing.
Make sure you have a recent version of Docker and [Docker
Compose](https://docs.docker.com/compose/install) installed on your laptop:
### Write
* 😌 Simple collaborative editing without the formatting complexity of markdown
* 🔌 Offline? No problem, keep writing, your edits will get synced when back online
* 💅 Create clean documents with limited but beautiful formatting options and focus on content
* 🧱 Built for productivity (markdown support, many block types, slash commands, keyboard shortcuts).
* ✨ Save time thanks to our AI actions (generate, sum up, correct, translate)
### Collaborate
* 🤝 Collaborate with your team in real time
* 🔒 Granular access control to ensure your information is secure and only shared with the right people
* 📑 Professional document exports in multiple formats (.odt, .doc, .pdf) with customizable templates
* 📚 Built-in wiki functionality to turn your team's collaborative work into organized knowledge `ETA 02/2025`
### Self-host
* 🚀 Easy to install, scalable and secure alternative to Notion, Outline or Confluence
## Getting started 🔧
### Test it
Test Docs on your browser by logging in on this [environment](https://impress-preprod.beta.numerique.gouv.fr/docs/0aa856e9-da41-4d59-b73d-a61cb2c1245f/)
```
email: test.docs@yopmail.com
password: I'd<3ToTestDocs
```
### Run it locally
> ⚠️ Running Docs locally using the methods described below is for testing purposes only. It is based on building Docs using Minio as the S3 storage solution: if you want to use Minio for production deployment of Docs, you will need to comply with Minio's AGPL-3.0 licence.
**Prerequisite**
Make sure you have a recent version of Docker and [Docker Compose](https://docs.docker.com/compose/install) installed on your laptop:
```shellscript
```bash
$ docker -v
Docker version 20.10.2, build 2291f61
Docker version 20.10.2, build 2291f61
$ docker compose version
Docker Compose version v2.32.4
$ docker compose -v
docker compose version 1.27.4, build 40524192
```
> ⚠️ You may need to run the following commands with sudo but this can be avoided by adding your user to the `docker` group.
> ⚠️ You may need to run the following commands with `sudo` but this can be
> avoided by assigning your user to the `docker` group.
**Project bootstrap**
### Project bootstrap
The easiest way to start working on the project is to use GNU Make:
```shellscript
```bash
$ make bootstrap FLUSH_ARGS='--no-input'
```
This command builds the `app` container, installs dependencies, performs database migrations and compile translations. It's a good idea to use this command each time you are pulling code from the project repository to avoid dependency-related or migration-related issues.
This command builds the `app` container, installs dependencies, performs
database migrations and compile translations. It's a good idea to use this
command each time you are pulling code from the project repository to avoid
dependency-releated or migration-releated issues.
Your Docker services should now be up and running 🎉
You can access to the project by going to <http://localhost:3000>.
You can access to the project by going to http://localhost:3000.
You will be prompted to log in, the default credentials are:
```
```bash
username: impress
password: impress
```
📝 Note that if you need to run them afterwards, you can use the eponym Make rule:
```shellscript
$ make run
```bash
$ make run-with-frontend
```
⚠️ For the frontend developer, it is often better to run the frontend in development mode locally.
---
⚠️ For the frontend developper, it is often better to run the frontend in development mode locally.
To do so, install the frontend dependencies with the following command:
```shellscript
$ make frontend-development-install
```bash
$ make frontend-install
```
And run the frontend locally in development mode with the following command:
```shellscript
```bash
$ make run-frontend-development
```
To start all the services, except the frontend container, you can use the following command:
```shellscript
$ make run-backend
```bash
$ make run
```
**Adding content**
---
### Adding content
You can create a basic demo site by running:
```shellscript
$ make demo
```
$ make demo
Finally, you can check all available Make rules using:
```shellscript
```bash
$ make help
```
**Django admin**
### Django admin
You can access the Django admin site at
<http://localhost:8071/admin>.
[http://localhost:8071/admin](http://localhost:8071/admin).
You first need to create a superuser account:
```shellscript
```bash
$ make superuser
```
## Feedback 🙋‍♂️🙋‍♀️
## Contributing
We'd love to hear your thoughts and hear about your experiments, so come and say hi on [Matrix](https://matrix.to/#/#docs-official:matrix.org).
This project is intended to be community-driven, so please, do not hesitate to
get in touch if you have any question related to our implementation or design
decisions.
## Roadmap
## License
Want to know where the project is headed? [🗺️ Checkout our roadmap](https://github.com/orgs/numerique-gouv/projects/13/views/11)
## Licence 📝
This work is released under the MIT License (see [LICENSE](https://github.com/suitenumerique/docs/blob/main/LICENSE)).
While Docs is a public driven initiative our licence choice is an invitation for private sector actors to use, sell and contribute to the project.
## Contributing 🙌
This project is intended to be community-driven, so please, do not hesitate to [get in touch](https://matrix.to/#/#docs-official:matrix.org) if you have any question related to our implementation or design decisions.
You can help us with translations on [Crowdin](https://crowdin.com/project/lasuite-docs).
If you intend to make pull requests see [CONTRIBUTING](https://github.com/suitenumerique/docs/blob/main/CONTRIBUTING.md) for guidelines.
Directory structure:
```markdown
docs
├── bin - executable scripts or binaries that are used for various tasks, such as setup scripts, utility scripts, or custom commands.
├── crowdin - for crowdin translations, a tool or service that helps manage translations for the project.
├── docker - Dockerfiles and related configuration files used to build Docker images for the project. These images can be used for development, testing, or production environments.
├── docs - documentation for the project, including user guides, API documentation, and other helpful resources.
├── env.d/development - environment-specific configuration files for the development environment. These files might include environment variables, configuration settings, or other setup files needed for development.
├── gitlint - configuration files for `gitlint`, a tool that enforces commit message guidelines to ensure consistency and quality in commit messages.
├── playground - experimental or temporary code, where developers can test new features or ideas without affecting the main codebase.
└── src - main source code directory, containing the core application code, libraries, and modules of the project.
```
## Credits ❤️
### Stack
Docs is built on top of [Django Rest Framework](https://www.django-rest-framework.org/), [Next.js](https://nextjs.org/), [BlockNote.js](https://www.blocknotejs.org/), [HocusPocus](https://tiptap.dev/docs/hocuspocus/introduction) and [Yjs](https://yjs.dev/).
### Gov ❤️ open source
Docs is the result of a joint effort led by the French 🇫🇷🥖 ([DINUM](https://www.numerique.gouv.fr/dinum/)) and German 🇩🇪🥨 governments ([ZenDiS](https://zendis.de/)).
We are proud sponsors of [BlockNotejs](https://www.blocknotejs.org/) and [Yjs](https://yjs.dev/).
We are always looking for new public partners (we are currently onboarding the Netherlands 🇳🇱🧀), feel free to [reach out](mailto:docs@numerique.gouv.fr) if you are interested in using or contributing to Docs.
<p align="center">
<img src="/docs/assets/europe_opensource.png" width="50%"/>
</p>
This work is released under the MIT License (see [LICENSE](./LICENSE)).

View File

@@ -1,23 +0,0 @@
# Security Policy
## Reporting a Vulnerability
Security is very important to us.
If you have any issue regarding security, please disclose the information responsibly submiting [this form](https://vdp.numerique.gouv.fr/p/Send-a-report?lang=en) and not by creating an issue on the repository. You can also email us at docs@numerique.gouv.fr
We appreciate your effort to make Docs more secure.
## Vulnerability disclosure policy
Working with security issues in an open source project can be challenging, as we are required to disclose potential problems that could be exploited by attackers. With this in mind, our security fix policy is as follows:
1. The Maintainers team will handle the fix as usual (Pull Request,
release).
2. In the release notes, we will include the identification numbers from the
GitHub Advisory Database (GHSA) and, if applicable, the Common Vulnerabilities
and Exposures (CVE) identifier for the vulnerability.
3. Once this grace period has passed, we will publish the vulnerability.
By adhering to this security policy, we aim to address security concerns
effectively and responsibly in our open source software project.

View File

@@ -15,8 +15,3 @@ the following command inside your docker container:
(Note : in your development environment, you can `make migrate`.)
## [Unreleased]
- AI features are now limited to users who are authenticated. Before this release, even anonymous
users who gained editor access on a document with link reach used to get AI feature.
IF you want anonymous users to keep access on AI features, you must now define the
`AI_ALLOW_REACH_FROM` setting to "public".

View File

@@ -7,6 +7,7 @@ UNSET_USER=0
TERRAFORM_DIRECTORY="./env.d/terraform"
COMPOSE_FILE="${REPO_DIR}/docker-compose.yml"
COMPOSE_PROJECT="impress"
# _set_user: set (or unset) default user id used to run docker commands
@@ -39,8 +40,9 @@ function _set_user() {
# ARGS : docker compose command arguments
function _docker_compose() {
echo "🐳(compose) file: '${COMPOSE_FILE}'"
echo "🐳(compose) project: '${COMPOSE_PROJECT}' file: '${COMPOSE_FILE}'"
docker compose \
-p "${COMPOSE_PROJECT}" \
-f "${COMPOSE_FILE}" \
--project-directory "${REPO_DIR}" \
"$@"

View File

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

View File

@@ -1,7 +1,7 @@
#
# Your crowdin's credentials
#
api_token_env: CROWDIN_PERSONAL_TOKEN
api_token_env: CROWDIN_API_TOKEN
project_id_env: CROWDIN_PROJECT_ID
base_path_env: CROWDIN_BASE_PATH
@@ -15,11 +15,11 @@ preserve_hierarchy: true
# Files configuration
#
files: [
{
source : "/backend/locale/django.pot",
dest: "/backend-impress.pot",
translation : "/backend/locale/%locale_with_underscore%/LC_MESSAGES/django.po"
},
{
source : "/backend/locale/django.pot",
dest: "/backend-impress.pot",
translation : "/backend/locale/%locale_with_underscore%/LC_MESSAGES/django.po"
},
{
source: "/frontend/packages/i18n/locales/impress/translations-crowdin.json",
dest: "/frontend-impress.json",

View File

@@ -1,13 +1,6 @@
name: docs
services:
postgresql:
image: postgres:16
healthcheck:
test: ["CMD-SHELL", "pg_isready"]
interval: 1s
timeout: 2s
retries: 300
env_file:
- env.d/development/postgresql
ports:
@@ -30,11 +23,6 @@ services:
ports:
- '9000:9000'
- '9001:9001'
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 1s
timeout: 20s
retries: 300
entrypoint: ""
command: minio server --console-address :9001 /data
volumes:
@@ -43,9 +31,7 @@ services:
createbuckets:
image: minio/mc
depends_on:
minio:
condition: service_healthy
restart: true
- minio
entrypoint: >
sh -c "
/usr/bin/mc alias set impress http://minio:9000 impress password && \
@@ -73,15 +59,10 @@ services:
- ./src/backend:/app
- ./data/static:/data/static
depends_on:
postgresql:
condition: service_healthy
restart: true
mailcatcher:
condition: service_started
redis:
condition: service_started
createbuckets:
condition: service_started
- postgresql
- mailcatcher
- redis
- createbuckets
celery-dev:
user: ${DOCKER_USER:-1000}
@@ -112,13 +93,9 @@ services:
- env.d/development/common
- env.d/development/postgresql
depends_on:
postgresql:
condition: service_healthy
restart: true
redis:
condition: service_started
minio:
condition: service_started
- postgresql
- redis
- minio
celery:
user: ${DOCKER_USER:-1000}
@@ -139,15 +116,11 @@ services:
volumes:
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
depends_on:
app-dev:
condition: service_started
y-provider:
condition: service_started
keycloak:
condition: service_healthy
restart: true
- keycloak
- app-dev
- y-provider
frontend:
frontend-dev:
user: "${DOCKER_USER:-1000}"
build:
context: .
@@ -162,6 +135,9 @@ services:
ports:
- "3000:3000"
dockerize:
image: jwilder/dockerize
crowdin:
image: crowdin/cli:3.16.0
volumes:
@@ -175,7 +151,7 @@ services:
image: node:18
user: "${DOCKER_USER:-1000}"
environment:
HOME: /tmp
HOME: /tmp
volumes:
- ".:/app"
@@ -193,11 +169,6 @@ services:
kc_postgresql:
image: postgres:14.3
healthcheck:
test: ["CMD-SHELL", "pg_isready"]
interval: 1s
timeout: 2s
retries: 300
ports:
- "5433:5432"
env_file:
@@ -216,13 +187,6 @@ services:
- --hostname-admin-url=http://localhost:8083/
- --hostname-strict=false
- --hostname-strict-https=false
- --health-enabled=true
- --metrics-enabled=true
healthcheck:
test: ["CMD", "curl", "--head", "-fsS", "http://localhost:8080/health/ready"]
interval: 1s
timeout: 2s
retries: 300
environment:
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
@@ -236,6 +200,4 @@ services:
ports:
- "8080:8080"
depends_on:
kc_postgresql:
condition: service_healthy
restart: true
- kc_postgresql

View File

@@ -88,11 +88,5 @@ server {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# Increase proxy buffer size to allow keycloak to send large
# header responses when a user is created.
proxy_buffer_size 128k;
proxy_buffers 4 256k;
proxy_busy_buffers_size 256k;
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,3 @@
CROWDIN_PERSONAL_TOKEN=Your-Personal-Token
CROWDIN_API_TOKEN=Your-Api-Token
CROWDIN_PROJECT_ID=Your-Project-Id
CROWDIN_BASE_PATH=/app/src

745
package-lock.json generated
View File

@@ -1,745 +0,0 @@
{
"name": "docs",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"@ag-media/react-pdf-table": "^2.0.1"
}
},
"node_modules/@ag-media/react-pdf-table": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@ag-media/react-pdf-table/-/react-pdf-table-2.0.1.tgz",
"integrity": "sha512-UMNdGYAfuI6L1wLRziYmwcp/8I2JgbwX+PY7bHXGb2+P6MwgFJH8W71qZO1bxfxrmVUTP8YblQwl1PkXG2m6Rg==",
"license": "MIT",
"peerDependencies": {
"@react-pdf/renderer": "^2.0.2 || ^3.0.0 || ^4.0.0",
"@react-pdf/stylesheet": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0",
"react": "^16.8.6 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@babel/runtime": {
"version": "7.26.9",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.9.tgz",
"integrity": "sha512-aA63XwOkcl4xxQa3HjPMqOP6LiK0ZDv3mUPYEFXkpHbaFjtGggE1A61FjFzJnB+p7/oy2gA8E+rcBNl/zC1tMg==",
"license": "MIT",
"peer": true,
"dependencies": {
"regenerator-runtime": "^0.14.0"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@react-pdf/fns": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@react-pdf/fns/-/fns-3.1.1.tgz",
"integrity": "sha512-fYvgOWWRxTdkCciLSla2iek8W/oDLhExPTLPw3aArGPJHgVUc86V2c3YLULNHIBuy/64QVpPLB7gwNkTEW5m/A==",
"license": "MIT",
"peer": true
},
"node_modules/@react-pdf/font": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@react-pdf/font/-/font-3.1.0.tgz",
"integrity": "sha512-5q+r3DhZK41gVZp2Uw5M69FEVWeoasnM/HscW3kdpYnwjcB2bhCRWmBGCjm8fmuwQstwNPM1ZxyCWZRTRchwnA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@react-pdf/types": "^2.8.0",
"fontkit": "^2.0.2",
"is-url": "^1.2.4"
}
},
"node_modules/@react-pdf/image": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@react-pdf/image/-/image-3.0.2.tgz",
"integrity": "sha512-GrlApNDxLdFKN1ia+nt1svrnpBJIwf2ncK4Km/hQzAkbALn0HQ5YVrOEtMpnp/c0L0o9zO4hSoPL9iEQne5vzw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@react-pdf/png-js": "^3.0.0",
"jay-peg": "^1.1.1"
}
},
"node_modules/@react-pdf/layout": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/@react-pdf/layout/-/layout-4.2.3.tgz",
"integrity": "sha512-sSL14ki0nC8YwSrjkOzKI1ZV4xZC68v/wA5EFWW6IhmJ3qjX4+KbNdprWBCtih/Xbq2Kt9K1erZpKFiWoVf5/Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.20.13",
"@react-pdf/fns": "3.1.1",
"@react-pdf/image": "^3.0.2",
"@react-pdf/pdfkit": "^4.0.2",
"@react-pdf/primitives": "^4.1.1",
"@react-pdf/stylesheet": "^6.0.0",
"@react-pdf/textkit": "^5.0.3",
"@react-pdf/types": "^2.8.0",
"emoji-regex": "^10.3.0",
"queue": "^6.0.1",
"yoga-layout": "^3.2.1"
}
},
"node_modules/@react-pdf/layout/node_modules/@react-pdf/stylesheet": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@react-pdf/stylesheet/-/stylesheet-6.0.0.tgz",
"integrity": "sha512-uAwuMjbcEaxhRl7tGlqxAbLzo/KoYr6v9JksUJwgzd+rkvAp8jDq8NcG3sUp88tzgIyyRjBGl4FewgdxbAa2uw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@react-pdf/fns": "3.1.1",
"@react-pdf/types": "^2.8.0",
"color-string": "^1.9.1",
"hsl-to-hex": "^1.0.0",
"media-engine": "^1.0.3",
"postcss-value-parser": "^4.1.0"
}
},
"node_modules/@react-pdf/pdfkit": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@react-pdf/pdfkit/-/pdfkit-4.0.2.tgz",
"integrity": "sha512-pyYFAI7YL5Oud60W+wcu9zsN73tg8XgHGtEM8FQ6PY4RgEKp+AXkj+YE2hKuX3eOVB65MPzbJbVWtTjO3MPa5g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.20.13",
"@react-pdf/png-js": "^3.0.0",
"browserify-zlib": "^0.2.0",
"crypto-js": "^4.2.0",
"fontkit": "^2.0.2",
"jay-peg": "^1.1.1",
"linebreak": "^1.1.0",
"vite-compatible-readable-stream": "^3.6.1"
}
},
"node_modules/@react-pdf/png-js": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@react-pdf/png-js/-/png-js-3.0.0.tgz",
"integrity": "sha512-eSJnEItZ37WPt6Qv5pncQDxLJRK15eaRwPT+gZoujP548CodenOVp49GST8XJvKMFt9YqIBzGBV/j9AgrOQzVA==",
"license": "MIT",
"peer": true,
"dependencies": {
"browserify-zlib": "^0.2.0"
}
},
"node_modules/@react-pdf/primitives": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@react-pdf/primitives/-/primitives-4.1.1.tgz",
"integrity": "sha512-IuhxYls1luJb7NUWy6q5avb1XrNaVj9bTNI40U9qGRuS6n7Hje/8H8Qi99Z9UKFV74bBP3DOf3L1wV2qZVgVrQ==",
"license": "MIT",
"peer": true
},
"node_modules/@react-pdf/reconciler": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@react-pdf/reconciler/-/reconciler-1.1.3.tgz",
"integrity": "sha512-4vqY0klmUH32kTFvuqdAszkOpwfZYKMLO4VpJ5xZWTsoUOLQSyhC2QM2QCj9eaxpB2Nd5Kl9uW+KfyutvZnMzQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"object-assign": "^4.1.1",
"scheduler": "0.25.0-rc-603e6108-20241029"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@react-pdf/reconciler/node_modules/scheduler": {
"version": "0.25.0-rc-603e6108-20241029",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0-rc-603e6108-20241029.tgz",
"integrity": "sha512-pFwF6H1XrSdYYNLfOcGlM28/j8CGLu8IvdrxqhjWULe2bPcKiKW4CV+OWqR/9fT52mywx65l7ysNkjLKBda7eA==",
"license": "MIT",
"peer": true
},
"node_modules/@react-pdf/render": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@react-pdf/render/-/render-4.1.2.tgz",
"integrity": "sha512-x9R7yaU/EisU2loWLAeVZqUEhkPR1EDa4CXM6PPiPhB2hTZAXgqeZCTVOODX0iGkUBM3scOjzrf5gPPnoMf0jg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.20.13",
"@react-pdf/fns": "3.1.1",
"@react-pdf/primitives": "^4.1.1",
"@react-pdf/textkit": "^5.0.3",
"@react-pdf/types": "^2.8.0",
"abs-svg-path": "^0.1.1",
"color-string": "^1.9.1",
"normalize-svg-path": "^1.1.0",
"parse-svg-path": "^0.1.2",
"svg-arc-to-cubic-bezier": "^3.2.0"
}
},
"node_modules/@react-pdf/renderer": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@react-pdf/renderer/-/renderer-4.2.2.tgz",
"integrity": "sha512-ldWT9Mi+Ie50oqH0NxYZ1UsnZF7BmhoUiI9GMyXBxMvaiG4lIGR5ki8KWLmA6Ti6+Yp7jXNWg0sYDrvZRLiLjg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.20.13",
"@react-pdf/fns": "3.1.1",
"@react-pdf/font": "^3.1.0",
"@react-pdf/layout": "^4.2.3",
"@react-pdf/pdfkit": "^4.0.2",
"@react-pdf/primitives": "^4.1.1",
"@react-pdf/reconciler": "^1.1.3",
"@react-pdf/render": "^4.1.2",
"@react-pdf/types": "^2.8.0",
"events": "^3.3.0",
"object-assign": "^4.1.1",
"prop-types": "^15.6.2",
"queue": "^6.0.1"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@react-pdf/stylesheet": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/@react-pdf/stylesheet/-/stylesheet-5.2.2.tgz",
"integrity": "sha512-oHP+hZakETrecnZCSRPqNvFhSyBgoZSDOkonY9WJOxRkUb6P6A+mAVSOWBaNt2eM4FHMDpYDeR9stx+gAWn6gg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.20.13",
"@react-pdf/fns": "3.1.0",
"@react-pdf/types": "^2.7.1",
"color-string": "^1.9.1",
"hsl-to-hex": "^1.0.0",
"media-engine": "^1.0.3",
"postcss-value-parser": "^4.1.0"
}
},
"node_modules/@react-pdf/stylesheet/node_modules/@react-pdf/fns": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@react-pdf/fns/-/fns-3.1.0.tgz",
"integrity": "sha512-BjT7C/IeYlrF4Pevlrlo+fILhSxsWSm6Ka/rQrQzYsyQuOsqI6bmBzsTW+T6ghqrD5HLRKr1n8vjAaE9g4rFhA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.20.13"
}
},
"node_modules/@react-pdf/textkit": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/@react-pdf/textkit/-/textkit-5.0.3.tgz",
"integrity": "sha512-gRQBw2lOlGl/gZR2O9Joxu3TqlP0u3wy8KVMw3R6glqDSrgLH43cNfdOWIchNvL6adRIjxd8l/FCv2u7zcHqOQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@react-pdf/fns": "3.1.1",
"bidi-js": "^1.0.2",
"hyphen": "^1.6.4",
"unicode-properties": "^1.4.1"
}
},
"node_modules/@react-pdf/types": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@react-pdf/types/-/types-2.8.0.tgz",
"integrity": "sha512-lBnLonM2GupyTzUGlWTEoUUGvsRcgbWLn0Py3i3lK/tgn2rPCYwJ9gQ5A3warT5g4jQWyc7HmaNoPU/Zy5iBbQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@react-pdf/font": "^3.1.0",
"@react-pdf/primitives": "^4.1.1",
"@react-pdf/stylesheet": "^6.0.0"
}
},
"node_modules/@react-pdf/types/node_modules/@react-pdf/stylesheet": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@react-pdf/stylesheet/-/stylesheet-6.0.0.tgz",
"integrity": "sha512-uAwuMjbcEaxhRl7tGlqxAbLzo/KoYr6v9JksUJwgzd+rkvAp8jDq8NcG3sUp88tzgIyyRjBGl4FewgdxbAa2uw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@react-pdf/fns": "3.1.1",
"@react-pdf/types": "^2.8.0",
"color-string": "^1.9.1",
"hsl-to-hex": "^1.0.0",
"media-engine": "^1.0.3",
"postcss-value-parser": "^4.1.0"
}
},
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
"integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/abs-svg-path": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/abs-svg-path/-/abs-svg-path-0.1.1.tgz",
"integrity": "sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==",
"license": "MIT",
"peer": true
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"peer": true
},
"node_modules/bidi-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
"license": "MIT",
"peer": true,
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/brotli": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
"license": "MIT",
"peer": true,
"dependencies": {
"base64-js": "^1.1.2"
}
},
"node_modules/browserify-zlib": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz",
"integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==",
"license": "MIT",
"peer": true,
"dependencies": {
"pako": "~1.0.5"
}
},
"node_modules/clone": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.8"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT",
"peer": true
},
"node_modules/color-string": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
"integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
"license": "MIT",
"peer": true,
"dependencies": {
"color-name": "^1.0.0",
"simple-swizzle": "^0.2.2"
}
},
"node_modules/crypto-js": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
"license": "MIT",
"peer": true
},
"node_modules/dfa": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
"license": "MIT",
"peer": true
},
"node_modules/emoji-regex": {
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz",
"integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==",
"license": "MIT",
"peer": true
},
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.8.x"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT",
"peer": true
},
"node_modules/fontkit": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@swc/helpers": "^0.5.12",
"brotli": "^1.3.2",
"clone": "^2.1.2",
"dfa": "^1.2.0",
"fast-deep-equal": "^3.1.3",
"restructure": "^3.0.0",
"tiny-inflate": "^1.0.3",
"unicode-properties": "^1.4.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/hsl-to-hex": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/hsl-to-hex/-/hsl-to-hex-1.0.0.tgz",
"integrity": "sha512-K6GVpucS5wFf44X0h2bLVRDsycgJmf9FF2elg+CrqD8GcFU8c6vYhgXn8NjUkFCwj+xDFb70qgLbTUm6sxwPmA==",
"license": "MIT",
"peer": true,
"dependencies": {
"hsl-to-rgb-for-reals": "^1.1.0"
}
},
"node_modules/hsl-to-rgb-for-reals": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/hsl-to-rgb-for-reals/-/hsl-to-rgb-for-reals-1.1.1.tgz",
"integrity": "sha512-LgOWAkrN0rFaQpfdWBQlv/VhkOxb5AsBjk6NQVx4yEzWS923T07X0M1Y0VNko2H52HeSpZrZNNMJ0aFqsdVzQg==",
"license": "ISC",
"peer": true
},
"node_modules/hyphen": {
"version": "1.10.6",
"resolved": "https://registry.npmjs.org/hyphen/-/hyphen-1.10.6.tgz",
"integrity": "sha512-fXHXcGFTXOvZTSkPJuGOQf5Lv5T/R2itiiCVPg9LxAje5D00O0pP83yJShFq5V89Ly//Gt6acj7z8pbBr34stw==",
"license": "ISC",
"peer": true
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC",
"peer": true
},
"node_modules/is-arrayish": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz",
"integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==",
"license": "MIT",
"peer": true
},
"node_modules/is-url": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
"license": "MIT",
"peer": true
},
"node_modules/jay-peg": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/jay-peg/-/jay-peg-1.1.1.tgz",
"integrity": "sha512-D62KEuBxz/ip2gQKOEhk/mx14o7eiFRaU+VNNSP4MOiIkwb/D6B3G1Mfas7C/Fit8EsSV2/IWjZElx/Gs6A4ww==",
"license": "MIT",
"peer": true,
"dependencies": {
"restructure": "^3.0.0"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"license": "MIT",
"peer": true
},
"node_modules/linebreak": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"base64-js": "0.0.8",
"unicode-trie": "^2.0.0"
}
},
"node_modules/linebreak/node_modules/base64-js": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.4"
}
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/media-engine": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/media-engine/-/media-engine-1.0.3.tgz",
"integrity": "sha512-aa5tG6sDoK+k70B9iEX1NeyfT8ObCKhNDs6lJVpwF6r8vhUfuKMslIcirq6HIUYuuUYLefcEQOn9bSBOvawtwg==",
"license": "MIT",
"peer": true
},
"node_modules/normalize-svg-path": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/normalize-svg-path/-/normalize-svg-path-1.1.0.tgz",
"integrity": "sha512-r9KHKG2UUeB5LoTouwDzBy2VxXlHsiM6fyLQvnJa0S5hrhzqElH/CH7TUGhT1fVvIYBIKf3OpY4YJ4CK+iaqHg==",
"license": "MIT",
"peer": true,
"dependencies": {
"svg-arc-to-cubic-bezier": "^3.0.0"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)",
"peer": true
},
"node_modules/parse-svg-path": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.1.2.tgz",
"integrity": "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==",
"license": "MIT",
"peer": true
},
"node_modules/postcss-value-parser": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"license": "MIT",
"peer": true
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.13.1"
}
},
"node_modules/queue": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
"integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
"license": "MIT",
"peer": true,
"dependencies": {
"inherits": "~2.0.3"
}
},
"node_modules/react": {
"version": "19.0.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz",
"integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT",
"peer": true
},
"node_modules/regenerator-runtime": {
"version": "0.14.1",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==",
"license": "MIT",
"peer": true
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/restructure": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
"license": "MIT",
"peer": true
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"peer": true
},
"node_modules/simple-swizzle": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz",
"integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==",
"license": "MIT",
"peer": true,
"dependencies": {
"is-arrayish": "^0.3.1"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"peer": true,
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/svg-arc-to-cubic-bezier": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/svg-arc-to-cubic-bezier/-/svg-arc-to-cubic-bezier-3.2.0.tgz",
"integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==",
"license": "ISC",
"peer": true
},
"node_modules/tiny-inflate": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
"license": "MIT",
"peer": true
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD",
"peer": true
},
"node_modules/unicode-properties": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
"license": "MIT",
"peer": true,
"dependencies": {
"base64-js": "^1.3.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/unicode-trie": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"pako": "^0.2.5",
"tiny-inflate": "^1.0.0"
}
},
"node_modules/unicode-trie/node_modules/pako": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
"license": "MIT",
"peer": true
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT",
"peer": true
},
"node_modules/vite-compatible-readable-stream": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/vite-compatible-readable-stream/-/vite-compatible-readable-stream-3.6.1.tgz",
"integrity": "sha512-t20zYkrSf868+j/p31cRIGN28Phrjm3nRSLR2fyc2tiWi4cZGVdv68yNlwnIINTkMTmPoMiSlc0OadaO7DXZaQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/yoga-layout": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz",
"integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==",
"license": "MIT",
"peer": true
}
}
}

View File

@@ -1,5 +0,0 @@
{
"dependencies": {
"@ag-media/react-pdf-table": "^2.0.1"
}
}

314
q
View File

@@ -1,314 +0,0 @@
SSUUMMMMAARRYY OOFF LLEESSSS CCOOMMMMAANNDDSS
Commands marked with * may be preceded by a number, _N.
Notes in parentheses indicate the behavior if _N is given.
A key preceded by a caret indicates the Ctrl key; thus ^K is ctrl-K.
h H Display this help.
q :q Q :Q ZZ Exit.
---------------------------------------------------------------------------
MMOOVVIINNGG
e ^E j ^N CR * Forward one line (or _N lines).
y ^Y k ^K ^P * Backward one line (or _N lines).
f ^F ^V SPACE * Forward one window (or _N lines).
b ^B ESC-v * Backward one window (or _N lines).
z * Forward one window (and set window to _N).
w * Backward one window (and set window to _N).
ESC-SPACE * Forward one window, but don't stop at end-of-file.
d ^D * Forward one half-window (and set half-window to _N).
u ^U * Backward one half-window (and set half-window to _N).
ESC-) RightArrow * Right one half screen width (or _N positions).
ESC-( LeftArrow * Left one half screen width (or _N positions).
ESC-} ^RightArrow Right to last column displayed.
ESC-{ ^LeftArrow Left to first column.
F Forward forever; like "tail -f".
ESC-F Like F but stop when search pattern is found.
r ^R ^L Repaint screen.
R Repaint screen, discarding buffered input.
---------------------------------------------------
Default "window" is the screen height.
Default "half-window" is half of the screen height.
---------------------------------------------------------------------------
SSEEAARRCCHHIINNGG
/_p_a_t_t_e_r_n * Search forward for (_N-th) matching line.
?_p_a_t_t_e_r_n * Search backward for (_N-th) matching line.
n * Repeat previous search (for _N-th occurrence).
N * Repeat previous search in reverse direction.
ESC-n * Repeat previous search, spanning files.
ESC-N * Repeat previous search, reverse dir. & spanning files.
^O^N ^On * Search forward for (_N-th) OSC8 hyperlink.
^O^P ^Op * Search backward for (_N-th) OSC8 hyperlink.
^O^L ^Ol Jump to the currently selected OSC8 hyperlink.
ESC-u Undo (toggle) search highlighting.
ESC-U Clear search highlighting.
&_p_a_t_t_e_r_n * Display only matching lines.
---------------------------------------------------
A search pattern may begin with one or more of:
^N or ! Search for NON-matching lines.
^E or * Search multiple files (pass thru END OF FILE).
^F or @ Start search at FIRST file (for /) or last file (for ?).
^K Highlight matches, but don't move (KEEP position).
^R Don't use REGULAR EXPRESSIONS.
^S _n Search for match in _n-th parenthesized subpattern.
^W WRAP search if no match found.
^L Enter next character literally into pattern.
---------------------------------------------------------------------------
JJUUMMPPIINNGG
g < ESC-< * Go to first line in file (or line _N).
G > ESC-> * Go to last line in file (or line _N).
p % * Go to beginning of file (or _N percent into file).
t * Go to the (_N-th) next tag.
T * Go to the (_N-th) previous tag.
{ ( [ * Find close bracket } ) ].
} ) ] * Find open bracket { ( [.
ESC-^F _<_c_1_> _<_c_2_> * Find close bracket _<_c_2_>.
ESC-^B _<_c_1_> _<_c_2_> * Find open bracket _<_c_1_>.
---------------------------------------------------
Each "find close bracket" command goes forward to the close bracket
matching the (_N-th) open bracket in the top line.
Each "find open bracket" command goes backward to the open bracket
matching the (_N-th) close bracket in the bottom line.
m_<_l_e_t_t_e_r_> Mark the current top line with <letter>.
M_<_l_e_t_t_e_r_> Mark the current bottom line with <letter>.
'_<_l_e_t_t_e_r_> Go to a previously marked position.
'' Go to the previous position.
^X^X Same as '.
ESC-m_<_l_e_t_t_e_r_> Clear a mark.
---------------------------------------------------
A mark is any upper-case or lower-case letter.
Certain marks are predefined:
^ means beginning of the file
$ means end of the file
---------------------------------------------------------------------------
CCHHAANNGGIINNGG FFIILLEESS
:e [_f_i_l_e] Examine a new file.
^X^V Same as :e.
:n * Examine the (_N-th) next file from the command line.
:p * Examine the (_N-th) previous file from the command line.
:x * Examine the first (or _N-th) file from the command line.
^O^O Open the currently selected OSC8 hyperlink.
:d Delete the current file from the command line list.
= ^G :f Print current file name.
---------------------------------------------------------------------------
MMIISSCCEELLLLAANNEEOOUUSS CCOOMMMMAANNDDSS
-_<_f_l_a_g_> Toggle a command line option [see OPTIONS below].
--_<_n_a_m_e_> Toggle a command line option, by name.
__<_f_l_a_g_> Display the setting of a command line option.
___<_n_a_m_e_> Display the setting of an option, by name.
+_c_m_d Execute the less cmd each time a new file is examined.
!_c_o_m_m_a_n_d Execute the shell command with $SHELL.
#_c_o_m_m_a_n_d Execute the shell command, expanded like a prompt.
|XX_c_o_m_m_a_n_d Pipe file between current pos & mark XX to shell command.
s _f_i_l_e Save input to a file.
v Edit the current file with $VISUAL or $EDITOR.
V Print version number of "less".
---------------------------------------------------------------------------
OOPPTTIIOONNSS
Most options may be changed either on the command line,
or from within less by using the - or -- command.
Options may be given in one of two forms: either a single
character preceded by a -, or a name preceded by --.
-? ........ --help
Display help (from command line).
-a ........ --search-skip-screen
Search skips current screen.
-A ........ --SEARCH-SKIP-SCREEN
Search starts just after target line.
-b [_N] .... --buffers=[_N]
Number of buffers.
-B ........ --auto-buffers
Don't automatically allocate buffers for pipes.
-c ........ --clear-screen
Repaint by clearing rather than scrolling.
-d ........ --dumb
Dumb terminal.
-D xx_c_o_l_o_r . --color=xx_c_o_l_o_r
Set screen colors.
-e -E .... --quit-at-eof --QUIT-AT-EOF
Quit at end of file.
-f ........ --force
Force open non-regular files.
-F ........ --quit-if-one-screen
Quit if entire file fits on first screen.
-g ........ --hilite-search
Highlight only last match for searches.
-G ........ --HILITE-SEARCH
Don't highlight any matches for searches.
-h [_N] .... --max-back-scroll=[_N]
Backward scroll limit.
-i ........ --ignore-case
Ignore case in searches that do not contain uppercase.
-I ........ --IGNORE-CASE
Ignore case in all searches.
-j [_N] .... --jump-target=[_N]
Screen position of target lines.
-J ........ --status-column
Display a status column at left edge of screen.
-k _f_i_l_e ... --lesskey-file=_f_i_l_e
Use a compiled lesskey file.
-K ........ --quit-on-intr
Exit less in response to ctrl-C.
-L ........ --no-lessopen
Ignore the LESSOPEN environment variable.
-m -M .... --long-prompt --LONG-PROMPT
Set prompt style.
-n ......... --line-numbers
Suppress line numbers in prompts and messages.
-N ......... --LINE-NUMBERS
Display line number at start of each line.
-o [_f_i_l_e] .. --log-file=[_f_i_l_e]
Copy to log file (standard input only).
-O [_f_i_l_e] .. --LOG-FILE=[_f_i_l_e]
Copy to log file (unconditionally overwrite).
-p _p_a_t_t_e_r_n . --pattern=[_p_a_t_t_e_r_n]
Start at pattern (from command line).
-P [_p_r_o_m_p_t] --prompt=[_p_r_o_m_p_t]
Define new prompt.
-q -Q .... --quiet --QUIET --silent --SILENT
Quiet the terminal bell.
-r -R .... --raw-control-chars --RAW-CONTROL-CHARS
Output "raw" control characters.
-s ........ --squeeze-blank-lines
Squeeze multiple blank lines.
-S ........ --chop-long-lines
Chop (truncate) long lines rather than wrapping.
-t _t_a_g .... --tag=[_t_a_g]
Find a tag.
-T [_t_a_g_s_f_i_l_e] --tag-file=[_t_a_g_s_f_i_l_e]
Use an alternate tags file.
-u -U .... --underline-special --UNDERLINE-SPECIAL
Change handling of backspaces, tabs and carriage returns.
-V ........ --version
Display the version number of "less".
-w ........ --hilite-unread
Highlight first new line after forward-screen.
-W ........ --HILITE-UNREAD
Highlight first new line after any forward movement.
-x [_N[,...]] --tabs=[_N[,...]]
Set tab stops.
-X ........ --no-init
Don't use termcap init/deinit strings.
-y [_N] .... --max-forw-scroll=[_N]
Forward scroll limit.
-z [_N] .... --window=[_N]
Set size of window.
-" [_c[_c]] . --quotes=[_c[_c]]
Set shell quote characters.
-~ ........ --tilde
Don't display tildes after end of file.
-# [_N] .... --shift=[_N]
Set horizontal scroll amount (0 = one half screen width).
--exit-follow-on-close
Exit F command on a pipe when writer closes pipe.
--file-size
Automatically determine the size of the input file.
--follow-name
The F command changes files if the input file is renamed.
--header=[_L[,_C[,_N]]]
Use _L lines (starting at line _N) and _C columns as headers.
--incsearch
Search file as each pattern character is typed in.
--intr=[_C]
Use _C instead of ^X to interrupt a read.
--lesskey-context=_t_e_x_t
Use lesskey source file contents.
--lesskey-src=_f_i_l_e
Use a lesskey source file.
--line-num-width=[_N]
Set the width of the -N line number field to _N characters.
--match-shift=[_N]
Show at least _N characters to the left of a search match.
--modelines=[_N]
Read _N lines from the input file and look for vim modelines.
--mouse
Enable mouse input.
--no-keypad
Don't send termcap keypad init/deinit strings.
--no-histdups
Remove duplicates from command history.
--no-number-headers
Don't give line numbers to header lines.
--no-search-header-lines
Searches do not include header lines.
--no-search-header-columns
Searches do not include header columns.
--no-search-headers
Searches do not include header lines or columns.
--no-vbell
Disable the terminal's visual bell.
--redraw-on-quit
Redraw final screen when quitting.
--rscroll=[_C]
Set the character used to mark truncated lines.
--save-marks
Retain marks across invocations of less.
--search-options=[EFKNRW-]
Set default options for every search.
--show-preproc-errors
Display a message if preprocessor exits with an error status.
--proc-backspace
Process backspaces for bold/underline.
--PROC-BACKSPACE
Treat backspaces as control characters.
--proc-return
Delete carriage returns before newline.
--PROC-RETURN
Treat carriage returns as control characters.
--proc-tab
Expand tabs to spaces.
--PROC-TAB
Treat tabs as control characters.
--status-col-width=[_N]
Set the width of the -J status column to _N characters.
--status-line
Highlight or color the entire line containing a mark.
--use-backslash
Subsequent options use backslash as escape char.
--use-color
Enables colored text.
--wheel-lines=[_N]
Each click of the mouse wheel moves _N lines.
--wordwrap
Wrap lines at spaces.
---------------------------------------------------------------------------
LLIINNEE EEDDIITTIINNGG
These keys can be used to edit text being entered
on the "command line" at the bottom of the screen.
RightArrow ..................... ESC-l ... Move cursor right one character.
LeftArrow ...................... ESC-h ... Move cursor left one character.
ctrl-RightArrow ESC-RightArrow ESC-w ... Move cursor right one word.
ctrl-LeftArrow ESC-LeftArrow ESC-b ... Move cursor left one word.
HOME ........................... ESC-0 ... Move cursor to start of line.
END ............................ ESC-$ ... Move cursor to end of line.
BACKSPACE ................................ Delete char to left of cursor.
DELETE ......................... ESC-x ... Delete char under cursor.
ctrl-BACKSPACE ESC-BACKSPACE ........... Delete word to left of cursor.
ctrl-DELETE .... ESC-DELETE .... ESC-X ... Delete word under cursor.
ctrl-U ......... ESC (MS-DOS only) ....... Delete entire line.
UpArrow ........................ ESC-k ... Retrieve previous command line.
DownArrow ...................... ESC-j ... Retrieve next command line.
TAB ...................................... Complete filename & cycle.
SHIFT-TAB ...................... ESC-TAB Complete filename & reverse cycle.
ctrl-L ................................... Complete filename, list all.

View File

@@ -14,15 +14,10 @@
"groupName": "ignored js dependencies",
"matchManagers": ["npm"],
"matchPackageNames": [
"@openfun/cunningham-react",
"@types/react",
"@types/react-dom",
"eslint",
"fetch-mock",
"node",
"node-fetch",
"react",
"react-dom",
"eslint",
"workbox-webpack-plugin"
]
}

1
secrets Submodule

Submodule secrets added at 38594182e8

View File

@@ -4,16 +4,12 @@ from django.contrib import admin
from django.contrib.auth import admin as auth_admin
from django.utils.translation import gettext_lazy as _
from treebeard.admin import TreeAdmin
from treebeard.forms import movenodeform_factory
from . import models
class TemplateAccessInline(admin.TabularInline):
"""Inline admin class for template accesses."""
autocomplete_fields = ["user"]
model = models.TemplateAccess
extra = 0
@@ -115,47 +111,14 @@ class TemplateAdmin(admin.ModelAdmin):
class DocumentAccessInline(admin.TabularInline):
"""Inline admin class for template accesses."""
autocomplete_fields = ["user"]
model = models.DocumentAccess
extra = 0
@admin.register(models.Document)
class DocumentAdmin(TreeAdmin):
class DocumentAdmin(admin.ModelAdmin):
"""Document admin interface declaration."""
fieldsets = (
(
None,
{
"fields": (
"id",
"title",
)
},
),
(
_("Permissions"),
{
"fields": (
"creator",
"link_reach",
"link_role",
)
},
),
(
_("Tree structure"),
{
"fields": (
"path",
"depth",
"numchild",
)
},
),
)
form = movenodeform_factory(models.Document)
inlines = (DocumentAccessInline,)
list_display = (
"id",
@@ -165,14 +128,6 @@ class DocumentAdmin(TreeAdmin):
"created_at",
"updated_at",
)
readonly_fields = (
"creator",
"depth",
"id",
"numchild",
"path",
)
search_fields = ("id", "title")
@admin.register(models.Invitation)

View File

@@ -24,7 +24,7 @@ class DocumentFilter(django_filters.FilterSet):
class Meta:
model = models.Document
fields = ["is_creator_me", "is_favorite", "title"]
fields = ["is_creator_me", "is_favorite", "link_reach", "title"]
# pylint: disable=unused-argument
def filter_is_creator_me(self, queryset, name, value):
@@ -63,4 +63,7 @@ class DocumentFilter(django_filters.FilterSet):
if not user.is_authenticated:
return queryset
return queryset.filter(is_favorite=bool(value))
if value:
return queryset.filter(favorited_by_users__user=user)
return queryset.exclude(favorited_by_users__user=user)

View File

@@ -2,15 +2,13 @@
from django.core import exceptions
from django.db.models import Q
from django.http import Http404
from rest_framework import permissions
from core.models import DocumentAccess, RoleChoices, get_trashbin_cutoff
from core.models import DocumentAccess, RoleChoices
ACTION_FOR_METHOD_TO_PERMISSION = {
"versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"},
"children": {"GET": "children_list", "POST": "children_create"},
"versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"}
}
@@ -111,26 +109,3 @@ class AccessPermission(permissions.BasePermission):
except KeyError:
pass
return abilities.get(action, False)
class DocumentAccessPermission(AccessPermission):
"""Subclass to handle soft deletion specificities."""
def has_object_permission(self, request, view, obj):
"""
Return a 404 on deleted documents
- for which the trashbin cutoff is past
- for which the current user is not owner of the document or one of its ancestors
"""
if (
deleted_at := obj.ancestors_deleted_at
) and deleted_at < get_trashbin_cutoff():
raise Http404
# Compute permission first to ensure the "user_roles" attribute is set
has_permission = super().has_object_permission(request, view, obj)
if obj.ancestors_deleted_at and not RoleChoices.OWNER in obj.user_roles:
raise Http404
return has_permission

View File

@@ -147,54 +147,34 @@ class ListDocumentSerializer(BaseResourceSerializer):
is_favorite = serializers.BooleanField(read_only=True)
nb_accesses = serializers.IntegerField(read_only=True)
user_roles = serializers.SerializerMethodField(read_only=True)
class Meta:
model = models.Document
fields = [
"id",
"abilities",
"content",
"created_at",
"creator",
"depth",
"excerpt",
"is_favorite",
"link_role",
"link_reach",
"nb_accesses",
"numchild",
"path",
"title",
"updated_at",
"user_roles",
]
read_only_fields = [
"id",
"abilities",
"created_at",
"creator",
"depth",
"excerpt",
"is_favorite",
"link_role",
"link_reach",
"nb_accesses",
"numchild",
"path",
"updated_at",
"user_roles",
]
def get_user_roles(self, document):
"""
Return roles of the logged-in user for the current document,
taking into account ancestors.
"""
request = self.context.get("request")
if request:
return document.get_roles(request.user)
return []
class DocumentSerializer(ListDocumentSerializer):
"""Serialize documents with all fields for display in detail views."""
@@ -209,32 +189,23 @@ class DocumentSerializer(ListDocumentSerializer):
"content",
"created_at",
"creator",
"depth",
"excerpt",
"is_favorite",
"link_role",
"link_reach",
"nb_accesses",
"numchild",
"path",
"title",
"updated_at",
"user_roles",
]
read_only_fields = [
"id",
"abilities",
"created_at",
"creator",
"depth",
"is_favorite",
"is_avorite",
"link_role",
"link_reach",
"nb_accesses",
"numchild",
"path",
"updated_at",
"user_roles",
]
def get_fields(self):
@@ -293,17 +264,13 @@ class ServerCreateDocumentSerializer(serializers.Serializer):
"""Create the document and associate it with the user or send an invitation."""
language = validated_data.get("language", settings.LANGUAGE_CODE)
# Get the user on its sub (unique identifier). Default on email if allowed in settings
email = validated_data["email"]
# Get the user based on the sub (unique identifier)
try:
user = models.User.objects.get_user_by_sub_or_email(
validated_data["sub"], email
)
except models.DuplicateEmailError as err:
raise serializers.ValidationError({"email": [err.message]}) from err
if user:
user = models.User.objects.get(sub=validated_data["sub"])
except (models.User.DoesNotExist, KeyError):
user = None
email = validated_data["email"]
else:
email = user.email
language = user.language or language
@@ -312,11 +279,9 @@ class ServerCreateDocumentSerializer(serializers.Serializer):
validated_data["content"]
)
except ConversionError as err:
raise serializers.ValidationError(
{"content": ["Could not convert content"]}
) from err
raise exceptions.APIException(detail="could not convert content") from err
document = models.Document.add_root(
document = models.Document.objects.create(
title=validated_data["title"],
content=document_content,
creator=user,
@@ -337,11 +302,7 @@ class ServerCreateDocumentSerializer(serializers.Serializer):
role=models.RoleChoices.OWNER,
)
self._send_email_notification(document, validated_data, email, language)
return document
def _send_email_notification(self, document, validated_data, email, language):
"""Notify the user about the newly created document."""
# Notify the user about the newly created document
subject = validated_data.get("subject") or _(
"A new document was created on your behalf!"
)
@@ -352,6 +313,8 @@ class ServerCreateDocumentSerializer(serializers.Serializer):
}
document.send_email(subject, [email], context, language)
return document
def update(self, instance, validated_data):
"""
This serializer does not support updates.
@@ -417,7 +380,6 @@ class FileUploadSerializer(serializers.Serializer):
raise serializers.ValidationError("Could not determine file extension.")
self.context["expected_extension"] = extension
self.context["content_type"] = magic_mime_type
return file
@@ -425,7 +387,6 @@ class FileUploadSerializer(serializers.Serializer):
"""Override validate to add the computed extension to validated_data."""
attrs["expected_extension"] = self.context["expected_extension"]
attrs["is_unsafe"] = self.context["is_unsafe"]
attrs["content_type"] = self.context["content_type"]
return attrs
@@ -568,37 +529,3 @@ class AITranslateSerializer(serializers.Serializer):
if len(value.strip()) == 0:
raise serializers.ValidationError("Text field cannot be empty.")
return value
class MoveDocumentSerializer(serializers.Serializer):
"""
Serializer for validating input data to move a document within the tree structure.
Fields:
- target_document_id (UUIDField): The ID of the target parent document where the
document should be moved. This field is required and must be a valid UUID.
- position (ChoiceField): Specifies the position of the document in relation to
the target parent's children.
Choices:
- "first-child": Place the document as the first child of the target parent.
- "last-child": Place the document as the last child of the target parent (default).
- "left": Place the document as the left sibling of the target parent.
- "right": Place the document as the right sibling of the target parent.
Example:
Input payload for moving a document:
{
"target_document_id": "123e4567-e89b-12d3-a456-426614174000",
"position": "first-child"
}
Notes:
- The `target_document_id` is mandatory.
- The `position` defaults to "last-child" if not provided.
"""
target_document_id = serializers.UUIDField(required=True)
position = serializers.ChoiceField(
choices=enums.MoveNodePositionChoices.choices,
default=enums.MoveNodePositionChoices.LAST_CHILD,
)

View File

@@ -11,29 +11,6 @@ import botocore
from rest_framework.throttling import BaseThrottle
def filter_root_paths(paths, skip_sorting=False):
"""
Filters root paths from a list of paths representing a tree structure.
A root path is defined as a path that is not a prefix of any other path.
Args:
paths (list of str): The list of paths.
Returns:
list of str: The filtered list of root paths.
"""
if not skip_sorting:
paths.sort()
root_paths = []
for path in paths:
# If the current path is not a prefix of the last added root path, add it
if not root_paths or not path.startswith(root_paths[-1]):
root_paths.append(path)
return root_paths
def generate_s3_authorization_headers(key):
"""
Generate authorization headers for an s3 object.

View File

@@ -8,20 +8,24 @@ from urllib.parse import urlparse
from django.conf import settings
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.contrib.postgres.search import TrigramSimilarity
from django.core.exceptions import ValidationError
from django.core.files.storage import default_storage
from django.db import models as db
from django.db import transaction
from django.db.models.expressions import RawSQL
from django.db.models.functions import Left, Length
from django.db.models import (
Count,
Exists,
OuterRef,
Q,
Subquery,
Value,
)
from django.http import Http404
import rest_framework as drf
from botocore.exceptions import ClientError
from django_filters import rest_framework as drf_filters
from rest_framework import filters, status, viewsets
from rest_framework import filters, status
from rest_framework import response as drf_response
from rest_framework.permissions import AllowAny
@@ -48,7 +52,7 @@ COLLABORATION_WS_URL_PATTERN = re.compile(rf"(?:^|&)room=(?P<pk>{UUID_REGEX})(?:
# pylint: disable=too-many-ancestors
class NestedGenericViewSet(viewsets.GenericViewSet):
class NestedGenericViewSet(drf.viewsets.GenericViewSet):
"""
A generic Viewset aims to be used in a nested route context.
e.g: `/api/v1.0/resource_1/<resource_1_pk>/resource_2/<resource_2_pk>/`
@@ -108,35 +112,28 @@ class SerializerPerActionMixin:
This mixin is useful to avoid to define a serializer class for each action in the
`get_serializer_class` method.
Example:
```
class MyViewSet(SerializerPerActionMixin, viewsets.GenericViewSet):
serializer_class = MySerializer
list_serializer_class = MyListSerializer
retrieve_serializer_class = MyRetrieveSerializer
```
"""
serializer_classes: dict[str, type] = {}
default_serializer_class: type = None
def get_serializer_class(self):
"""
Return the serializer class to use depending on the action.
"""
if serializer_class := getattr(self, f"{self.action}_serializer_class", None):
return serializer_class
return super().get_serializer_class()
return self.serializer_classes.get(self.action, self.default_serializer_class)
class Pagination(drf.pagination.PageNumberPagination):
"""Pagination to display no more than 100 objects per page sorted by creation date."""
ordering = "-created_on"
max_page_size = 200
max_page_size = 100
page_size_query_param = "page_size"
class UserViewSet(
drf.mixins.UpdateModelMixin, viewsets.GenericViewSet, drf.mixins.ListModelMixin
drf.mixins.UpdateModelMixin, drf.viewsets.GenericViewSet, drf.mixins.ListModelMixin
):
"""User ViewSet"""
@@ -153,35 +150,29 @@ class UserViewSet(
"""
queryset = self.queryset
if self.action != "list":
return 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)
# 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", ""):
# For performance reasons we filter first by similarity, which relies on an index,
# then only calculate precise similarity scores for sorting purposes
queryset = queryset.filter(email__trigram_word_similar=query)
if not (query := self.request.GET.get("q", "")):
return queryset
# For emails, match emails by Levenstein distance to prevent typing errors
if "@" in query:
return (
queryset.annotate(
distance=RawSQL("levenshtein(email::text, %s::text)", (query,))
queryset = queryset.annotate(
similarity=TrigramSimilarity("email", query)
)
.filter(distance__lte=3)
.order_by("distance", "email")
)
# When the query only is on the name part, we should try to make many proposals
# But when the query looks like an email we should only propose serious matches
threshold = 0.6 if "@" in query else 0.1
# Use trigram similarity for non-email-like queries
# For performance reasons we filter first by similarity, which relies on an
# index, then only calculate precise similarity scores for sorting purposes
return (
queryset.filter(email__trigram_word_similar=query)
.annotate(similarity=TrigramSimilarity("email", query))
.filter(similarity__gt=0.2)
.order_by("-similarity", "email")
)
queryset = queryset.filter(similarity__gt=threshold).order_by(
"-similarity", "email"
)
return queryset
@drf.decorators.action(
detail=False,
@@ -310,241 +301,100 @@ class DocumentMetadata(drf.metadata.SimpleMetadata):
return simple_metadata
# pylint: disable=too-many-public-methods
class DocumentViewSet(
SerializerPerActionMixin,
drf.mixins.CreateModelMixin,
drf.mixins.DestroyModelMixin,
drf.mixins.ListModelMixin,
drf.mixins.UpdateModelMixin,
viewsets.GenericViewSet,
drf.viewsets.GenericViewSet,
):
"""
DocumentViewSet API.
Document ViewSet for managing documents.
This view set provides CRUD operations and additional actions for managing documents.
Supports filtering, ordering, and annotations for enhanced querying capabilities.
Provides endpoints for creating, updating, and deleting documents,
along with filtering options.
### API Endpoints:
1. **List**: Retrieve a paginated list of documents.
Example: GET /documents/?page=2
2. **Retrieve**: Get a specific document by its ID.
Example: GET /documents/{id}/
3. **Create**: Create a new document.
Example: POST /documents/
4. **Update**: Update a document by its ID.
Example: PUT /documents/{id}/
5. **Delete**: Soft delete a document by its ID.
Example: DELETE /documents/{id}/
### Additional Actions:
1. **Trashbin**: List soft deleted documents for a document owner
Example: GET /documents/{id}/trashbin/
2. **Children**: List or create child documents.
Example: GET, POST /documents/{id}/children/
3. **Versions List**: Retrieve version history of a document.
Example: GET /documents/{id}/versions/
4. **Version Detail**: Get or delete a specific document version.
Example: GET, DELETE /documents/{id}/versions/{version_id}/
5. **Favorite**: Get list of favorite documents for a user. Mark or unmark
a document as favorite.
Examples:
- GET /documents/favorite/
- POST, DELETE /documents/{id}/favorite/
6. **Create for Owner**: Create a document via server-to-server on behalf of a user.
Example: POST /documents/create-for-owner/
7. **Link Configuration**: Update document link configuration.
Example: PUT /documents/{id}/link-configuration/
8. **Attachment Upload**: Upload a file attachment for the document.
Example: POST /documents/{id}/attachment-upload/
9. **Media Auth**: Authorize access to document media.
Example: GET /documents/media-auth/
10. **Collaboration Auth**: Authorize access to the collaboration server for a document.
Example: GET /documents/collaboration-auth/
11. **AI Transform**: Apply a transformation action on a piece of text with AI.
Example: POST /documents/{id}/ai-transform/
Expected data:
- text (str): The input text.
- action (str): The transformation type, one of [prompt, correct, rephrase, summarize].
Returns: JSON response with the processed text.
Throttled by: AIDocumentRateThrottle, AIUserRateThrottle.
12. **AI Translate**: Translate a piece of text with AI.
Example: POST /documents/{id}/ai-translate/
Expected data:
- text (str): The input text.
- language (str): The target language, chosen from settings.LANGUAGES.
Returns: JSON response with the translated text.
Throttled by: AIDocumentRateThrottle, AIUserRateThrottle.
### Ordering: created_at, updated_at, is_favorite, title
Example:
- Ascending: GET /api/v1.0/documents/?ordering=created_at
- Desceding: GET /api/v1.0/documents/?ordering=-title
### Filtering:
Filtering:
- `is_creator_me=true`: Returns documents created by the current user.
- `is_creator_me=false`: Returns documents created by other users.
- `is_favorite=true`: Returns documents marked as favorite by the current user
- `is_favorite=false`: Returns documents not marked as favorite by the current user
- `title=hello`: Returns documents which title contains the "hello" string
Example:
Example Usage:
- GET /api/v1.0/documents/?is_creator_me=true&is_favorite=true
- GET /api/v1.0/documents/?is_creator_me=false&title=hello
### Annotations:
1. **is_favorite**: Indicates whether the document is marked as favorite by the current user.
2. **user_roles**: Roles the current user has on the document or its ancestors.
### Notes:
- Only the highest ancestor in a document hierarchy is shown in list views.
- Implements soft delete logic to retain document tree structures.
"""
filter_backends = [drf_filters.DjangoFilterBackend]
filter_backends = [drf_filters.DjangoFilterBackend, filters.OrderingFilter]
filterset_class = DocumentFilter
metadata_class = DocumentMetadata
ordering = ["-updated_at"]
ordering_fields = ["created_at", "updated_at", "title"]
ordering_fields = ["created_at", "is_favorite", "updated_at", "title"]
permission_classes = [
permissions.DocumentAccessPermission,
permissions.AccessPermission,
]
queryset = models.Document.objects.all()
serializer_class = serializers.DocumentSerializer
list_serializer_class = serializers.ListDocumentSerializer
trashbin_serializer_class = serializers.ListDocumentSerializer
children_serializer_class = serializers.ListDocumentSerializer
ai_translate_serializer_class = serializers.AITranslateSerializer
def annotate_is_favorite(self, queryset):
def get_serializer_class(self):
"""
Annotate document queryset with the favorite status for the current user.
Use ListDocumentSerializer for list actions, otherwise use DocumentSerializer.
"""
user = self.request.user
if user.is_authenticated:
favorite_exists_subquery = models.DocumentFavorite.objects.filter(
document_id=db.OuterRef("pk"), user=user
)
return queryset.annotate(is_favorite=db.Exists(favorite_exists_subquery))
return queryset.annotate(is_favorite=db.Value(False))
def annotate_user_roles(self, queryset):
"""
Annotate document queryset with the roles of the current user
on the document or its ancestors.
"""
user = self.request.user
output_field = ArrayField(base_field=db.CharField())
if user.is_authenticated:
user_roles_subquery = models.DocumentAccess.objects.filter(
db.Q(user=user) | db.Q(team__in=user.teams),
document__path=Left(db.OuterRef("path"), Length("document__path")),
).values_list("role", flat=True)
return queryset.annotate(
user_roles=db.Func(
user_roles_subquery, function="ARRAY", output_field=output_field
)
)
return queryset.annotate(
user_roles=db.Value([], output_field=output_field),
)
if self.action == "list":
return serializers.ListDocumentSerializer
return self.serializer_class
def get_queryset(self):
"""Get queryset performing all annotation and filtering on the document tree structure."""
user = self.request.user
"""Optimize queryset to include favorite status for the current user."""
queryset = super().get_queryset()
user = self.request.user
# Only list views need filtering and annotation
if self.detail:
return queryset
# Annotate the number of accesses associated with each document
queryset = queryset.annotate(nb_accesses=Count("accesses", distinct=True))
if not user.is_authenticated:
return queryset.none()
# If the user is not authenticated, annotate `is_favorite` as False
return queryset.annotate(is_favorite=Value(False))
queryset = queryset.filter(ancestors_deleted_at__isnull=True)
# Filter documents to which the current user has access...
access_documents_ids = models.DocumentAccess.objects.filter(
db.Q(user=user) | db.Q(team__in=user.teams)
).values_list("document_id", flat=True)
# ...or that were previously accessed and are not restricted
traced_documents_ids = models.LinkTrace.objects.filter(user=user).values_list(
"document_id", flat=True
# Annotate the queryset to indicate if the document is favorited by the current user
favorite_exists = models.DocumentFavorite.objects.filter(
document_id=OuterRef("pk"), user=user
)
queryset = queryset.annotate(is_favorite=Exists(favorite_exists))
return queryset.filter(
db.Q(id__in=access_documents_ids)
| (
db.Q(id__in=traced_documents_ids)
& ~db.Q(link_reach=models.LinkReachChoices.RESTRICTED)
# Annotate the queryset with the logged-in user roles
user_roles_query = (
models.DocumentAccess.objects.filter(
Q(user=user) | Q(team__in=user.teams),
document_id=OuterRef("pk"),
)
.values("document")
.annotate(roles_array=ArrayAgg("role"))
.values("roles_array")
)
return queryset.annotate(user_roles=Subquery(user_roles_query)).distinct()
def filter_queryset(self, queryset):
"""Apply annotations and filters sequentially."""
filterset = DocumentFilter(
self.request.GET, queryset=queryset, request=self.request
)
filterset.is_valid()
filter_data = filterset.form.cleaned_data
def list(self, request, *args, **kwargs):
"""Restrict resources returned by the list endpoint"""
queryset = self.filter_queryset(self.get_queryset())
user = self.request.user
# Filter as early as possible on fields that are available on the model
for field in ["is_creator_me", "title"]:
queryset = filterset.filters[field].filter(queryset, filter_data[field])
queryset = self.annotate_user_roles(queryset)
if self.action == "list":
# Among the results, we may have documents that are ancestors/descendants
# of each other. In this case we want to keep only the highest ancestors.
root_paths = utils.filter_root_paths(
queryset.order_by("path").values_list("path", flat=True),
skip_sorting=True,
)
queryset = queryset.filter(path__in=root_paths)
# Annotate the queryset with an attribute marking instances as highest ancestor
# in order to save some time while computing abilities in the instance
queryset = queryset.annotate(
is_highest_ancestor_for_user=db.Value(
True, output_field=db.BooleanField()
if user.is_authenticated:
queryset = queryset.filter(
db.Q(accesses__user=user)
| db.Q(accesses__team__in=user.teams)
| (
db.Q(link_traces__user=user)
& ~db.Q(link_reach=models.LinkReachChoices.RESTRICTED)
)
)
else:
queryset = queryset.none()
# Annotate favorite status and filter if applicable as late as possible
queryset = self.annotate_is_favorite(queryset)
queryset = filterset.filters["is_favorite"].filter(
queryset, filter_data["is_favorite"]
)
# Apply ordering only now that everyting is filtered and annotated
return filters.OrderingFilter().filter_queryset(self.request, queryset, self)
def get_response_for_queryset(self, queryset):
"""Return paginated response for the queryset if requested."""
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
result = self.get_paginated_response(serializer.data)
return result
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True)
return drf.response.Response(serializer.data)
@@ -555,75 +405,32 @@ class DocumentViewSet(
on a user's list view even though the user has no specific role in the document (link
access when the link reach configuration of the document allows it).
"""
user = self.request.user
instance = self.get_object()
serializer = self.get_serializer(instance)
# The `create` query generates 5 db queries which are much less efficient than an
# `exists` query. The user will visit the document many times after the first visit
# so that's what we should optimize for.
if (
user.is_authenticated
and not instance.link_traces.filter(user=user).exists()
):
models.LinkTrace.objects.create(document=instance, user=request.user)
if self.request.user.is_authenticated:
try:
# Add a trace that the user visited the document (this is needed to include
# the document in the user's list view)
models.LinkTrace.objects.create(
document=instance,
user=self.request.user,
)
except ValidationError:
# The trace already exists, so we just pass without doing anything
pass
return drf.response.Response(serializer.data)
@transaction.atomic
def perform_create(self, serializer):
"""Set the current user as creator and owner of the newly created object."""
obj = models.Document.add_root(
creator=self.request.user,
**serializer.validated_data,
)
serializer.instance = obj
obj = serializer.save(creator=self.request.user)
models.DocumentAccess.objects.create(
document=obj,
user=self.request.user,
role=models.RoleChoices.OWNER,
)
def perform_destroy(self, instance):
"""Override to implement a soft delete instead of dumping the record in database."""
instance.soft_delete()
@drf.decorators.action(
detail=False,
methods=["get"],
)
def favorite_list(self, request, *args, **kwargs):
"""Get list of favorite documents for the current user."""
user = request.user
favorite_documents_ids = models.DocumentFavorite.objects.filter(
user=user
).values_list("document_id", flat=True)
queryset = self.get_queryset()
queryset = queryset.filter(id__in=favorite_documents_ids)
return self.get_response_for_queryset(queryset)
@drf.decorators.action(
detail=False,
methods=["get"],
)
def trashbin(self, request, *args, **kwargs):
"""
Retrieve soft-deleted documents for which the current user has the owner role.
The selected documents are those deleted within the cutoff period defined in the
settings (see TRASHBIN_CUTOFF_DAYS), before they are considered permanently deleted.
"""
queryset = self.queryset.filter(
deleted_at__isnull=False,
deleted_at__gte=models.get_trashbin_cutoff(),
)
queryset = self.annotate_user_roles(queryset)
queryset = queryset.filter(user_roles__contains=[models.RoleChoices.OWNER])
return self.get_response_for_queryset(queryset)
@drf.decorators.action(
authentication_classes=[authentication.ServerToServerAuthentication],
detail=False,
@@ -648,123 +455,6 @@ class DocumentViewSet(
{"id": str(document.id)}, status=status.HTTP_201_CREATED
)
@drf.decorators.action(detail=True, methods=["post"])
@transaction.atomic
def move(self, request, *args, **kwargs):
"""
Move a document to another location within the document tree.
The user must be an administrator or owner of both the document being moved
and the target parent document.
"""
user = request.user
document = self.get_object() # including permission checks
# Validate the input payload
serializer = serializers.MoveDocumentSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
validated_data = serializer.validated_data
target_document_id = validated_data["target_document_id"]
try:
target_document = models.Document.objects.get(
id=target_document_id, ancestors_deleted_at__isnull=True
)
except models.Document.DoesNotExist:
return drf.response.Response(
{"target_document_id": "Target parent document does not exist."},
status=status.HTTP_400_BAD_REQUEST,
)
position = validated_data["position"]
message = None
if position in [
enums.MoveNodePositionChoices.FIRST_CHILD,
enums.MoveNodePositionChoices.LAST_CHILD,
]:
if not target_document.get_abilities(user).get("move"):
message = (
"You do not have permission to move documents "
"as a child to this target document."
)
elif not target_document.is_root():
if not target_document.get_parent().get_abilities(user).get("move"):
message = (
"You do not have permission to move documents "
"as a sibling of this target document."
)
if message:
return drf.response.Response(
{"target_document_id": message},
status=status.HTTP_400_BAD_REQUEST,
)
document.move(target_document, pos=position)
return drf.response.Response(
{"message": "Document moved successfully."}, status=status.HTTP_200_OK
)
@drf.decorators.action(
detail=True,
methods=["post"],
)
def restore(self, request, *args, **kwargs):
"""
Restore a soft-deleted document if it was deleted less than x days ago.
"""
document = self.get_object()
document.restore()
return drf_response.Response(
{"detail": "Document has been successfully restored."},
status=status.HTTP_200_OK,
)
@drf.decorators.action(
detail=True,
methods=["get", "post"],
ordering=["path"],
url_path="children",
)
def children(self, request, *args, **kwargs):
"""Handle listing and creating children of a document"""
document = self.get_object()
if request.method == "POST":
# Create a child document
serializer = serializers.DocumentSerializer(
data=request.data, context=self.get_serializer_context()
)
serializer.is_valid(raise_exception=True)
with transaction.atomic():
child_document = document.add_child(
creator=request.user,
**serializer.validated_data,
)
models.DocumentAccess.objects.create(
document=child_document,
user=request.user,
role=models.RoleChoices.OWNER,
)
# Set the created instance to the serializer
serializer.instance = child_document
headers = self.get_success_headers(serializer.data)
return drf.response.Response(
serializer.data, status=status.HTTP_201_CREATED, headers=headers
)
# GET: List children
queryset = document.get_children().filter(deleted_at__isnull=True)
queryset = self.filter_queryset(queryset)
queryset = self.annotate_is_favorite(queryset)
queryset = self.annotate_user_roles(queryset)
return self.get_response_for_queryset(queryset)
@drf.decorators.action(detail=True, methods=["get"], url_path="versions")
def versions_list(self, request, *args, **kwargs):
"""
@@ -783,9 +473,8 @@ class DocumentViewSet(
# Users should not see version history dating from before they gained access to the
# document. Filter to get the minimum access date for the logged-in user
access_queryset = models.DocumentAccess.objects.filter(
db.Q(user=user) | db.Q(team__in=user.teams),
document__path=Left(db.Value(document.path), Length("document__path")),
access_queryset = document.accesses.filter(
db.Q(user=user) | db.Q(team__in=user.teams)
).aggregate(min_date=db.Min("created_at"))
# Handle the case where the user has no accesses
@@ -823,12 +512,10 @@ class DocumentViewSet(
user = request.user
min_datetime = min(
access.created_at
for access in models.DocumentAccess.objects.filter(
for access in document.accesses.filter(
db.Q(user=user) | db.Q(team__in=user.teams),
document__path=Left(db.Value(document.path), Length("document__path")),
)
)
if response["LastModified"] < min_datetime:
raise Http404
@@ -918,10 +605,7 @@ class DocumentViewSet(
key = f"{document.key_base}/{ATTACHMENTS_FOLDER:s}/{file_id!s}.{extension:s}"
# Prepare metadata for storage
extra_args = {
"Metadata": {"owner": str(request.user.id)},
"ContentType": serializer.validated_data["content_type"],
}
extra_args = {"Metadata": {"owner": str(request.user.id)}}
if serializer.validated_data["is_unsafe"]:
extra_args["Metadata"]["is_unsafe"] = "true"
@@ -992,7 +676,7 @@ class DocumentViewSet(
# Fetch the document and check if the user has access
try:
document = models.Document.objects.get(pk=pk)
document, _created = models.Document.objects.get_or_create(pk=pk)
except models.Document.DoesNotExist as exc:
logger.debug("Document with ID '%s' does not exist", pk)
raise drf.exceptions.PermissionDenied() from exc
@@ -1083,6 +767,7 @@ class DocumentViewSet(
detail=True,
methods=["post"],
name="Translate a piece of text with AI",
serializer_class=serializers.AITranslateSerializer,
url_path="ai-translate",
throttle_classes=[utils.AIDocumentRateThrottle, utils.AIUserRateThrottle],
)
@@ -1115,7 +800,7 @@ class DocumentAccessViewSet(
drf.mixins.ListModelMixin,
drf.mixins.RetrieveModelMixin,
drf.mixins.UpdateModelMixin,
viewsets.GenericViewSet,
drf.viewsets.GenericViewSet,
):
"""
API ViewSet for all interactions with document accesses.
@@ -1188,7 +873,7 @@ class TemplateViewSet(
drf.mixins.DestroyModelMixin,
drf.mixins.RetrieveModelMixin,
drf.mixins.UpdateModelMixin,
viewsets.GenericViewSet,
drf.viewsets.GenericViewSet,
):
"""Template ViewSet"""
@@ -1212,14 +897,14 @@ class TemplateViewSet(
user_roles_query = (
models.TemplateAccess.objects.filter(
db.Q(user=user) | db.Q(team__in=user.teams),
template_id=db.OuterRef("pk"),
Q(user=user) | Q(team__in=user.teams),
template_id=OuterRef("pk"),
)
.values("template")
.annotate(roles_array=ArrayAgg("role"))
.values("roles_array")
)
return queryset.annotate(user_roles=db.Subquery(user_roles_query)).distinct()
return queryset.annotate(user_roles=Subquery(user_roles_query)).distinct()
def list(self, request, *args, **kwargs):
"""Restrict templates returned by the list endpoint"""
@@ -1242,7 +927,6 @@ class TemplateViewSet(
serializer = self.get_serializer(queryset, many=True)
return drf.response.Response(serializer.data)
@transaction.atomic
def perform_create(self, serializer):
"""Set the current user as owner of the newly created object."""
obj = serializer.save()
@@ -1252,6 +936,40 @@ class TemplateViewSet(
role=models.RoleChoices.OWNER,
)
@drf.decorators.action(
detail=True,
methods=["post"],
url_path="generate-document",
permission_classes=[permissions.AccessPermission],
)
# 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"
"""
serializer = serializers.DocumentGenerationSerializer(data=request.data)
if not serializer.is_valid():
return drf.response.Response(
serializer.errors, status=drf.status.HTTP_400_BAD_REQUEST
)
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)
class TemplateAccessViewSet(
ResourceAccessViewsetMixin,
@@ -1260,7 +978,7 @@ class TemplateAccessViewSet(
drf.mixins.ListModelMixin,
drf.mixins.RetrieveModelMixin,
drf.mixins.UpdateModelMixin,
viewsets.GenericViewSet,
drf.viewsets.GenericViewSet,
):
"""
API ViewSet for all interactions with template accesses.
@@ -1300,7 +1018,7 @@ class InvitationViewset(
drf.mixins.RetrieveModelMixin,
drf.mixins.DestroyModelMixin,
drf.mixins.UpdateModelMixin,
viewsets.GenericViewSet,
drf.viewsets.GenericViewSet,
):
"""API ViewSet for user invitations to document.
@@ -1406,7 +1124,6 @@ class ConfigView(drf.views.APIView):
"ENVIRONMENT",
"FRONTEND_THEME",
"MEDIA_BASE_URL",
"POSTHOG_KEY",
"LANGUAGES",
"LANGUAGE_CODE",
"SENTRY_DSN",

View File

@@ -1,7 +1,5 @@
"""Authentication Backends for the Impress core app."""
import logging
from django.conf import settings
from django.core.exceptions import SuspiciousOperation
from django.utils.translation import gettext_lazy as _
@@ -11,9 +9,7 @@ from mozilla_django_oidc.auth import (
OIDCAuthenticationBackend as MozillaOIDCAuthenticationBackend,
)
from core.models import DuplicateEmailError, User
logger = logging.getLogger(__name__)
from core.models import User
class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
@@ -63,29 +59,10 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
return userinfo
def verify_claims(self, claims):
"""
Verify the presence of essential claims and the "sub" (which is mandatory as defined
by the OIDC specification) to decide if authentication should be allowed.
"""
essential_claims = settings.USER_OIDC_ESSENTIAL_CLAIMS
missing_claims = [claim for claim in essential_claims if claim not in claims]
if missing_claims:
logger.error("Missing essential claims: %s", missing_claims)
return False
return True
def get_or_create_user(self, access_token, id_token, payload):
"""Return a User based on userinfo. Create a new user if no match is found."""
user_info = self.get_userinfo(access_token, id_token, payload)
if not self.verify_claims(user_info):
raise SuspiciousOperation("Claims verification failed.")
sub = user_info["sub"]
email = user_info.get("email")
# Get user's full name from OIDC fields defined in settings
@@ -98,10 +75,13 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
"short_name": short_name,
}
try:
user = User.objects.get_user_by_sub_or_email(sub, email)
except DuplicateEmailError as err:
raise SuspiciousOperation(err.message) from err
sub = user_info.get("sub")
if not sub:
raise SuspiciousOperation(
_("User info contained no recognizable user identification")
)
user = self.get_existing_user(sub, email)
if user:
if not user.is_active:
@@ -120,6 +100,18 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
)
return full_name or None
def get_existing_user(self, sub, email):
"""Fetch existing user by sub or email."""
try:
return User.objects.get(sub=sub)
except User.DoesNotExist:
if email and settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION:
try:
return User.objects.get(email=email)
except User.DoesNotExist:
pass
return None
def update_user_if_needed(self, user, claims):
"""Update user claims if they have changed."""
has_changed = any(
@@ -127,4 +119,4 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
)
if has_changed:
updated_claims = {key: value for key, value in claims.items() if value}
self.UserModel.objects.filter(id=user.id).update(**updated_claims)
self.UserModel.objects.filter(sub=user.sub).update(**updated_claims)

View File

@@ -3,7 +3,6 @@ Core application enums declaration
"""
from django.conf import global_settings
from django.db import models
from django.utils.translation import gettext_lazy as _
# In Django's code base, `LANGUAGES` is set by default with all supported languages.
@@ -11,14 +10,3 @@ from django.utils.translation import gettext_lazy as _
# active in the app.
# pylint: disable=no-member
ALL_LANGUAGES = {language: _(name) for language, name in global_settings.LANGUAGES}
class MoveNodePositionChoices(models.TextChoices):
"""Defines the possible positions when moving a django-treebeard node."""
FIRST_CHILD = "first-child", _("First child")
LAST_CHILD = "last-child", _("Last child")
FIRST_SIBLING = "first-sibling", _("First sibling")
LAST_SIBLING = "last-sibling", _("Last sibling")
LEFT = "left", _("Left")
RIGHT = "right", _("Right")

View File

@@ -46,23 +46,6 @@ class UserFactory(factory.django.DjangoModelFactory):
UserTemplateAccessFactory(user=self, role="owner")
class ParentNodeFactory(factory.declarations.ParameteredAttribute):
"""Custom factory attribute for setting the parent node."""
def generate(self, step, params):
"""
Generate a parent node for the factory.
This method is invoked during the factory's build process to determine the parent
node of the current object being created. If `params` is provided, it uses the factory's
metadata to recursively create or fetch the parent node. Otherwise, it returns `None`.
"""
if not params:
return None
subfactory = step.builder.factory_meta.factory
return step.recurse(subfactory, params)
class DocumentFactory(factory.django.DjangoModelFactory):
"""A factory to create documents"""
@@ -71,13 +54,9 @@ class DocumentFactory(factory.django.DjangoModelFactory):
django_get_or_create = ("title",)
skip_postgeneration_save = True
parent = ParentNodeFactory()
title = factory.Sequence(lambda n: f"document{n}")
excerpt = factory.Sequence(lambda n: f"excerpt{n}")
content = factory.Sequence(lambda n: f"content{n}")
creator = factory.SubFactory(UserFactory)
deleted_at = None
link_reach = factory.fuzzy.FuzzyChoice(
[a[0] for a in models.LinkReachChoices.choices]
)
@@ -85,29 +64,6 @@ class DocumentFactory(factory.django.DjangoModelFactory):
[r[0] for r in models.LinkRoleChoices.choices]
)
@classmethod
def _create(cls, model_class, *args, **kwargs):
"""
Custom creation logic for the factory: creates a document as a child node if
a parent is provided; otherwise, creates it as a root node.
"""
parent = kwargs.pop("parent", None)
if parent:
# Add as a child node
kwargs["ancestors_deleted_at"] = (
kwargs.get("ancestors_deleted_at") or parent.ancestors_deleted_at
)
return parent.add_child(instance=model_class(**kwargs))
# Add as a root node
return model_class.add_root(instance=model_class(**kwargs))
@factory.lazy_attribute
def ancestors_deleted_at(self):
"""Should always be set when "deleted_at" is set."""
return self.deleted_at
@factory.post_generation
def users(self, create, extracted, **kwargs):
"""Add users to document from a given list of users with or without roles."""
@@ -118,16 +74,6 @@ class DocumentFactory(factory.django.DjangoModelFactory):
else:
UserDocumentAccessFactory(document=self, user=item[0], role=item[1])
@factory.post_generation
def teams(self, create, extracted, **kwargs):
"""Add teams to document from a given list of teams with or without roles."""
if create and extracted:
for item in extracted:
if isinstance(item, str):
TeamDocumentAccessFactory(document=self, team=item)
else:
TeamDocumentAccessFactory(document=self, team=item[0], role=item[1])
@factory.post_generation
def link_traces(self, create, extracted, **kwargs):
"""Add link traces to document from a given list of users."""

View File

@@ -1,95 +0,0 @@
"""Management command updating the metadata for all the files in the MinIO bucket."""
from django.core.files.storage import default_storage
from django.core.management.base import BaseCommand
import magic
from core.models import Document
# pylint: disable=too-many-locals, broad-exception-caught
class Command(BaseCommand):
"""Update the metadata for all the files in the MinIO bucket."""
help = __doc__
def handle(self, *args, **options):
"""Execute management command."""
s3_client = default_storage.connection.meta.client
bucket_name = default_storage.bucket_name
mime_detector = magic.Magic(mime=True)
documents = Document.objects.all()
self.stdout.write(
f"[INFO] Found {documents.count()} documents. Starting ContentType fix..."
)
for doc in documents:
doc_id_str = str(doc.id)
prefix = f"{doc_id_str}/attachments/"
self.stdout.write(
f"[INFO] Processing attachments under prefix '{prefix}' ..."
)
continuation_token = None
total_updated = 0
while True:
list_kwargs = {"Bucket": bucket_name, "Prefix": prefix}
if continuation_token:
list_kwargs["ContinuationToken"] = continuation_token
response = s3_client.list_objects_v2(**list_kwargs)
# If no objects found under this prefix, break out of the loop
if "Contents" not in response:
break
for obj in response["Contents"]:
key = obj["Key"]
# Skip if it's a folder
if key.endswith("/"):
continue
try:
# Get existing metadata
head_resp = s3_client.head_object(Bucket=bucket_name, Key=key)
# Read first ~1KB for MIME detection
partial_obj = s3_client.get_object(
Bucket=bucket_name, Key=key, Range="bytes=0-1023"
)
partial_data = partial_obj["Body"].read()
# Detect MIME type
magic_mime_type = mime_detector.from_buffer(partial_data)
# Update ContentType
s3_client.copy_object(
Bucket=bucket_name,
CopySource={"Bucket": bucket_name, "Key": key},
Key=key,
ContentType=magic_mime_type,
Metadata=head_resp.get("Metadata", {}),
MetadataDirective="REPLACE",
)
total_updated += 1
except Exception as exc: # noqa
self.stderr.write(
f"[ERROR] Could not update ContentType for {key}: {exc}"
)
if response.get("IsTruncated"):
continuation_token = response.get("NextContinuationToken")
else:
break
if total_updated > 0:
self.stdout.write(
f"[INFO] -> Updated {total_updated} objects for Document {doc_id_str}."
)

View File

@@ -1,552 +1,166 @@
# Generated by Django 5.0.3 on 2024-05-28 20:29
import uuid
import django.contrib.auth.models
import django.core.validators
import django.db.models.deletion
import timezone_field.fields
import uuid
from django.conf import settings
from django.db import migrations, models
import timezone_field.fields
class Migration(migrations.Migration):
initial = True
dependencies = [
("auth", "0012_alter_user_first_name_max_length"),
('auth', '0012_alter_user_first_name_max_length'),
]
operations = [
migrations.CreateModel(
name="Document",
name='Document',
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",
),
),
("title", models.CharField(max_length=255, verbose_name="title")),
(
"is_public",
models.BooleanField(
default=False,
help_text="Whether this document is public for anyone to use.",
verbose_name="public",
),
),
('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')),
('title', models.CharField(max_length=255, verbose_name='title')),
('is_public', models.BooleanField(default=False, help_text='Whether this document is public for anyone to use.', verbose_name='public')),
],
options={
"verbose_name": "Document",
"verbose_name_plural": "Documents",
"db_table": "impress_document",
"ordering": ("title",),
'verbose_name': 'Document',
'verbose_name_plural': 'Documents',
'db_table': 'impress_document',
'ordering': ('title',),
},
),
migrations.CreateModel(
name="Template",
name='Template',
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",
),
),
("title", models.CharField(max_length=255, verbose_name="title")),
(
"description",
models.TextField(blank=True, verbose_name="description"),
),
("code", models.TextField(blank=True, verbose_name="code")),
("css", models.TextField(blank=True, verbose_name="css")),
(
"is_public",
models.BooleanField(
default=False,
help_text="Whether this template is public for anyone to use.",
verbose_name="public",
),
),
('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')),
('title', models.CharField(max_length=255, verbose_name='title')),
('description', models.TextField(blank=True, verbose_name='description')),
('code', models.TextField(blank=True, verbose_name='code')),
('css', models.TextField(blank=True, verbose_name='css')),
('is_public', models.BooleanField(default=False, help_text='Whether this template is public for anyone to use.', verbose_name='public')),
],
options={
"verbose_name": "Template",
"verbose_name_plural": "Templates",
"db_table": "impress_template",
"ordering": ("title",),
'verbose_name': 'Template',
'verbose_name_plural': 'Templates',
'db_table': 'impress_template',
'ordering': ('title',),
},
),
migrations.CreateModel(
name="User",
name='User',
fields=[
("password", models.CharField(max_length=128, verbose_name="password")),
(
"last_login",
models.DateTimeField(
blank=True, null=True, verbose_name="last login"
),
),
(
"is_superuser",
models.BooleanField(
default=False,
help_text="Designates that this user has all permissions without explicitly assigning them.",
verbose_name="superuser status",
),
),
(
"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",
),
),
(
"sub",
models.CharField(
blank=True,
help_text="Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only.",
max_length=255,
null=True,
unique=True,
validators=[
django.core.validators.RegexValidator(
message="Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters.",
regex="^[\\w.@+-]+\\Z",
)
],
verbose_name="sub",
),
),
(
"email",
models.EmailField(
blank=True,
max_length=254,
null=True,
verbose_name="identity email address",
),
),
(
"admin_email",
models.EmailField(
blank=True,
max_length=254,
null=True,
unique=True,
verbose_name="admin email address",
),
),
(
"language",
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",
),
),
(
"timezone",
timezone_field.fields.TimeZoneField(
choices_display="WITH_GMT_OFFSET",
default="UTC",
help_text="The timezone in which the user wants to see times.",
use_pytz=False,
),
),
(
"is_device",
models.BooleanField(
default=False,
help_text="Whether the user is a device or a real user.",
verbose_name="device",
),
),
(
"is_staff",
models.BooleanField(
default=False,
help_text="Whether the user can log into this admin site.",
verbose_name="staff status",
),
),
(
"is_active",
models.BooleanField(
default=True,
help_text="Whether this user should be treated as active. Unselect this instead of deleting accounts.",
verbose_name="active",
),
),
(
"groups",
models.ManyToManyField(
blank=True,
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
related_name="user_set",
related_query_name="user",
to="auth.group",
verbose_name="groups",
),
),
(
"user_permissions",
models.ManyToManyField(
blank=True,
help_text="Specific permissions for this user.",
related_name="user_set",
related_query_name="user",
to="auth.permission",
verbose_name="user permissions",
),
),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('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')),
('sub', models.CharField(blank=True, help_text='Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only.', max_length=255, null=True, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters.', regex='^[\\w.@+-]+\\Z')], verbose_name='sub')),
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='identity email address')),
('admin_email', models.EmailField(blank=True, max_length=254, null=True, unique=True, verbose_name='admin email address')),
('language', 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')),
('timezone', timezone_field.fields.TimeZoneField(choices_display='WITH_GMT_OFFSET', default='UTC', help_text='The timezone in which the user wants to see times.', use_pytz=False)),
('is_device', models.BooleanField(default=False, help_text='Whether the user is a device or a real user.', verbose_name='device')),
('is_staff', models.BooleanField(default=False, help_text='Whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
],
options={
"verbose_name": "user",
"verbose_name_plural": "users",
"db_table": "impress_user",
'verbose_name': 'user',
'verbose_name_plural': 'users',
'db_table': 'impress_user',
},
managers=[
("objects", django.contrib.auth.models.UserManager()),
('objects', django.contrib.auth.models.UserManager()),
],
),
migrations.CreateModel(
name="DocumentAccess",
name='DocumentAccess',
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",
),
),
("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,
),
),
(
"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,
),
),
('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')),
('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)),
('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)),
],
options={
"verbose_name": "Document/user relation",
"verbose_name_plural": "Document/user relations",
"db_table": "impress_document_access",
"ordering": ("-created_at",),
'verbose_name': 'Document/user relation',
'verbose_name_plural': 'Document/user relations',
'db_table': 'impress_document_access',
'ordering': ('-created_at',),
},
),
migrations.CreateModel(
name="Invitation",
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,
),
),
('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",
'verbose_name': 'Document invitation',
'verbose_name_plural': 'Document invitations',
'db_table': 'impress_invitation',
},
),
migrations.CreateModel(
name="TemplateAccess",
name='TemplateAccess',
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",
),
),
("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,
),
),
(
"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,
),
),
('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')),
('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)),
('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)),
],
options={
"verbose_name": "Template/user relation",
"verbose_name_plural": "Template/user relations",
"db_table": "impress_template_access",
"ordering": ("-created_at",),
'verbose_name': 'Template/user relation',
'verbose_name_plural': 'Template/user relations',
'db_table': 'impress_template_access',
'ordering': ('-created_at',),
},
),
migrations.AddConstraint(
model_name="documentaccess",
constraint=models.UniqueConstraint(
condition=models.Q(("user__isnull", False)),
fields=("user", "document"),
name="unique_document_user",
violation_error_message="This user is already in this document.",
),
model_name='documentaccess',
constraint=models.UniqueConstraint(condition=models.Q(('user__isnull', False)), fields=('user', 'document'), name='unique_document_user', violation_error_message='This user is already in this document.'),
),
migrations.AddConstraint(
model_name="documentaccess",
constraint=models.UniqueConstraint(
condition=models.Q(("team__gt", "")),
fields=("team", "document"),
name="unique_document_team",
violation_error_message="This team is already in this document.",
),
model_name='documentaccess',
constraint=models.UniqueConstraint(condition=models.Q(('team__gt', '')), fields=('team', 'document'), name='unique_document_team', violation_error_message='This team is already in this document.'),
),
migrations.AddConstraint(
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.",
),
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"
),
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.",
),
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.'),
),
migrations.AddConstraint(
model_name="templateaccess",
constraint=models.UniqueConstraint(
condition=models.Q(("team__gt", "")),
fields=("team", "template"),
name="unique_template_team",
violation_error_message="This team is already in this template.",
),
model_name='templateaccess',
constraint=models.UniqueConstraint(condition=models.Q(('team__gt', '')), fields=('team', 'template'), name='unique_template_team', violation_error_message='This team is already in this template.'),
),
migrations.AddConstraint(
model_name="templateaccess",
constraint=models.CheckConstraint(
check=models.Q(
models.Q(("team", ""), ("user__isnull", False)),
models.Q(("team__gt", ""), ("user__isnull", True)),
_connector="OR",
),
name="check_template_access_either_user_or_team",
violation_error_message="Either user or team must be set, not both.",
),
model_name='templateaccess',
constraint=models.CheckConstraint(check=models.Q(models.Q(('team', ''), ('user__isnull', False)), models.Q(('team__gt', ''), ('user__isnull', True)), _connector='OR'), name='check_template_access_either_user_or_team', violation_error_message='Either user or team must be set, not both.'),
),
]

View File

@@ -1,9 +1,9 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("core", "0001_initial"),
('core', '0001_initial'),
]
operations = [

View File

@@ -1,114 +1,52 @@
# Generated by Django 5.1 on 2024-09-08 16:55
import uuid
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0002_create_pg_trgm_extension"),
('core', '0002_create_pg_trgm_extension'),
]
operations = [
migrations.AddField(
model_name="document",
name="link_reach",
field=models.CharField(
choices=[
("restricted", "Restricted"),
("authenticated", "Authenticated"),
("public", "Public"),
],
default="authenticated",
max_length=20,
),
model_name='document',
name='link_reach',
field=models.CharField(choices=[('restricted', 'Restricted'), ('authenticated', 'Authenticated'), ('public', 'Public')], default='authenticated', max_length=20),
),
migrations.AddField(
model_name="document",
name="link_role",
field=models.CharField(
choices=[("reader", "Reader"), ("editor", "Editor")],
default="reader",
max_length=20,
),
model_name='document',
name='link_role',
field=models.CharField(choices=[('reader', 'Reader'), ('editor', 'Editor')], default='reader', max_length=20),
),
migrations.AlterField(
model_name="document",
name="is_public",
model_name='document',
name='is_public',
field=models.BooleanField(null=True),
),
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",
),
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="LinkTrace",
name='LinkTrace',
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
help_text="primary key for the record as UUID",
primary_key=True,
serialize=False,
verbose_name="id",
),
),
(
"created_at",
models.DateTimeField(
auto_now_add=True,
help_text="date and time at which a record was created",
verbose_name="created on",
),
),
(
"updated_at",
models.DateTimeField(
auto_now=True,
help_text="date and time at which a record was last updated",
verbose_name="updated on",
),
),
(
"document",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="link_traces",
to="core.document",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="link_traces",
to=settings.AUTH_USER_MODEL,
),
),
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='link_traces', to='core.document')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='link_traces', to=settings.AUTH_USER_MODEL)),
],
options={
"verbose_name": "Document/user link trace",
"verbose_name_plural": "Document/user link traces",
"db_table": "impress_link_trace",
"constraints": [
models.UniqueConstraint(
fields=("user", "document"),
name="unique_link_trace_document_user",
violation_error_message="A link trace already exists for this document/user.",
)
],
'verbose_name': 'Document/user link trace',
'verbose_name_plural': 'Document/user link traces',
'db_table': 'impress_link_trace',
'constraints': [models.UniqueConstraint(fields=('user', 'document'), name='unique_link_trace_document_user', violation_error_message='A link trace already exists for this document/user.')],
},
),
]

View File

@@ -1,14 +1,13 @@
# Generated by Django 5.1 on 2024-09-08 17:04
from django.db import migrations
def migrate_is_public_to_link_reach(apps, schema_editor):
"""
Forward migration: Migrate 'is_public' to 'link_reach'.
If is_public == True, set link_reach to 'public'
"""
Document = apps.get_model("core", "Document")
Document.objects.filter(is_public=True).update(link_reach="public")
Document = apps.get_model('core', 'Document')
Document.objects.filter(is_public=True).update(link_reach='public')
def reverse_migrate_link_reach_to_is_public(apps, schema_editor):
@@ -17,20 +16,20 @@ def reverse_migrate_link_reach_to_is_public(apps, schema_editor):
- If link_reach == 'public', set is_public to True
- Else set is_public to False
"""
Document = apps.get_model("core", "Document")
Document.objects.filter(link_reach="public").update(is_public=True)
Document.objects.filter(link_reach__in=["restricted", "authenticated"]).update(
is_public=False
)
Document = apps.get_model('core', 'Document')
Document.objects.filter(link_reach='public').update(is_public=True)
Document.objects.filter(link_reach__in=['restricted', "authenticated"]).update(is_public=False)
class Migration(migrations.Migration):
dependencies = [
("core", "0003_document_link_reach_document_link_role_and_more"),
('core', '0003_document_link_reach_document_link_role_and_more'),
]
operations = [
migrations.RunPython(
migrate_is_public_to_link_reach, reverse_migrate_link_reach_to_is_public
migrate_is_public_to_link_reach,
reverse_migrate_link_reach_to_is_public
),
]

View File

@@ -4,16 +4,15 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0004_migrate_is_public_to_link_reach"),
('core', '0004_migrate_is_public_to_link_reach'),
]
operations = [
migrations.AlterField(
model_name="document",
name="title",
field=models.CharField(
blank=True, max_length=255, null=True, verbose_name="title"
),
model_name='document',
name='title',
field=models.CharField(blank=True, max_length=255, null=True, verbose_name='title'),
),
]

View File

@@ -4,34 +4,25 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0005_remove_document_is_public_alter_document_link_reach_and_more"),
('core', '0005_remove_document_is_public_alter_document_link_reach_and_more'),
]
operations = [
migrations.AddField(
model_name="user",
name="full_name",
field=models.CharField(
blank=True, max_length=100, null=True, verbose_name="full name"
),
model_name='user',
name='full_name',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='full name'),
),
migrations.AddField(
model_name="user",
name="short_name",
field=models.CharField(
blank=True, max_length=20, null=True, verbose_name="short name"
),
model_name='user',
name='short_name',
field=models.CharField(blank=True, max_length=20, null=True, verbose_name='short name'),
),
migrations.AlterField(
model_name="user",
name="language",
field=models.CharField(
choices="(('en-us', 'English'), ('fr-fr', 'French'))",
default="en-us",
help_text="The language in which the user wants to see the interface.",
max_length=10,
verbose_name="language",
),
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'),
),
]

View File

@@ -117,10 +117,10 @@ BEGIN
END $$;
"""
class Migration(migrations.Migration):
dependencies = [
("core", "0006_add_user_full_name_and_short_name"),
('core', '0006_add_user_full_name_and_short_name'),
]
operations = [

View File

@@ -4,22 +4,15 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0007_fix_users_duplicate"),
('core', '0007_fix_users_duplicate'),
]
operations = [
migrations.AlterField(
model_name="document",
name="link_reach",
field=models.CharField(
choices=[
("restricted", "Restricted"),
("authenticated", "Authenticated"),
("public", "Public"),
],
default="restricted",
max_length=20,
),
model_name='document',
name='link_reach',
field=models.CharField(choices=[('restricted', 'Restricted'), ('authenticated', 'Authenticated'), ('public', 'Public')], default='restricted', max_length=20),
),
]

View File

@@ -1,87 +1,37 @@
# Generated by Django 5.1.2 on 2024-11-08 07:59
import uuid
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0008_alter_document_link_reach"),
('core', '0008_alter_document_link_reach'),
]
operations = [
migrations.AlterField(
model_name="user",
name="language",
field=models.CharField(
choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))",
default="en-us",
help_text="The language in which the user wants to see the interface.",
max_length=10,
verbose_name="language",
),
model_name='user',
name='language',
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
),
migrations.CreateModel(
name="DocumentFavorite",
name='DocumentFavorite',
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
help_text="primary key for the record as UUID",
primary_key=True,
serialize=False,
verbose_name="id",
),
),
(
"created_at",
models.DateTimeField(
auto_now_add=True,
help_text="date and time at which a record was created",
verbose_name="created on",
),
),
(
"updated_at",
models.DateTimeField(
auto_now=True,
help_text="date and time at which a record was last updated",
verbose_name="updated on",
),
),
(
"document",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="favorited_by_users",
to="core.document",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="favorite_documents",
to=settings.AUTH_USER_MODEL,
),
),
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorited_by_users', to='core.document')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorite_documents', to=settings.AUTH_USER_MODEL)),
],
options={
"verbose_name": "Document favorite",
"verbose_name_plural": "Document favorites",
"db_table": "impress_document_favorite",
"constraints": [
models.UniqueConstraint(
fields=("user", "document"),
name="unique_document_favorite_user",
violation_error_message="This document is already targeted by a favorite relation instance for the same user.",
)
],
'verbose_name': 'Document favorite',
'verbose_name_plural': 'Document favorites',
'db_table': 'impress_document_favorite',
'constraints': [models.UniqueConstraint(fields=('user', 'document'), name='unique_document_favorite_user', violation_error_message='This document is already targeted by a favorite relation instance for the same user.')],
},
),
]

View File

@@ -7,48 +7,25 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0009_add_document_favorite"),
('core', '0009_add_document_favorite'),
]
operations = [
migrations.AddField(
model_name="document",
name="creator",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.RESTRICT,
related_name="documents_created",
to=settings.AUTH_USER_MODEL,
),
model_name='document',
name='creator',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='documents_created', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name="user",
name="language",
field=models.CharField(
choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))",
default="en-us",
help_text="The language in which the user wants to see the interface.",
max_length=10,
verbose_name="language",
),
model_name='user',
name='language',
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
),
migrations.AlterField(
model_name="user",
name="sub",
field=models.CharField(
blank=True,
help_text="Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only.",
max_length=255,
null=True,
unique=True,
validators=[
django.core.validators.RegexValidator(
message="Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters.",
regex="^[\\w.@+-:]+\\Z",
)
],
verbose_name="sub",
),
model_name='user',
name='sub',
field=models.CharField(blank=True, help_text='Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only.', max_length=255, null=True, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters.', regex='^[\\w.@+-:]+\\Z')], verbose_name='sub'),
),
]

View File

@@ -3,7 +3,7 @@
import django.db.models.deletion
from django.conf import settings
from django.db import migrations
from django.db.models import F, ForeignKey, OuterRef, Q, Subquery
from django.db.models import F, ForeignKey, Subquery, OuterRef, Q
def set_creator_from_document_access(apps, schema_editor):
@@ -25,37 +25,28 @@ def set_creator_from_document_access(apps, schema_editor):
DocumentAccess = apps.get_model("core", "DocumentAccess")
# Update `creator` using the "owner" role
owner_subquery = (
DocumentAccess.objects.filter(
document=OuterRef("pk"),
user__isnull=False,
role="owner",
)
.order_by("created_at")
.values("user_id")[:1]
)
owner_subquery = DocumentAccess.objects.filter(
document=OuterRef('pk'),
user__isnull=False,
role='owner',
).order_by('created_at').values('user_id')[:1]
Document.objects.filter(creator__isnull=True).update(
creator=Subquery(owner_subquery)
)
Document.objects.filter(
creator__isnull=True
).update(creator=Subquery(owner_subquery))
class Migration(migrations.Migration):
dependencies = [
("core", "0010_add_field_creator_to_document"),
('core', '0010_add_field_creator_to_document'),
]
operations = [
migrations.RunPython(
set_creator_from_document_access, reverse_code=migrations.RunPython.noop
),
migrations.RunPython(set_creator_from_document_access, reverse_code=migrations.RunPython.noop),
migrations.AlterField(
model_name="document",
name="creator",
field=ForeignKey(
on_delete=django.db.models.deletion.RESTRICT,
related_name="documents_created",
to=settings.AUTH_USER_MODEL,
),
model_name='document',
name='creator',
field=ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='documents_created', to=settings.AUTH_USER_MODEL),
),
]

View File

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

View File

@@ -1,16 +0,0 @@
# Generated by Django 5.1.4 on 2025-01-25 08:38
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("core", "0012_make_document_creator_and_invitation_issuer_optional"),
]
operations = [
migrations.RunSQL(
"CREATE EXTENSION IF NOT EXISTS fuzzystrmatch;",
reverse_sql="DROP EXTENSION IF EXISTS fuzzystrmatch;",
),
]

View File

@@ -1,32 +0,0 @@
# Generated by Django 5.1.2 on 2024-12-07 09:43
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0013_activate_fuzzystrmatch_extension"),
]
operations = [
migrations.AddField(
model_name="document",
name="depth",
field=models.PositiveIntegerField(default=0),
preserve_default=False,
),
migrations.AddField(
model_name="document",
name="numchild",
field=models.PositiveIntegerField(default=0),
),
migrations.AddField(
model_name="document",
name="path",
# Allow null values pending the next datamigration to populate the field
field=models.CharField(
db_collation="C", max_length=252, null=True, unique=True
),
preserve_default=False,
),
]

View File

@@ -1,51 +0,0 @@
# Generated by Django 5.1.2 on 2024-12-07 10:33
from django.db import migrations, models
from treebeard.numconv import NumConv
ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
STEPLEN = 7
def set_path_on_existing_documents(apps, schema_editor):
"""
Updates the `path` and `depth` fields for all existing Document records
to ensure valid materialized paths.
This function assigns a unique `path` to each Document as a root node
Note: After running this migration, we quickly modify the schema to make
the `path` field required as it should.
"""
Document = apps.get_model("core", "Document")
# Iterate over all existing documents and make them root nodes
documents = Document.objects.order_by("created_at").values_list("id", flat=True)
numconv = NumConv(len(ALPHABET), ALPHABET)
updates = []
for i, pk in enumerate(documents):
key = numconv.int2str(i)
path = "{0}{1}".format(ALPHABET[0] * (STEPLEN - len(key)), key)
updates.append(Document(pk=pk, path=path, depth=1))
# Bulk update using the prepared updates list
Document.objects.bulk_update(updates, ["depth", "path"])
class Migration(migrations.Migration):
dependencies = [
("core", "0014_add_tree_structure_to_documents"),
]
operations = [
migrations.RunPython(
set_path_on_existing_documents, reverse_code=migrations.RunPython.noop
),
migrations.AlterField(
model_name="document",
name="path",
field=models.CharField(db_collation="C", max_length=252, unique=True),
),
]

View File

@@ -1,30 +0,0 @@
# Generated by Django 5.1.4 on 2024-12-18 08:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0015_set_path_on_existing_documents"),
]
operations = [
migrations.AddField(
model_name="document",
name="excerpt",
field=models.TextField(
blank=True, max_length=300, null=True, verbose_name="excerpt"
),
),
migrations.AlterField(
model_name="user",
name="language",
field=models.CharField(
choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))",
default="en-us",
help_text="The language in which the user wants to see the interface.",
max_length=10,
verbose_name="language",
),
),
]

View File

@@ -1,52 +0,0 @@
# Generated by Django 5.1.4 on 2025-01-12 14:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0016_add_document_excerpt"),
]
operations = [
migrations.AlterModelOptions(
name="document",
options={
"ordering": ("path",),
"verbose_name": "Document",
"verbose_name_plural": "Documents",
},
),
migrations.AddField(
model_name="document",
name="ancestors_deleted_at",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name="document",
name="deleted_at",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AlterField(
model_name="user",
name="language",
field=models.CharField(
choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))",
default="en-us",
help_text="The language in which the user wants to see the interface.",
max_length=10,
verbose_name="language",
),
),
migrations.AddConstraint(
model_name="document",
constraint=models.CheckConstraint(
condition=models.Q(
("deleted_at__isnull", True),
("deleted_at", models.F("ancestors_deleted_at")),
_connector="OR",
),
name="check_deleted_at_matches_ancestors_deleted_at_when_set",
),
),
]

View File

@@ -1,24 +0,0 @@
from django.db import migrations
def update_titles_to_null(apps, schema_editor):
"""
If the titles are "Untitled document" or "Unbenanntes Dokument" or "Document sans titre"
we set them to Null
"""
Document = apps.get_model("core", "Document")
Document.objects.filter(
title__in=["Untitled document", "Unbenanntes Dokument", "Document sans titre"]
).update(title=None)
class Migration(migrations.Migration):
dependencies = [
("core", "0017_add_fields_for_soft_delete"),
]
operations = [
migrations.RunPython(
update_titles_to_null, reverse_code=migrations.RunPython.noop
),
]

View File

@@ -1,51 +1,59 @@
"""
Declare and configure the models for the impress core application
"""
# pylint: disable=too-many-lines
import hashlib
import smtplib
import tempfile
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 mail, validators
from django.core.cache import cache
from django.core import exceptions, mail, validators
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
from django.core.mail import send_mail
from django.db import models, transaction
from django.db.models.functions import Left, Length
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 timezone
from django.utils import html, timezone
from django.utils.functional import cached_property, lazy
from django.utils.translation import get_language, override
from django.utils.translation import gettext_lazy as _
import frontmatter
import markdown
import pypandoc
import weasyprint
from botocore.exceptions import ClientError
from rest_framework.exceptions import ValidationError
from timezone_field import TimeZoneField
from treebeard.mp_tree import MP_Node
logger = getLogger(__name__)
def get_trashbin_cutoff():
"""
Calculate the cutoff datetime for soft-deleted items based on the retention policy.
def get_resource_roles(resource, user):
"""Compute the roles a user has on a resource."""
if not user.is_authenticated:
return []
The function returns the current datetime minus the number of days specified in
the TRASHBIN_CUTOFF_DAYS setting, indicating the oldest date for items that can
remain in the trash bin.
Returns:
datetime: The cutoff datetime for soft-deleted items.
"""
return timezone.now() - timedelta(days=settings.TRASHBIN_CUTOFF_DAYS)
try:
roles = resource.user_roles or []
except AttributeError:
try:
roles = resource.accesses.filter(
models.Q(user=user) | models.Q(team__in=user.teams),
).values_list("role", flat=True)
except (models.ObjectDoesNotExist, IndexError):
roles = []
return roles
class LinkRoleChoices(models.TextChoices):
@@ -81,16 +89,6 @@ class LinkReachChoices(models.TextChoices):
PUBLIC = "public", _("Public") # Even anonymous users can access the document
class DuplicateEmailError(Exception):
"""Raised when an email is already associated with a pre-existing user."""
def __init__(self, message=None, email=None):
"""Set message and email to describe the exception."""
self.message = message
self.email = email
super().__init__(self.message)
class BaseModel(models.Model):
"""
Serves as an abstract base model for other models, ensuring that records are validated
@@ -128,35 +126,6 @@ class BaseModel(models.Model):
super().save(*args, **kwargs)
class UserManager(auth_models.UserManager):
"""Custom manager for User model with additional methods."""
def get_user_by_sub_or_email(self, sub, email):
"""Fetch existing user by sub or email."""
try:
return self.get(sub=sub)
except self.model.DoesNotExist as err:
if not email:
return None
if settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION:
try:
return self.get(email=email)
except self.model.DoesNotExist:
pass
elif (
self.filter(email=email).exists()
and not settings.OIDC_ALLOW_DUPLICATE_EMAILS
):
raise DuplicateEmailError(
_(
"We couldn't find a user with this sub but the email is already "
"associated with a registered user."
)
) from err
return None
class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
"""User model to work with OIDC only authentication."""
@@ -223,7 +192,7 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
),
)
objects = UserManager()
objects = auth_models.UserManager()
USERNAME_FIELD = "admin_email"
REQUIRED_FIELDS = []
@@ -367,11 +336,10 @@ class BaseAccess(BaseModel):
}
class Document(MP_Node, BaseModel):
class Document(BaseModel):
"""Pad document carrying the content."""
title = models.CharField(_("title"), max_length=255, null=True, blank=True)
excerpt = models.TextField(_("excerpt"), max_length=300, null=True, blank=True)
link_reach = models.CharField(
max_length=20,
choices=LinkReachChoices.choices,
@@ -387,32 +355,14 @@ class Document(MP_Node, BaseModel):
blank=True,
null=True,
)
deleted_at = models.DateTimeField(null=True, blank=True)
ancestors_deleted_at = models.DateTimeField(null=True, blank=True)
_content = None
# Tree structure
alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
steplen = 7 # nb siblings max: 3,521,614,606,208
node_order_by = [] # Manual ordering
path = models.CharField(max_length=7 * 36, unique=True, db_collation="C")
class Meta:
db_table = "impress_document"
ordering = ("path",)
ordering = ("title",)
verbose_name = _("Document")
verbose_name_plural = _("Documents")
constraints = [
models.CheckConstraint(
check=(
models.Q(deleted_at__isnull=True)
| models.Q(deleted_at=models.F("ancestors_deleted_at"))
),
name="check_deleted_at_matches_ancestors_deleted_at_when_set",
),
]
def __str__(self):
return str(self.title) if self.title else str(_("Untitled Document"))
@@ -551,139 +501,48 @@ class Document(MP_Node, BaseModel):
Bucket=default_storage.bucket_name, Key=self.file_key, VersionId=version_id
)
def get_nb_accesses_cache_key(self):
"""Generate a unique cache key for each document."""
return f"document_{self.id!s}_nb_accesses"
@property
def nb_accesses(self):
"""Calculate the number of accesses."""
cache_key = self.get_nb_accesses_cache_key()
nb_accesses = cache.get(cache_key)
if nb_accesses is None:
nb_accesses = DocumentAccess.objects.filter(
document__path=Left(models.Value(self.path), Length("document__path")),
).count()
cache.set(cache_key, nb_accesses)
return nb_accesses
def invalidate_nb_accesses_cache(self):
"""
Invalidate the cache for number of accesses, including on affected descendants.
"""
for document in Document.objects.filter(path__startswith=self.path).only("id"):
cache_key = document.get_nb_accesses_cache_key()
cache.delete(cache_key)
def get_roles(self, user):
"""Return the roles a user has on a document."""
if not user.is_authenticated:
return []
try:
roles = self.user_roles or []
except AttributeError:
try:
roles = DocumentAccess.objects.filter(
models.Q(user=user) | models.Q(team__in=user.teams),
document__path=Left(
models.Value(self.path), Length("document__path")
),
).values_list("role", flat=True)
except (models.ObjectDoesNotExist, IndexError):
roles = []
return roles
@cached_property
def links_definitions(self):
"""Get links reach/role definitions for the current document and its ancestors."""
links_definitions = {self.link_reach: {self.link_role}}
# Ancestors links definitions are only interesting if the document is not the highest
# ancestor to which the current user has access. Look for the annotation:
if self.depth > 1 and not getattr(self, "is_highest_ancestor_for_user", False):
for ancestor in self.get_ancestors().values("link_reach", "link_role"):
links_definitions.setdefault(ancestor["link_reach"], set()).add(
ancestor["link_role"]
)
return links_definitions
def get_abilities(self, user):
"""
Compute and return abilities for a given user on the document.
"""
roles = set(
self.get_roles(user)
) # at this point only roles based on specific access
roles = set(get_resource_roles(self, user))
# Characteristics that are based only on specific access
is_owner = RoleChoices.OWNER in roles
is_deleted = self.ancestors_deleted_at and not is_owner
is_owner_or_admin = (is_owner or RoleChoices.ADMIN in roles) and not is_deleted
# Compute access roles before adding link roles because we don't
# Compute version roles before adding link roles because we don't
# want anonymous users to access versions (we wouldn't know from
# which date to allow them anyway)
# Anonymous users should also not see document accesses
has_access_role = bool(roles) and not is_deleted
can_update_from_access = (
is_owner_or_admin or RoleChoices.EDITOR in roles
) and not is_deleted
has_role = bool(roles)
# Add roles provided by the document link, taking into account its ancestors
# Add role provided by the document link
if self.link_reach == LinkReachChoices.PUBLIC or (
self.link_reach == LinkReachChoices.AUTHENTICATED and user.is_authenticated
):
roles.add(self.link_role)
# Add roles provided by the document link
links_definitions = self.links_definitions
public_roles = links_definitions.get(LinkReachChoices.PUBLIC, set())
authenticated_roles = (
links_definitions.get(LinkReachChoices.AUTHENTICATED, set())
if user.is_authenticated
else set()
)
roles = roles | public_roles | authenticated_roles
can_get = bool(roles) and not is_deleted
can_update = (
is_owner_or_admin or RoleChoices.EDITOR in roles
) and not is_deleted
ai_allow_reach_from = settings.AI_ALLOW_REACH_FROM
ai_access = any(
[
ai_allow_reach_from == LinkReachChoices.PUBLIC and can_update,
ai_allow_reach_from == LinkReachChoices.AUTHENTICATED
and user.is_authenticated
and can_update,
ai_allow_reach_from == LinkReachChoices.RESTRICTED
and can_update_from_access,
]
is_owner_or_admin = bool(
roles.intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
)
can_get = bool(roles)
can_update = is_owner_or_admin or RoleChoices.EDITOR in roles
return {
"accesses_manage": is_owner_or_admin,
"accesses_view": has_access_role,
"ai_transform": ai_access,
"ai_translate": ai_access,
"accesses_view": has_role,
"ai_transform": can_update,
"ai_translate": can_update,
"attachment_upload": can_update,
"children_list": can_get,
"children_create": can_update and user.is_authenticated,
"collaboration_auth": can_get,
"destroy": is_owner,
"destroy": RoleChoices.OWNER in roles,
"favorite": can_get and user.is_authenticated,
"link_configuration": is_owner_or_admin,
"invite_owner": is_owner,
"move": is_owner_or_admin and not self.ancestors_deleted_at,
"invite_owner": RoleChoices.OWNER in roles,
"partial_update": can_update,
"restore": is_owner,
"retrieve": can_get,
"media_auth": can_get,
"update": can_update,
"versions_destroy": is_owner_or_admin,
"versions_list": has_access_role,
"versions_retrieve": has_access_role,
"versions_list": has_role,
"versions_retrieve": has_role,
}
def send_email(self, subject, emails, context=None, language=None):
@@ -744,77 +603,6 @@ class Document(MP_Node, BaseModel):
self.send_email(subject, [email], context, language)
@transaction.atomic
def soft_delete(self):
"""
Soft delete the document, marking the deletion on descendants.
We still keep the .delete() method untouched for programmatic purposes.
"""
if self.deleted_at or self.ancestors_deleted_at:
raise RuntimeError(
"This document is already deleted or has deleted ancestors."
)
# Check if any ancestors are deleted
if self.get_ancestors().filter(deleted_at__isnull=False).exists():
raise RuntimeError(
"Cannot delete this document because one or more ancestors are already deleted."
)
self.ancestors_deleted_at = self.deleted_at = timezone.now()
self.save()
# Mark all descendants as soft deleted
self.get_descendants().filter(ancestors_deleted_at__isnull=True).update(
ancestors_deleted_at=self.ancestors_deleted_at
)
@transaction.atomic
def restore(self):
"""Cancelling a soft delete with checks."""
# This should not happen
if self.deleted_at is None:
raise ValidationError({"deleted_at": [_("This document is not deleted.")]})
if self.deleted_at < get_trashbin_cutoff():
raise ValidationError(
{
"deleted_at": [
_(
"This document was permanently deleted and cannot be restored."
)
]
}
)
# Restore the current document
self.deleted_at = None
# Calculate the minimum `deleted_at` among all ancestors
ancestors_deleted_at = (
self.get_ancestors()
.filter(deleted_at__isnull=False)
.values_list("deleted_at", flat=True)
)
self.ancestors_deleted_at = min(ancestors_deleted_at, default=None)
self.save()
# Update descendants excluding those who were deleted prior to the deletion of the
# current document (the ancestor_deleted_at date for those should already by good)
# The number of deleted descendants should not be too big so we can handcraft a union
# clause for them:
deleted_descendants_paths = (
self.get_descendants()
.filter(deleted_at__isnull=False)
.values_list("path", flat=True)
)
exclude_condition = models.Q(
*(models.Q(path__startswith=path) for path in deleted_descendants_paths)
)
self.get_descendants().exclude(exclude_condition).update(
ancestors_deleted_at=self.ancestors_deleted_at
)
class LinkTrace(BaseModel):
"""
@@ -917,16 +705,6 @@ class DocumentAccess(BaseAccess):
def __str__(self):
return f"{self.user!s} is {self.role:s} in document {self.document!s}"
def save(self, *args, **kwargs):
"""Override save to clear the document's cache for number of accesses."""
super().save(*args, **kwargs)
self.document.invalidate_nb_accesses_cache()
def delete(self, *args, **kwargs):
"""Override delete to clear the document's cache for number of accesses."""
super().delete(*args, **kwargs)
self.document.invalidate_nb_accesses_cache()
def get_abilities(self, user):
"""
Compute and return abilities for a given user on the document access.
@@ -956,27 +734,11 @@ class Template(BaseModel):
def __str__(self):
return self.title
def get_roles(self, user):
"""Return the roles a user has on a resource as an iterable."""
if not user.is_authenticated:
return []
try:
roles = self.user_roles or []
except AttributeError:
try:
roles = self.accesses.filter(
models.Q(user=user) | models.Q(team__in=user.teams),
).values_list("role", flat=True)
except (models.ObjectDoesNotExist, IndexError):
roles = []
return roles
def get_abilities(self, user):
"""
Compute and return abilities for a given user on the template.
"""
roles = self.get_roles(user)
roles = get_resource_roles(self, user)
is_owner_or_admin = bool(
set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
)
@@ -992,6 +754,107 @@ class Template(BaseModel):
"retrieve": can_get,
}
def generate_pdf(self, body_html, metadata):
"""
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
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
strip_body = document.content.strip()
if body_type == "html":
body_html = strip_body
else:
body_html = (
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)
class TemplateAccess(BaseAccess):
"""Relation model to give access to a template for a user or a team with a role."""
@@ -1076,12 +939,9 @@ class Invitation(BaseModel):
super().clean()
# Check if an identity already exists for the provided email
if (
User.objects.filter(email=self.email).exists()
and not settings.OIDC_ALLOW_DUPLICATE_EMAILS
):
raise ValidationError(
{"email": [_("This email is already associated to a registered user.")]}
if User.objects.filter(email=self.email).exists():
raise exceptions.ValidationError(
{"email": _("This email is already associated to a registered user.")}
)
@property

Binary file not shown.

View File

@@ -1,9 +1,6 @@
"""Unit tests for the Authentication Backends."""
import random
import re
from logging import Logger
from unittest import mock
from django.core.exceptions import SuspiciousOperation
from django.test.utils import override_settings
@@ -65,33 +62,7 @@ def test_authentication_getter_existing_user_via_email(
assert user == db_user
def test_authentication_getter_email_none(monkeypatch):
"""
If no user is found with the sub and no email is provided, a new user should be created.
"""
klass = OIDCAuthenticationBackend()
db_user = UserFactory(email=None)
def get_userinfo_mocked(*args):
user_info = {"sub": "123"}
if random.choice([True, False]):
user_info["email"] = None
return user_info
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
# Since the sub and email didn't match, it should create a new user
assert models.User.objects.count() == 2
assert user != db_user
assert user.sub == "123"
def test_authentication_getter_existing_user_no_fallback_to_email_allow_duplicate(
def test_authentication_getter_existing_user_no_fallback_to_email(
settings, monkeypatch
):
"""
@@ -104,7 +75,6 @@ def test_authentication_getter_existing_user_no_fallback_to_email_allow_duplicat
# Set the setting to False
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = False
settings.OIDC_ALLOW_DUPLICATE_EMAILS = True
def get_userinfo_mocked(*args):
return {"sub": "123", "email": db_user.email}
@@ -121,39 +91,6 @@ def test_authentication_getter_existing_user_no_fallback_to_email_allow_duplicat
assert user.sub == "123"
def test_authentication_getter_existing_user_no_fallback_to_email_no_duplicate(
settings, monkeypatch
):
"""
When the "OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION" setting is set to False,
the system should not match users by email, even if the email matches.
"""
klass = OIDCAuthenticationBackend()
db_user = UserFactory()
# Set the setting to False
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = False
settings.OIDC_ALLOW_DUPLICATE_EMAILS = False
def get_userinfo_mocked(*args):
return {"sub": "123", "email": db_user.email}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with pytest.raises(
SuspiciousOperation,
match=(
"We couldn't find a user with this sub but the email is already associated "
"with a registered user."
),
):
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
# Since the sub doesn't match, it should not create a new user
assert models.User.objects.count() == 1
def test_authentication_getter_existing_user_with_email(
django_assert_num_queries, monkeypatch
):
@@ -191,12 +128,11 @@ def test_authentication_getter_existing_user_with_email(
("Jack", "Duy", "jack.duy@example.com"),
],
)
def test_authentication_getter_existing_user_change_fields_sub(
def test_authentication_getter_existing_user_change_fields(
first_name, last_name, email, django_assert_num_queries, monkeypatch
):
"""
It should update the email or name fields on the user when they change
and the user was identified by its "sub".
It should update the email or name fields on the user when they change.
"""
klass = OIDCAuthenticationBackend()
user = UserFactory(
@@ -226,48 +162,6 @@ def test_authentication_getter_existing_user_change_fields_sub(
assert user.short_name == first_name
@pytest.mark.parametrize(
"first_name, last_name, email",
[
("Jack", "Doe", "john.doe@example.com"),
("John", "Duy", "john.doe@example.com"),
],
)
def test_authentication_getter_existing_user_change_fields_email(
first_name, last_name, email, django_assert_num_queries, monkeypatch
):
"""
It should update the name fields on the user when they change
and the user was identified by its "email" as fallback.
"""
klass = OIDCAuthenticationBackend()
user = UserFactory(
full_name="John Doe", short_name="John", email="john.doe@example.com"
)
def get_userinfo_mocked(*args):
return {
"sub": "123",
"email": user.email,
"first_name": first_name,
"last_name": last_name,
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
# One and only one additional update query when a field has changed
with django_assert_num_queries(3):
authenticated_user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user == authenticated_user
user.refresh_from_db()
assert user.email == email
assert user.full_name == f"{first_name:s} {last_name:s}"
assert user.short_name == first_name
def test_authentication_getter_new_user_no_email(monkeypatch):
"""
If no user matches the user's info sub, a user should be created.
@@ -319,6 +213,29 @@ def test_authentication_getter_new_user_with_email(monkeypatch):
assert models.User.objects.count() == 1
def test_authentication_getter_invalid_token(django_assert_num_queries, monkeypatch):
"""The user's info doesn't contain a sub."""
klass = OIDCAuthenticationBackend()
def get_userinfo_mocked(*args):
return {
"test": "123",
}
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",
),
):
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
assert models.User.objects.exists() is False
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
@responses.activate
def test_authentication_get_userinfo_json_response():
@@ -424,7 +341,7 @@ def test_authentication_getter_existing_disabled_user_via_email(
django_assert_num_queries, monkeypatch
):
"""
If an existing user does not match the sub but matches the email and is disabled,
If an existing user does not matches the sub but matches the email and is disabled,
an error should be raised and a user should not be created.
"""
@@ -448,102 +365,3 @@ def test_authentication_getter_existing_disabled_user_via_email(
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
assert models.User.objects.count() == 1
# Essential claims
def test_authentication_verify_claims_default(django_assert_num_queries, monkeypatch):
"""The sub claim should be mandatory by default."""
klass = OIDCAuthenticationBackend()
def get_userinfo_mocked(*args):
return {
"test": "123",
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with (
django_assert_num_queries(0),
pytest.raises(
KeyError,
match="sub",
),
):
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
assert models.User.objects.exists() is False
@pytest.mark.parametrize(
"essential_claims, missing_claims",
[
(["email", "sub"], ["email"]),
(["Email", "sub"], ["Email"]), # Case sensitivity
],
)
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
@mock.patch.object(Logger, "error")
def test_authentication_verify_claims_essential_missing(
mock_logger,
essential_claims,
missing_claims,
django_assert_num_queries,
monkeypatch,
):
"""Ensure SuspiciousOperation is raised if essential claims are missing."""
klass = OIDCAuthenticationBackend()
def get_userinfo_mocked(*args):
return {
"sub": "123",
"last_name": "Doe",
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with (
django_assert_num_queries(0),
pytest.raises(
SuspiciousOperation,
match="Claims verification failed",
),
override_settings(USER_OIDC_ESSENTIAL_CLAIMS=essential_claims),
):
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
assert models.User.objects.exists() is False
mock_logger.assert_called_once_with("Missing essential claims: %s", missing_claims)
@override_settings(
OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo",
USER_OIDC_ESSENTIAL_CLAIMS=["email", "last_name"],
)
def test_authentication_verify_claims_success(django_assert_num_queries, monkeypatch):
"""Ensure user is authenticated when all essential claims are present."""
klass = OIDCAuthenticationBackend()
def get_userinfo_mocked(*args):
return {
"email": "john.doe@example.com",
"last_name": "Doe",
"sub": "123",
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with django_assert_num_queries(6):
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert models.User.objects.filter(id=user.id).exists()
assert user.sub == "123"
assert user.full_name == "Doe"
assert user.short_name is None
assert user.email == "john.doe@example.com"

View File

@@ -1,50 +0,0 @@
"""
Unit test for `update_files_content_type_metadata` command.
"""
import uuid
from django.core.files.storage import default_storage
from django.core.management import call_command
import pytest
from core import factories
@pytest.mark.django_db
def test_update_files_content_type_metadata():
"""
Test that the command `update_files_content_type_metadata`
fixes the ContentType of attachment in the storage.
"""
s3_client = default_storage.connection.meta.client
bucket_name = default_storage.bucket_name
# Create files with a wrong ContentType
keys = []
for _ in range(10):
doc_id = uuid.uuid4()
factories.DocumentFactory(id=doc_id)
key = f"{doc_id}/attachments/testfile.png"
keys.append(key)
fake_png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR..."
s3_client.put_object(
Bucket=bucket_name,
Key=key,
Body=fake_png,
ContentType="text/plain",
Metadata={"owner": "None"},
)
# Call the command that fixes the ContentType
call_command("update_files_content_type_metadata")
for key in keys:
head_resp = s3_client.head_object(Bucket=bucket_name, Key=key)
assert head_resp["ContentType"] == "image/png", (
f"ContentType not fixed, got {head_resp['ContentType']!r}"
)
# Check that original metadata was preserved
assert head_resp["Metadata"].get("owner") == "None"

View File

@@ -76,14 +76,14 @@ def test_api_document_accesses_list_authenticated_related(via, mock_user_teams):
user_access = models.DocumentAccess.objects.create(
document=document,
user=user,
role=random.choice(models.RoleChoices.values),
role=random.choice(models.RoleChoices.choices)[0],
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
user_access = models.DocumentAccess.objects.create(
document=document,
team="lasuite",
role=random.choice(models.RoleChoices.values),
role=random.choice(models.RoleChoices.choices)[0],
)
access1 = factories.TeamDocumentAccessFactory(document=document)
@@ -227,7 +227,7 @@ def test_api_document_accesses_update_anonymous():
new_values = {
"id": uuid4(),
"user": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.values),
"role": random.choice(models.RoleChoices.choices)[0],
}
api_client = APIClient()
@@ -260,7 +260,7 @@ def test_api_document_accesses_update_authenticated_unrelated():
new_values = {
"id": uuid4(),
"user": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.values),
"role": random.choice(models.RoleChoices.choices)[0],
}
for field, value in new_values.items():
@@ -302,7 +302,7 @@ def test_api_document_accesses_update_authenticated_reader_or_editor(
new_values = {
"id": uuid4(),
"user": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.values),
"role": random.choice(models.RoleChoices.choices)[0],
}
for field, value in new_values.items():
@@ -413,7 +413,7 @@ def test_api_document_accesses_update_administrator_from_owner(via, mock_user_te
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.values),
"role": random.choice(models.RoleChoices.choices)[0],
}
for field, value in new_values.items():
@@ -527,7 +527,7 @@ def test_api_document_accesses_update_owner(
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.values),
"role": random.choice(models.RoleChoices.choices)[0],
}
for field, value in new_values.items():

View File

@@ -26,7 +26,7 @@ def test_api_document_accesses_create_anonymous():
{
"user_id": str(other_user.id),
"document": str(document.id),
"role": random.choice(models.RoleChoices.values),
"role": random.choice(models.RoleChoices.choices)[0],
},
format="json",
)

View File

@@ -304,7 +304,7 @@ def test_api_document_invitations_create_anonymous():
document = factories.DocumentFactory()
invitation_values = {
"email": "guest@example.com",
"role": random.choice(models.RoleChoices.values),
"role": random.choice(models.RoleChoices.choices)[0],
}
response = APIClient().post(
@@ -325,7 +325,7 @@ def test_api_document_invitations_create_authenticated_outsider():
document = factories.DocumentFactory()
invitation_values = {
"email": "guest@example.com",
"role": random.choice(models.RoleChoices.values),
"role": random.choice(models.RoleChoices.choices)[0],
}
client = APIClient()
@@ -458,10 +458,6 @@ def test_api_document_invitations_create_email_from_content_language():
email_content = " ".join(email.body.split())
assert f"{user.full_name} a partagé un document avec vous!" in email_content
assert (
"Docs, votre nouvel outil incontournable pour organiser, partager et collaborer "
"sur vos documents en équipe." in email_content
)
def test_api_document_invitations_create_email_from_content_language_not_supported():
@@ -554,7 +550,7 @@ def test_api_document_invitations_create_issuer_and_document_override():
"document": str(other_document.id),
"issuer": str(factories.UserFactory().id),
"email": "guest@example.com",
"role": random.choice(models.RoleChoices.values),
"role": random.choice(models.RoleChoices.choices)[0],
}
client = APIClient()
@@ -615,7 +611,7 @@ def test_api_document_invitations_create_cannot_invite_existing_users():
# Build an invitation to the email of an exising identity in the db
invitation_values = {
"email": existing_user.email,
"role": random.choice(models.RoleChoices.values),
"role": random.choice(models.RoleChoices.choices)[0],
}
client = APIClient()
@@ -628,9 +624,7 @@ def test_api_document_invitations_create_cannot_invite_existing_users():
)
assert response.status_code == 400
assert response.json() == {
"email": ["This email is already associated to a registered user."]
}
assert response.json() == ["This email is already associated to a registered user."]
# Update

View File

@@ -75,14 +75,14 @@ def test_api_document_versions_list_authenticated_related_success(via, mock_user
models.DocumentAccess.objects.create(
document=document,
user=user,
role=random.choice(models.RoleChoices.values),
role=random.choice(models.RoleChoices.choices)[0],
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
models.DocumentAccess.objects.create(
document=document,
team="lasuite",
role=random.choice(models.RoleChoices.values),
role=random.choice(models.RoleChoices.choices)[0],
)
# Other versions of documents to which the user has access should not be listed
@@ -134,14 +134,14 @@ def test_api_document_versions_list_authenticated_related_pagination(
models.DocumentAccess.objects.create(
document=document,
user=user,
role=random.choice(models.RoleChoices.values),
role=random.choice(models.RoleChoices.choices)[0],
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
models.DocumentAccess.objects.create(
document=document,
team="lasuite",
role=random.choice(models.RoleChoices.values),
role=random.choice(models.RoleChoices.choices)[0],
)
for i in range(4):
@@ -185,84 +185,6 @@ def test_api_document_versions_list_authenticated_related_pagination(
assert content["versions"][0]["version_id"] == all_version_ids[2]
@pytest.mark.parametrize("via", VIA)
def test_api_document_versions_list_authenticated_related_pagination_parent(
via, mock_user_teams
):
"""
When a user gains access to a document's versions via an ancestor, the date of access
to the parent should be used to filter versions that were created prior to the
user gaining access to the document.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
grand_parent = factories.DocumentFactory()
parent = factories.DocumentFactory(parent=grand_parent)
document = factories.DocumentFactory(parent=parent)
for i in range(3):
document.content = f"before {i:d}"
document.save()
if via == USER:
models.DocumentAccess.objects.create(
document=grand_parent,
user=user,
role=random.choice(models.RoleChoices.values),
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
models.DocumentAccess.objects.create(
document=grand_parent,
team="lasuite",
role=random.choice(models.RoleChoices.values),
)
for i in range(4):
document.content = f"after {i:d}"
document.save()
response = client.get(
f"/api/v1.0/documents/{document.id!s}/versions/",
)
content = response.json()
assert response.status_code == 200
assert content["is_truncated"] is False
# The current version is not listed
assert content["count"] == 3
assert content["next_version_id_marker"] == ""
all_version_ids = [version["version_id"] for version in content["versions"]]
# - set page size
response = client.get(
f"/api/v1.0/documents/{document.id!s}/versions/?page_size=2",
)
content = response.json()
assert content["count"] == 2
assert content["is_truncated"] is True
marker = content["next_version_id_marker"]
assert marker == all_version_ids[1]
assert [
version["version_id"] for version in content["versions"]
] == all_version_ids[:2]
# - get page 2
response = client.get(
f"/api/v1.0/documents/{document.id!s}/versions/?page_size=2&version_id={marker:s}",
)
content = response.json()
assert content["count"] == 1
assert content["is_truncated"] is False
assert content["next_version_id_marker"] == ""
assert content["versions"][0]["version_id"] == all_version_ids[2]
def test_api_document_versions_list_exceeds_max_page_size():
"""Page size should not exceed the limit set on the serializer"""
user = factories.UserFactory()
@@ -392,74 +314,6 @@ def test_api_document_versions_retrieve_authenticated_related(via, mock_user_tea
assert response.json()["content"] == "new content 1"
@pytest.mark.parametrize("via", VIA)
def test_api_document_versions_retrieve_authenticated_related_parent(
via, mock_user_teams
):
"""
A user who gains access to a document's versions via one of its ancestors, should be able to
retrieve the document versions. The date of access to the parent should be used to filter
versions that were created prior to the user gaining access to the document.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
grand_parent = factories.DocumentFactory()
parent = factories.DocumentFactory(parent=grand_parent)
document = factories.DocumentFactory(parent=parent)
document.content = "new content"
document.save()
assert len(document.get_versions_slice()["versions"]) == 1
version_id = document.get_versions_slice()["versions"][0]["version_id"]
if via == USER:
factories.UserDocumentAccessFactory(document=grand_parent, user=user)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(document=grand_parent, team="lasuite")
time.sleep(1) # minio stores datetimes with the precision of a second
# Versions created before the document was shared should not be seen by the user
response = client.get(
f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/",
)
assert response.status_code == 404
# Create a new version should not make it available to the user because
# only the current version is available to the user but it is excluded
# from the list
document.content = "new content 1"
document.save()
assert len(document.get_versions_slice()["versions"]) == 2
version_id = document.get_versions_slice()["versions"][0]["version_id"]
response = client.get(
f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/",
)
assert response.status_code == 404
# Adding one more version should make the previous version available to the user
document.content = "new content 2"
document.save()
assert len(document.get_versions_slice()["versions"]) == 3
version_id = document.get_versions_slice()["versions"][0]["version_id"]
response = client.get(
f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/",
)
assert response.status_code == 200
assert response.json()["content"] == "new content 1"
def test_api_document_versions_create_anonymous():
"""Anonymous users should not be allowed to create document versions."""
document = factories.DocumentFactory()
@@ -604,19 +458,15 @@ def test_api_document_versions_update_authenticated_related(via, mock_user_teams
# Delete
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
def test_api_document_versions_delete_anonymous(reach):
def test_api_document_versions_delete_anonymous():
"""Anonymous users should not be allowed to destroy a document version."""
access = factories.UserDocumentAccessFactory(document__link_reach=reach)
access = factories.UserDocumentAccessFactory()
response = APIClient().delete(
f"/api/v1.0/documents/{access.document_id!s}/versions/{access.id!s}/",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)

View File

@@ -2,7 +2,6 @@
Test AI transform API endpoint for users in impress's core app.
"""
import random
from unittest.mock import MagicMock, patch
from django.core.cache import cache
@@ -32,9 +31,6 @@ def ai_settings():
yield
@override_settings(
AI_ALLOW_REACH_FROM=random.choice(["public", "authenticated", "restricted"])
)
@pytest.mark.parametrize(
"reach, role",
[
@@ -61,7 +57,6 @@ def test_api_documents_ai_transform_anonymous_forbidden(reach, role):
}
@override_settings(AI_ALLOW_REACH_FROM="public")
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_transform_anonymous_success(mock_create):
@@ -98,27 +93,6 @@ def test_api_documents_ai_transform_anonymous_success(mock_create):
)
@override_settings(AI_ALLOW_REACH_FROM=random.choice(["authenticated", "restricted"]))
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_transform_anonymous_limited_by_setting(mock_create):
"""
Anonymous users should be able to request AI transform to a document
if the link reach and role permit it.
"""
document = factories.DocumentFactory(link_reach="public", link_role="editor")
answer = '{"answer": "Salut"}'
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=answer))]
)
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
response = APIClient().post(url, {"text": "Hello", "action": "summarize"})
assert response.status_code == 401
@pytest.mark.parametrize(
"reach, role",
[

View File

@@ -2,7 +2,6 @@
Test AI translate API endpoint for users in impress's core app.
"""
import random
from unittest.mock import MagicMock, patch
from django.core.cache import cache
@@ -52,9 +51,6 @@ def test_api_documents_ai_translate_viewset_options_metadata():
}
@override_settings(
AI_ALLOW_REACH_FROM=random.choice(["public", "authenticated", "restricted"])
)
@pytest.mark.parametrize(
"reach, role",
[
@@ -81,7 +77,6 @@ def test_api_documents_ai_translate_anonymous_forbidden(reach, role):
}
@override_settings(AI_ALLOW_REACH_FROM="public")
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_translate_anonymous_success(mock_create):
@@ -118,27 +113,6 @@ def test_api_documents_ai_translate_anonymous_success(mock_create):
)
@override_settings(AI_ALLOW_REACH_FROM=random.choice(["authenticated", "restricted"]))
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_translate_anonymous_limited_by_setting(mock_create):
"""
Anonymous users should be able to request AI translate to a document
if the link reach and role permit it.
"""
document = factories.DocumentFactory(link_reach="public", link_role="editor")
answer = '{"answer": "Salut"}'
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=answer))]
)
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
response = APIClient().post(url, {"text": "Hello", "language": "es"})
assert response.status_code == 401
@pytest.mark.parametrize(
"reach, role",
[

View File

@@ -64,22 +64,12 @@ def test_api_documents_attachment_upload_anonymous_success():
assert response.status_code == 201
pattern = re.compile(rf"^/media/{document.id!s}/attachments/(.*)\.png")
file_path = response.json()["file"]
match = pattern.search(file_path)
match = pattern.search(response.json()["file"])
file_id = match.group(1)
# Validate that file_id is a valid UUID
uuid.UUID(file_id)
# Now, check the metadata of the uploaded file
key = file_path.replace("/media", "")
file_head = default_storage.connection.meta.client.head_object(
Bucket=default_storage.bucket_name, Key=key
)
assert file_head["Metadata"] == {"owner": "None"}
assert file_head["ContentType"] == "image/png"
@pytest.mark.parametrize(
"reach, role",
@@ -216,7 +206,6 @@ def test_api_documents_attachment_upload_success(via, role, mock_user_teams):
Bucket=default_storage.bucket_name, Key=key
)
assert file_head["Metadata"] == {"owner": str(user.id)}
assert file_head["ContentType"] == "image/png"
def test_api_documents_attachment_upload_invalid(client):
@@ -258,18 +247,16 @@ def test_api_documents_attachment_upload_size_limit_exceeded(settings):
@pytest.mark.parametrize(
"name,content,extension,content_type",
"name,content,extension",
[
("test.exe", b"text", "exe", "text/plain"),
("test", b"text", "txt", "text/plain"),
("test.aaaaaa", b"test", "txt", "text/plain"),
("test.txt", PIXEL, "txt", "image/png"),
("test.py", b"#!/usr/bin/python", "py", "text/plain"),
("test.exe", b"text", "exe"),
("test", b"text", "txt"),
("test.aaaaaa", b"test", "txt"),
("test.txt", PIXEL, "txt"),
("test.py", b"#!/usr/bin/python", "py"),
],
)
def test_api_documents_attachment_upload_fix_extension(
name, content, extension, content_type
):
def test_api_documents_attachment_upload_fix_extension(name, content, extension):
"""
A file with no extension or a wrong extension is accepted and the extension
is corrected in storage.
@@ -300,7 +287,6 @@ def test_api_documents_attachment_upload_fix_extension(
Bucket=default_storage.bucket_name, Key=key
)
assert file_head["Metadata"] == {"owner": str(user.id), "is_unsafe": "true"}
assert file_head["ContentType"] == content_type
def test_api_documents_attachment_upload_empty_file():
@@ -349,4 +335,3 @@ def test_api_documents_attachment_upload_unsafe():
Bucket=default_storage.bucket_name, Key=key
)
assert file_head["Metadata"] == {"owner": str(user.id), "is_unsafe": "true"}
assert file_head["ContentType"] == "application/octet-stream"

View File

@@ -1,251 +0,0 @@
"""
Tests for Documents API endpoint in impress's core app: create
"""
from uuid import uuid4
import pytest
from rest_framework.test import APIClient
from core import factories
from core.models import Document, LinkReachChoices, LinkRoleChoices
pytestmark = pytest.mark.django_db
@pytest.mark.parametrize("depth", [1, 2, 3])
@pytest.mark.parametrize("role", LinkRoleChoices.values)
@pytest.mark.parametrize("reach", LinkReachChoices.values)
def test_api_documents_children_create_anonymous(reach, role, depth):
"""Anonymous users should not be allowed to create children documents."""
for i in range(depth):
if i == 0:
document = factories.DocumentFactory(link_reach=reach, link_role=role)
else:
document = factories.DocumentFactory(parent=document)
response = APIClient().post(
f"/api/v1.0/documents/{document.id!s}/children/",
{
"title": "my document",
},
)
assert Document.objects.count() == depth
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
@pytest.mark.parametrize("depth", [1, 2, 3])
@pytest.mark.parametrize(
"reach,role",
[
["restricted", "editor"],
["restricted", "reader"],
["public", "reader"],
["authenticated", "reader"],
],
)
def test_api_documents_children_create_authenticated_forbidden(reach, role, depth):
"""
Authenticated users with no write access on a document should not be allowed
to create a nested document.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
for i in range(depth):
if i == 0:
document = factories.DocumentFactory(link_reach=reach, link_role=role)
else:
document = factories.DocumentFactory(parent=document, link_role="reader")
response = client.post(
f"/api/v1.0/documents/{document.id!s}/children/",
{
"title": "my document",
},
)
assert response.status_code == 403
assert Document.objects.count() == depth
@pytest.mark.parametrize("depth", [1, 2, 3])
@pytest.mark.parametrize(
"reach,role",
[
["public", "editor"],
["authenticated", "editor"],
],
)
def test_api_documents_children_create_authenticated_success(reach, role, depth):
"""
Authenticated users with write access on a document should be able
to create a nested document.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
for i in range(depth):
if i == 0:
document = factories.DocumentFactory(link_reach=reach, link_role=role)
else:
document = factories.DocumentFactory(parent=document, link_role="reader")
response = client.post(
f"/api/v1.0/documents/{document.id!s}/children/",
{
"title": "my child",
},
)
assert response.status_code == 201
child = Document.objects.get(id=response.json()["id"])
assert child.title == "my child"
assert child.link_reach == "restricted"
assert child.accesses.filter(role="owner", user=user).exists()
@pytest.mark.parametrize("depth", [1, 2, 3])
def test_api_documents_children_create_related_forbidden(depth):
"""
Authenticated users with a specific read access on a document should not be allowed
to create a nested document.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
for i in range(depth):
if i == 0:
document = factories.DocumentFactory(link_reach="restricted")
factories.UserDocumentAccessFactory(
user=user, document=document, role="reader"
)
else:
document = factories.DocumentFactory(
parent=document, link_reach="restricted"
)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/children/",
{
"title": "my document",
},
)
assert response.status_code == 403
assert Document.objects.count() == depth
@pytest.mark.parametrize("depth", [1, 2, 3])
@pytest.mark.parametrize("role", ["editor", "administrator", "owner"])
def test_api_documents_children_create_related_success(role, depth):
"""
Authenticated users with a specific write access on a document should be
able to create a nested document.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
for i in range(depth):
if i == 0:
document = factories.DocumentFactory(link_reach="restricted")
factories.UserDocumentAccessFactory(user=user, document=document, role=role)
else:
document = factories.DocumentFactory(
parent=document, link_reach="restricted"
)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/children/",
{
"title": "my child",
},
)
assert response.status_code == 201
child = Document.objects.get(id=response.json()["id"])
assert child.title == "my child"
assert child.link_reach == "restricted"
assert child.accesses.filter(role="owner", user=user).exists()
def test_api_documents_children_create_authenticated_title_null():
"""It should be possible to create several nested documents with a null title."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
parent = factories.DocumentFactory(
title=None, link_reach="authenticated", link_role="editor"
)
factories.DocumentFactory(title=None, parent=parent)
response = client.post(
f"/api/v1.0/documents/{parent.id!s}/children/", {}, format="json"
)
assert response.status_code == 201
assert Document.objects.filter(title__isnull=True).count() == 3
def test_api_documents_children_create_force_id_success():
"""It should be possible to force the document ID when creating a nested document."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
access = factories.UserDocumentAccessFactory(user=user, role="editor")
forced_id = uuid4()
response = client.post(
f"/api/v1.0/documents/{access.document.id!s}/children/",
{
"id": str(forced_id),
"title": "my document",
},
format="json",
)
assert response.status_code == 201
assert Document.objects.count() == 2
assert response.json()["id"] == str(forced_id)
def test_api_documents_children_create_force_id_existing():
"""
It should not be possible to use the ID of an existing document when forcing ID on creation.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
access = factories.UserDocumentAccessFactory(user=user, role="editor")
document = factories.DocumentFactory()
response = client.post(
f"/api/v1.0/documents/{access.document.id!s}/children/",
{
"id": str(document.id),
"title": "my document",
},
format="json",
)
assert response.status_code == 400
assert response.json() == {
"id": ["A document with this ID already exists. You cannot override it."]
}

View File

@@ -1,541 +0,0 @@
"""
Tests for Documents API endpoint in impress's core app: retrieve
"""
import random
from django.contrib.auth.models import AnonymousUser
import pytest
from rest_framework.test import APIClient
from core import factories
pytestmark = pytest.mark.django_db
def test_api_documents_children_list_anonymous_public_standalone():
"""Anonymous users should be allowed to retrieve the children of a public documents."""
document = factories.DocumentFactory(link_reach="public")
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
factories.UserDocumentAccessFactory(document=child1)
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/children/")
assert response.status_code == 200
assert response.json() == {
"count": 2,
"next": None,
"previous": None,
"results": [
{
"abilities": child1.get_abilities(AnonymousUser()),
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"depth": 2,
"excerpt": child1.excerpt,
"id": str(child1.id),
"is_favorite": False,
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 0,
"nb_accesses": 1,
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
},
{
"abilities": child2.get_abilities(AnonymousUser()),
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"depth": 2,
"excerpt": child2.excerpt,
"id": str(child2.id),
"is_favorite": False,
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
"nb_accesses": 0,
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
},
],
}
def test_api_documents_children_list_anonymous_public_parent():
"""
Anonymous users should be allowed to retrieve the children of a document who
has a public ancestor.
"""
grand_parent = factories.DocumentFactory(link_reach="public")
parent = factories.DocumentFactory(
parent=grand_parent, link_reach=random.choice(["authenticated", "restricted"])
)
document = factories.DocumentFactory(
link_reach=random.choice(["authenticated", "restricted"]), parent=parent
)
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
factories.UserDocumentAccessFactory(document=child1)
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/children/")
assert response.status_code == 200
assert response.json() == {
"count": 2,
"next": None,
"previous": None,
"results": [
{
"abilities": child1.get_abilities(AnonymousUser()),
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"depth": 4,
"excerpt": child1.excerpt,
"id": str(child1.id),
"is_favorite": False,
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 0,
"nb_accesses": 1,
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
},
{
"abilities": child2.get_abilities(AnonymousUser()),
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"depth": 4,
"excerpt": child2.excerpt,
"id": str(child2.id),
"is_favorite": False,
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
"nb_accesses": 0,
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
},
],
}
@pytest.mark.parametrize("reach", ["restricted", "authenticated"])
def test_api_documents_children_list_anonymous_restricted_or_authenticated(reach):
"""
Anonymous users should not be able to retrieve children of a document that is not public.
"""
document = factories.DocumentFactory(link_reach=reach)
factories.DocumentFactory.create_batch(2, parent=document)
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/children/")
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
@pytest.mark.parametrize("reach", ["public", "authenticated"])
def test_api_documents_children_list_authenticated_unrelated_public_or_authenticated(
reach,
):
"""
Authenticated users should be able to retrieve the children of a public/authenticated
document to which they are not related.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach)
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
factories.UserDocumentAccessFactory(document=child1)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/children/",
)
assert response.status_code == 200
assert response.json() == {
"count": 2,
"next": None,
"previous": None,
"results": [
{
"abilities": child1.get_abilities(user),
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"depth": 2,
"excerpt": child1.excerpt,
"id": str(child1.id),
"is_favorite": False,
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 0,
"nb_accesses": 1,
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
},
{
"abilities": child2.get_abilities(user),
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"depth": 2,
"excerpt": child2.excerpt,
"id": str(child2.id),
"is_favorite": False,
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
"nb_accesses": 0,
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
},
],
}
@pytest.mark.parametrize("reach", ["public", "authenticated"])
def test_api_documents_children_list_authenticated_public_or_authenticated_parent(
reach,
):
"""
Authenticated users should be allowed to retrieve the children of a document who
has a public or authenticated ancestor.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
grand_parent = factories.DocumentFactory(link_reach=reach)
parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted")
document = factories.DocumentFactory(link_reach="restricted", parent=parent)
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
factories.UserDocumentAccessFactory(document=child1)
response = client.get(f"/api/v1.0/documents/{document.id!s}/children/")
assert response.status_code == 200
assert response.json() == {
"count": 2,
"next": None,
"previous": None,
"results": [
{
"abilities": child1.get_abilities(user),
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"depth": 4,
"excerpt": child1.excerpt,
"id": str(child1.id),
"is_favorite": False,
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 0,
"nb_accesses": 1,
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
},
{
"abilities": child2.get_abilities(user),
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"depth": 4,
"excerpt": child2.excerpt,
"id": str(child2.id),
"is_favorite": False,
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
"nb_accesses": 0,
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
},
],
}
def test_api_documents_children_list_authenticated_unrelated_restricted():
"""
Authenticated users should not be allowed to retrieve the children of a document that is
restricted and to which they are not related.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="restricted")
child1, _child2 = factories.DocumentFactory.create_batch(2, parent=document)
factories.UserDocumentAccessFactory(document=child1)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/children/",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
def test_api_documents_children_list_authenticated_related_direct():
"""
Authenticated users should be allowed to retrieve the children of a document
to which they are directly related whatever the role.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
access = factories.UserDocumentAccessFactory(document=document, user=user)
factories.UserDocumentAccessFactory(document=document)
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
factories.UserDocumentAccessFactory(document=child1)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/children/",
)
assert response.status_code == 200
assert response.json() == {
"count": 2,
"next": None,
"previous": None,
"results": [
{
"abilities": child1.get_abilities(user),
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"depth": 2,
"excerpt": child1.excerpt,
"id": str(child1.id),
"is_favorite": False,
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 0,
"nb_accesses": 3,
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role],
},
{
"abilities": child2.get_abilities(user),
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"depth": 2,
"excerpt": child2.excerpt,
"id": str(child2.id),
"is_favorite": False,
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
"nb_accesses": 2,
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role],
},
],
}
def test_api_documents_children_list_authenticated_related_parent():
"""
Authenticated users should be allowed to retrieve the children of a document if they
are related to one of its ancestors whatever the role.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
grand_parent = factories.DocumentFactory(link_reach="restricted")
parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted")
document = factories.DocumentFactory(parent=parent, link_reach="restricted")
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
factories.UserDocumentAccessFactory(document=child1)
grand_parent_access = factories.UserDocumentAccessFactory(
document=grand_parent, user=user
)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/children/",
)
assert response.status_code == 200
assert response.json() == {
"count": 2,
"next": None,
"previous": None,
"results": [
{
"abilities": child1.get_abilities(user),
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"depth": 4,
"excerpt": child1.excerpt,
"id": str(child1.id),
"is_favorite": False,
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 0,
"nb_accesses": 2,
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [grand_parent_access.role],
},
{
"abilities": child2.get_abilities(user),
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"depth": 4,
"excerpt": child2.excerpt,
"id": str(child2.id),
"is_favorite": False,
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
"nb_accesses": 1,
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [grand_parent_access.role],
},
],
}
def test_api_documents_children_list_authenticated_related_child():
"""
Authenticated users should not be allowed to retrieve all the children of a document
as a result of being related to one of its children.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="restricted")
child1, _child2 = factories.DocumentFactory.create_batch(2, parent=document)
factories.UserDocumentAccessFactory(document=child1, user=user)
factories.UserDocumentAccessFactory(document=document)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/children/",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
def test_api_documents_children_list_authenticated_related_team_none(mock_user_teams):
"""
Authenticated users should not be able to retrieve the children of a restricted document
related to teams in which the user is not.
"""
mock_user_teams.return_value = []
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="restricted")
factories.DocumentFactory.create_batch(2, parent=document)
factories.TeamDocumentAccessFactory(document=document, team="myteam")
response = client.get(f"/api/v1.0/documents/{document.id!s}/children/")
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
def test_api_documents_children_list_authenticated_related_team_members(
mock_user_teams,
):
"""
Authenticated users should be allowed to retrieve the children of a document to which they
are related via a team whatever the role.
"""
mock_user_teams.return_value = ["myteam"]
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="restricted")
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
access = factories.TeamDocumentAccessFactory(document=document, team="myteam")
response = client.get(f"/api/v1.0/documents/{document.id!s}/children/")
# pylint: disable=R0801
assert response.status_code == 200
assert response.json() == {
"count": 2,
"next": None,
"previous": None,
"results": [
{
"abilities": child1.get_abilities(user),
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"depth": 2,
"excerpt": child1.excerpt,
"id": str(child1.id),
"is_favorite": False,
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 0,
"nb_accesses": 1,
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role],
},
{
"abilities": child2.get_abilities(user),
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"depth": 2,
"excerpt": child2.excerpt,
"id": str(child2.id),
"is_favorite": False,
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
"nb_accesses": 1,
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role],
},
],
}

View File

@@ -13,7 +13,6 @@ import pytest
from rest_framework.test import APIClient
from core import factories
from core.api.serializers import ServerCreateDocumentSerializer
from core.models import Document, Invitation, User
from core.services.converter_services import ConversionError, YdocConverter
@@ -21,7 +20,7 @@ pytestmark = pytest.mark.django_db
@pytest.fixture
def mock_convert_md():
def mock_convert_markdown():
"""Mock YdocConverter.convert_markdown to return a converted content."""
with patch.object(
YdocConverter,
@@ -170,11 +169,8 @@ def test_api_documents_create_for_owner_invalid_sub():
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"])
def test_api_documents_create_for_owner_existing(mock_convert_md):
"""
It should be possible to create a document on behalf of a pre-existing user
by passing their sub and email.
"""
def test_api_documents_create_for_owner_existing(mock_convert_markdown):
"""It should be possible to create a document on behalf of a pre-existing user."""
user = factories.UserFactory(language="en-us")
data = {
@@ -193,7 +189,7 @@ def test_api_documents_create_for_owner_existing(mock_convert_md):
assert response.status_code == 201
mock_convert_md.assert_called_once_with("Document content")
mock_convert_markdown.assert_called_once_with("Document content")
document = Document.objects.get()
assert response.json() == {"id": str(document.id)}
@@ -217,10 +213,10 @@ def test_api_documents_create_for_owner_existing(mock_convert_md):
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"])
def test_api_documents_create_for_owner_new_user(mock_convert_md):
def test_api_documents_create_for_owner_new_user(mock_convert_markdown):
"""
It should be possible to create a document on behalf of new users by
passing their unknown sub and email address.
passing only their email address.
"""
data = {
"title": "My Document",
@@ -238,7 +234,7 @@ def test_api_documents_create_for_owner_new_user(mock_convert_md):
assert response.status_code == 201
mock_convert_md.assert_called_once_with("Document content")
mock_convert_markdown.assert_called_once_with("Document content")
document = Document.objects.get()
assert response.json() == {"id": str(document.id)}
@@ -268,190 +264,8 @@ def test_api_documents_create_for_owner_new_user(mock_convert_md):
assert document.creator == user
@override_settings(
SERVER_TO_SERVER_API_TOKENS=["DummyToken"],
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION=True,
)
def test_api_documents_create_for_owner_existing_user_email_no_sub_with_fallback(
mock_convert_md,
):
"""
It should be possible to create a document on behalf of a pre-existing user for
who the sub was not found if the settings allow it. This edge case should not
happen in a healthy OIDC federation but can be usefull if an OIDC provider modifies
users sub on each login for example...
"""
user = factories.UserFactory(language="en-us")
data = {
"title": "My Document",
"content": "Document content",
"sub": "123",
"email": user.email,
}
response = APIClient().post(
"/api/v1.0/documents/create-for-owner/",
data,
format="json",
HTTP_AUTHORIZATION="Bearer DummyToken",
)
assert response.status_code == 201
mock_convert_md.assert_called_once_with("Document content")
document = Document.objects.get()
assert response.json() == {"id": str(document.id)}
assert document.title == "My Document"
assert document.content == "Converted document content"
assert document.creator == user
assert document.accesses.filter(user=user, role="owner").exists()
assert Invitation.objects.exists() is False
assert len(mail.outbox) == 1
email = mail.outbox[0]
assert email.to == [user.email]
assert email.subject == "A new document was created on your behalf!"
email_content = " ".join(email.body.split())
assert "A new document was created on your behalf!" in email_content
assert (
"You have been granted ownership of a new document: My Document"
) in email_content
@override_settings(
SERVER_TO_SERVER_API_TOKENS=["DummyToken"],
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION=False,
OIDC_ALLOW_DUPLICATE_EMAILS=False,
)
def test_api_documents_create_for_owner_existing_user_email_no_sub_no_fallback(
mock_convert_md,
):
"""
When a user does not match an existing sub and fallback to matching on email is
not allowed in settings, it should raise an error if the email is already used by
a registered user and duplicate emails are not allowed.
"""
user = factories.UserFactory()
data = {
"title": "My Document",
"content": "Document content",
"sub": "123",
"email": user.email,
}
response = APIClient().post(
"/api/v1.0/documents/create-for-owner/",
data,
format="json",
HTTP_AUTHORIZATION="Bearer DummyToken",
)
assert response.status_code == 400
assert response.json() == {
"email": [
(
"We couldn't find a user with this sub but the email is already "
"associated with a registered user."
)
]
}
assert mock_convert_md.called is False
assert Document.objects.exists() is False
assert Invitation.objects.exists() is False
assert len(mail.outbox) == 0
@override_settings(
SERVER_TO_SERVER_API_TOKENS=["DummyToken"],
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION=False,
OIDC_ALLOW_DUPLICATE_EMAILS=True,
)
def test_api_documents_create_for_owner_new_user_no_sub_no_fallback_allow_duplicate(
mock_convert_md,
):
"""
When a user does not match an existing sub and fallback to matching on email is
not allowed in settings, it should be possible to create a new user with the same
email as an existing user if the settings allow it (identification is still done
via the sub in this case).
"""
user = factories.UserFactory()
data = {
"title": "My Document",
"content": "Document content",
"sub": "123",
"email": user.email,
}
response = APIClient().post(
"/api/v1.0/documents/create-for-owner/",
data,
format="json",
HTTP_AUTHORIZATION="Bearer DummyToken",
)
assert response.status_code == 201
mock_convert_md.assert_called_once_with("Document content")
document = Document.objects.get()
assert response.json() == {"id": str(document.id)}
assert document.title == "My Document"
assert document.content == "Converted document content"
assert document.creator is None
assert document.accesses.exists() is False
invitation = Invitation.objects.get()
assert invitation.email == user.email
assert invitation.role == "owner"
assert len(mail.outbox) == 1
email = mail.outbox[0]
assert email.to == [user.email]
assert email.subject == "A new document was created on your behalf!"
email_content = " ".join(email.body.split())
assert "A new document was created on your behalf!" in email_content
assert (
"You have been granted ownership of a new document: My Document"
) in email_content
# The creator field on the document should be set when the user is created
user = User.objects.create(email=user.email, password="!")
document.refresh_from_db()
assert document.creator == user
@patch.object(ServerCreateDocumentSerializer, "_send_email_notification")
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"], LANGUAGE_CODE="de-de")
def test_api_documents_create_for_owner_with_default_language(
mock_send, mock_convert_md
):
"""The default language from settings should apply by default."""
data = {
"title": "My Document",
"content": "Document content",
"sub": "123",
"email": "john.doe@example.com",
}
response = APIClient().post(
"/api/v1.0/documents/create-for-owner/",
data,
format="json",
HTTP_AUTHORIZATION="Bearer DummyToken",
)
assert response.status_code == 201
mock_convert_md.assert_called_once_with("Document content")
assert mock_send.call_args[0][3] == "de-de"
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"])
def test_api_documents_create_for_owner_with_custom_language(mock_convert_md):
def test_api_documents_create_for_owner_with_custom_language(mock_convert_markdown):
"""
Test creating a document with a specific language.
Useful if the remote server knows the user's language.
@@ -473,7 +287,7 @@ def test_api_documents_create_for_owner_with_custom_language(mock_convert_md):
assert response.status_code == 201
mock_convert_md.assert_called_once_with("Document content")
mock_convert_markdown.assert_called_once_with("Document content")
assert len(mail.outbox) == 1
email = mail.outbox[0]
@@ -488,7 +302,7 @@ def test_api_documents_create_for_owner_with_custom_language(mock_convert_md):
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"])
def test_api_documents_create_for_owner_with_custom_subject_and_message(
mock_convert_md,
mock_convert_markdown,
):
"""It should be possible to customize the subject and message of the invitation email."""
data = {
@@ -509,7 +323,7 @@ def test_api_documents_create_for_owner_with_custom_subject_and_message(
assert response.status_code == 201
mock_convert_md.assert_called_once_with("Document content")
mock_convert_markdown.assert_called_once_with("Document content")
assert len(mail.outbox) == 1
email = mail.outbox[0]
@@ -522,11 +336,11 @@ def test_api_documents_create_for_owner_with_custom_subject_and_message(
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"])
def test_api_documents_create_for_owner_with_converter_exception(
mock_convert_md,
mock_convert_markdown,
):
"""In case of converter error, a 400 error should be raised."""
"""It should be possible to customize the subject and message of the invitation email."""
mock_convert_md.side_effect = ConversionError("Conversion failed")
mock_convert_markdown.side_effect = ConversionError("Conversion failed")
data = {
"title": "My Document",
@@ -543,33 +357,8 @@ def test_api_documents_create_for_owner_with_converter_exception(
format="json",
HTTP_AUTHORIZATION="Bearer DummyToken",
)
mock_convert_md.assert_called_once_with("Document content")
assert response.status_code == 400
assert response.json() == {"content": ["Could not convert content"]}
mock_convert_markdown.assert_called_once_with("Document content")
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"])
def test_api_documents_create_for_owner_with_empty_content():
"""The content should not be empty or a 400 error should be raised."""
data = {
"title": "My Document",
"content": " ",
"sub": "123",
"email": "john.doe@example.com",
}
response = APIClient().post(
"/api/v1.0/documents/create-for-owner/",
data,
format="json",
HTTP_AUTHORIZATION="Bearer DummyToken",
)
assert response.status_code == 400
assert response.json() == {
"content": [
"This field may not be blank.",
],
}
assert response.status_code == 500
assert response.json() == {"detail": "could not convert content"}

View File

@@ -77,37 +77,6 @@ def test_api_documents_delete_authenticated_not_owner(via, role, mock_user_teams
assert models.Document.objects.count() == 2
@pytest.mark.parametrize("depth", [1, 2, 3])
def test_api_documents_delete_authenticated_owner_of_ancestor(depth):
"""
Authenticated users should not be able to delete a document for which
they are only owner of an ancestor.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
documents = []
for i in range(depth):
documents.append(
factories.UserDocumentAccessFactory(role="owner", user=user).document
if i == 0
else factories.DocumentFactory(parent=documents[-1])
)
assert models.Document.objects.count() == depth
response = client.delete(
f"/api/v1.0/documents/{documents[-1].id}/",
)
assert response.status_code == 204
# Make sure it is only a soft delete
assert models.Document.objects.count() == depth
assert models.Document.objects.filter(deleted_at__isnull=True).count() == depth - 1
assert models.Document.objects.filter(deleted_at__isnull=False).count() == 1
@pytest.mark.parametrize("via", VIA)
def test_api_documents_delete_authenticated_owner(via, mock_user_teams):
"""
@@ -132,8 +101,4 @@ def test_api_documents_delete_authenticated_owner(via, mock_user_teams):
)
assert response.status_code == 204
# Make sure it is only a soft delete
assert models.Document.objects.count() == 1
assert models.Document.objects.filter(deleted_at__isnull=True).exists() is False
assert models.Document.objects.filter(deleted_at__isnull=False).count() == 1
assert models.Document.objects.exists() is False

View File

@@ -2,11 +2,10 @@
Tests for Documents API endpoint in impress's core app: list
"""
import operator
import random
from datetime import timedelta
from unittest import mock
from django.utils import timezone
from urllib.parse import urlencode
import pytest
from faker import Faker
@@ -24,7 +23,7 @@ pytestmark = pytest.mark.django_db
def test_api_documents_list_anonymous(reach, role):
"""
Anonymous users should not be allowed to list documents whatever the
link reach and link role
link reach and the role
"""
factories.DocumentFactory(link_reach=reach, link_role=role)
@@ -38,16 +37,16 @@ def test_api_documents_list_anonymous(reach, role):
def test_api_documents_list_format():
"""Validate the format of documents as returned by the list view."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
other_users = factories.UserFactory.create_batch(3)
document = factories.DocumentFactory(
users=factories.UserFactory.create_batch(2),
users=[user, *factories.UserFactory.create_batch(2)],
favorited_by=[user, *other_users],
link_traces=other_users,
)
access = factories.UserDocumentAccessFactory(document=document, user=user)
response = client.get("/api/v1.0/documents/")
@@ -63,23 +62,18 @@ def test_api_documents_list_format():
assert results[0] == {
"id": str(document.id),
"abilities": document.get_abilities(user),
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"depth": 1,
"excerpt": document.excerpt,
"is_favorite": True,
"link_reach": document.link_reach,
"link_role": document.link_role,
"nb_accesses": 3,
"numchild": 0,
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role],
}
# pylint: disable=too-many-locals
def test_api_documents_list_authenticated_direct(django_assert_num_queries):
"""
Authenticated users should be able to list documents they are a direct
@@ -87,10 +81,11 @@ def test_api_documents_list_authenticated_direct(django_assert_num_queries):
than restricted.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document1, document2 = [
documents = [
access.document
for access in factories.UserDocumentAccessFactory.create_batch(2, user=user)
]
@@ -100,64 +95,16 @@ def test_api_documents_list_authenticated_direct(django_assert_num_queries):
for role in models.LinkRoleChoices:
factories.DocumentFactory(link_reach=reach, link_role=role)
# Children of visible documents should not get listed even with a specific access
factories.DocumentFactory(parent=document1)
expected_ids = {str(document.id) for document in documents}
child1_with_access = factories.DocumentFactory(parent=document1)
factories.UserDocumentAccessFactory(user=user, document=child1_with_access)
middle_document = factories.DocumentFactory(parent=document2)
child2_with_access = factories.DocumentFactory(parent=middle_document)
factories.UserDocumentAccessFactory(user=user, document=child2_with_access)
# Children of hidden documents should get listed when visible by the logged-in user
hidden_root = factories.DocumentFactory()
child3_with_access = factories.DocumentFactory(parent=hidden_root)
factories.UserDocumentAccessFactory(user=user, document=child3_with_access)
child4_with_access = factories.DocumentFactory(parent=hidden_root)
factories.UserDocumentAccessFactory(user=user, document=child4_with_access)
# Documents that are soft deleted and children of a soft deleted document should not be listed
soft_deleted_document = factories.DocumentFactory(users=[user])
child_of_soft_deleted_document = factories.DocumentFactory(
users=[user],
parent=soft_deleted_document,
)
factories.DocumentFactory(users=[user], parent=child_of_soft_deleted_document)
soft_deleted_document.soft_delete()
# Documents that are permanently deleted and children of a permanently deleted
# document should not be listed
permanently_deleted_document = factories.DocumentFactory(users=[user])
child_of_permanently_deleted_document = factories.DocumentFactory(
users=[user], parent=permanently_deleted_document
)
factories.DocumentFactory(
users=[user], parent=child_of_permanently_deleted_document
)
fourty_days_ago = timezone.now() - timedelta(days=40)
with mock.patch("django.utils.timezone.now", return_value=fourty_days_ago):
permanently_deleted_document.soft_delete()
expected_ids = {
str(document1.id),
str(document2.id),
str(child3_with_access.id),
str(child4_with_access.id),
}
with django_assert_num_queries(8):
response = client.get("/api/v1.0/documents/")
# nb_accesses should now be cached
with django_assert_num_queries(4):
with django_assert_num_queries(3):
response = client.get("/api/v1.0/documents/")
assert response.status_code == 200
results = response.json()["results"]
results_ids = {result["id"] for result in results}
assert expected_ids == results_ids
assert len(results) == 2
results_id = {result["id"] for result in results}
assert expected_ids == results_id
def test_api_documents_list_authenticated_via_team(
@@ -185,11 +132,7 @@ def test_api_documents_list_authenticated_via_team(
expected_ids = {str(document.id) for document in documents_team1 + documents_team2}
with django_assert_num_queries(9):
response = client.get("/api/v1.0/documents/")
# nb_accesses should now be cached
with django_assert_num_queries(4):
with django_assert_num_queries(3):
response = client.get("/api/v1.0/documents/")
assert response.status_code == 200
@@ -218,12 +161,10 @@ def test_api_documents_list_authenticated_link_reach_restricted(
other_document = factories.DocumentFactory(link_reach="public")
models.LinkTrace.objects.create(document=other_document, user=user)
with django_assert_num_queries(5):
response = client.get("/api/v1.0/documents/")
# nb_accesses should now be cached
with django_assert_num_queries(4):
response = client.get("/api/v1.0/documents/")
with django_assert_num_queries(3):
response = client.get(
"/api/v1.0/documents/",
)
assert response.status_code == 200
results = response.json()["results"]
@@ -245,37 +186,21 @@ def test_api_documents_list_authenticated_link_reach_public_or_authenticated(
client = APIClient()
client.force_login(user)
document1, document2 = [
documents = [
factories.DocumentFactory(link_traces=[user], link_reach=reach)
for reach in models.LinkReachChoices
if reach != "restricted"
]
factories.DocumentFactory(
link_reach=random.choice(["public", "authenticated"]),
link_traces=[user],
parent=document1,
)
expected_ids = {str(document.id) for document in documents}
hidden_document = factories.DocumentFactory(
link_reach=random.choice(["public", "authenticated"])
)
visible_child = factories.DocumentFactory(
link_traces=[user],
link_reach=random.choice(["public", "authenticated"]),
parent=hidden_document,
)
expected_ids = {str(document1.id), str(document2.id), str(visible_child.id)}
with django_assert_num_queries(7):
response = client.get("/api/v1.0/documents/")
# nb_accesses should now be cached
with django_assert_num_queries(4):
response = client.get("/api/v1.0/documents/")
with django_assert_num_queries(3):
response = client.get(
"/api/v1.0/documents/",
)
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 2
results_id = {result["id"] for result in results}
assert expected_ids == results_id
@@ -362,11 +287,7 @@ def test_api_documents_list_favorites_no_extra_queries(django_assert_num_queries
factories.DocumentFactory.create_batch(2, users=[user])
url = "/api/v1.0/documents/"
with django_assert_num_queries(9):
response = client.get(url)
# nb_accesses should now be cached
with django_assert_num_queries(4):
with django_assert_num_queries(3):
response = client.get(url)
assert response.status_code == 200
@@ -379,7 +300,7 @@ def test_api_documents_list_favorites_no_extra_queries(django_assert_num_queries
for document in special_documents:
models.DocumentFavorite.objects.create(document=document, user=user)
with django_assert_num_queries(4):
with django_assert_num_queries(3):
response = client.get(url)
assert response.status_code == 200
@@ -393,3 +314,361 @@ def test_api_documents_list_favorites_no_extra_queries(django_assert_num_queries
assert result["is_favorite"] is True
else:
assert result["is_favorite"] is False
def test_api_documents_list_filter_and_access_rights():
"""Filtering on querystring parameters should respect access rights."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
other_user = factories.UserFactory()
def random_favorited_by():
return random.choice([[], [user], [other_user]])
# Documents that should be listed to this user
listed_documents = [
factories.DocumentFactory(
link_reach="public",
link_traces=[user],
favorited_by=random_favorited_by(),
creator=random.choice([user, other_user]),
),
factories.DocumentFactory(
link_reach="authenticated",
link_traces=[user],
favorited_by=random_favorited_by(),
creator=random.choice([user, other_user]),
),
factories.DocumentFactory(
link_reach="restricted",
users=[user],
favorited_by=random_favorited_by(),
creator=random.choice([user, other_user]),
),
]
listed_ids = [str(doc.id) for doc in listed_documents]
word_list = [word for doc in listed_documents for word in doc.title.split(" ")]
# Documents that should not be listed to this user
factories.DocumentFactory(
link_reach="public",
favorited_by=random_favorited_by(),
creator=random.choice([user, other_user]),
)
factories.DocumentFactory(
link_reach="authenticated",
favorited_by=random_favorited_by(),
creator=random.choice([user, other_user]),
)
factories.DocumentFactory(
link_reach="restricted",
favorited_by=random_favorited_by(),
creator=random.choice([user, other_user]),
)
factories.DocumentFactory(
link_reach="restricted",
link_traces=[user],
favorited_by=random_favorited_by(),
creator=random.choice([user, other_user]),
)
filters = {
"link_reach": random.choice([None, *models.LinkReachChoices.values]),
"title": random.choice([None, *word_list]),
"favorite": random.choice([None, True, False]),
"creator": random.choice([None, user, other_user]),
"ordering": random.choice(
[
None,
"created_at",
"-created_at",
"is_favorite",
"-is_favorite",
"nb_accesses",
"-nb_accesses",
"title",
"-title",
"updated_at",
"-updated_at",
]
),
}
query_params = {key: value for key, value in filters.items() if value is not None}
querystring = urlencode(query_params)
response = client.get(f"/api/v1.0/documents/?{querystring:s}")
assert response.status_code == 200
results = response.json()["results"]
# Ensure all documents in results respect expected access rights
for result in results:
assert result["id"] in listed_ids
# Filters: ordering
def test_api_documents_list_ordering_default():
"""Documents should be ordered by descending "updated_at" by default"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(5, users=[user])
response = client.get("/api/v1.0/documents/")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 5
# Check that results are sorted by descending "updated_at" as expected
for i in range(4):
assert operator.ge(results[i]["updated_at"], results[i + 1]["updated_at"])
def test_api_documents_list_ordering_by_fields():
"""It should be possible to order by several fields"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(5, users=[user])
for parameter in [
"created_at",
"-created_at",
"is_favorite",
"-is_favorite",
"nb_accesses",
"-nb_accesses",
"title",
"-title",
"updated_at",
"-updated_at",
]:
is_descending = parameter.startswith("-")
field = parameter.lstrip("-")
querystring = f"?ordering={parameter}"
response = client.get(f"/api/v1.0/documents/{querystring:s}")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 5
# Check that results are sorted by the field in querystring as expected
compare = operator.ge if is_descending else operator.le
for i in range(4):
assert compare(results[i][field], results[i + 1][field])
# Filters: is_creator_me
def test_api_documents_list_filter_is_creator_me_true():
"""
Authenticated users should be able to filter documents they created.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(3, users=[user], creator=user)
factories.DocumentFactory.create_batch(2, users=[user])
response = client.get("/api/v1.0/documents/?is_creator_me=true")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 3
# Ensure all results are created by the current user
for result in results:
assert result["creator"] == str(user.id)
def test_api_documents_list_filter_is_creator_me_false():
"""
Authenticated users should be able to filter documents created by others.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(3, users=[user], creator=user)
factories.DocumentFactory.create_batch(2, users=[user])
response = client.get("/api/v1.0/documents/?is_creator_me=false")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 2
# Ensure all results are created by other users
for result in results:
assert result["creator"] != str(user.id)
def test_api_documents_list_filter_is_creator_me_invalid():
"""Filtering with an invalid `is_creator_me` value should do nothing."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(3, users=[user], creator=user)
factories.DocumentFactory.create_batch(2, users=[user])
response = client.get("/api/v1.0/documents/?is_creator_me=invalid")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 5
# Filters: is_favorite
def test_api_documents_list_filter_is_favorite_true():
"""
Authenticated users should be able to filter documents they marked as favorite.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(3, users=[user], favorited_by=[user])
factories.DocumentFactory.create_batch(2, users=[user])
response = client.get("/api/v1.0/documents/?is_favorite=true")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 3
# Ensure all results are marked as favorite by the current user
for result in results:
assert result["is_favorite"] is True
def test_api_documents_list_filter_is_favorite_false():
"""
Authenticated users should be able to filter documents they didn't mark as favorite.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(3, users=[user], favorited_by=[user])
factories.DocumentFactory.create_batch(2, users=[user])
response = client.get("/api/v1.0/documents/?is_favorite=false")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 2
# Ensure all results are not marked as favorite by the current user
for result in results:
assert result["is_favorite"] is False
def test_api_documents_list_filter_is_favorite_invalid():
"""Filtering with an invalid `is_favorite` value should do nothing."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(3, users=[user], favorited_by=[user])
factories.DocumentFactory.create_batch(2, users=[user])
response = client.get("/api/v1.0/documents/?is_favorite=invalid")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 5
# Filters: link_reach
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
def test_api_documents_list_filter_link_reach(reach):
"""Authenticated users should be able to filter documents by link reach."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(5, users=[user])
response = client.get(f"/api/v1.0/documents/?link_reach={reach:s}")
assert response.status_code == 200
results = response.json()["results"]
# Ensure all results have the chosen link reach
for result in results:
assert result["link_reach"] == reach
def test_api_documents_list_filter_link_reach_invalid():
"""Filtering with an invalid `link_reach` value should raise an error."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(3, users=[user])
response = client.get("/api/v1.0/documents/?link_reach=invalid")
assert response.status_code == 400
assert response.json() == {
"link_reach": [
"Select a valid choice. invalid is not one of the available choices."
]
}
# Filters: title
@pytest.mark.parametrize(
"query,nb_results",
[
("Project Alpha", 1), # Exact match
("project", 2), # Partial match (case-insensitive)
("Guide", 1), # Word match within a title
("Special", 0), # No match (nonexistent keyword)
("2024", 2), # Match by numeric keyword
("", 5), # Empty string
],
)
def test_api_documents_list_filter_title(query, nb_results):
"""Authenticated users should be able to search documents by their title."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Create documents with predefined titles
titles = [
"Project Alpha Documentation",
"Project Beta Overview",
"User Guide",
"Financial Report 2024",
"Annual Review 2024",
]
for title in titles:
factories.DocumentFactory(title=title, users=[user])
# Perform the search query
response = client.get(f"/api/v1.0/documents/?title={query:s}")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == nb_results
# Ensure all results contain the query in their title
for result in results:
assert query.lower().strip() in result["title"].lower()

View File

@@ -1,356 +0,0 @@
"""
Tests for Documents API endpoint in impress's core app: list
"""
import operator
import random
from urllib.parse import urlencode
import pytest
from faker import Faker
from rest_framework.test import APIClient
from core import factories, models
fake = Faker()
pytestmark = pytest.mark.django_db
def test_api_documents_list_filter_and_access_rights():
"""Filtering on querystring parameters should respect access rights."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
other_user = factories.UserFactory()
def random_favorited_by():
return random.choice([[], [user], [other_user]])
# Documents that should be listed to this user
listed_documents = [
factories.DocumentFactory(
link_reach="public",
link_traces=[user],
favorited_by=random_favorited_by(),
creator=random.choice([user, other_user]),
),
factories.DocumentFactory(
link_reach="authenticated",
link_traces=[user],
favorited_by=random_favorited_by(),
creator=random.choice([user, other_user]),
),
factories.DocumentFactory(
link_reach="restricted",
users=[user],
favorited_by=random_favorited_by(),
creator=random.choice([user, other_user]),
),
]
listed_ids = [str(doc.id) for doc in listed_documents]
word_list = [word for doc in listed_documents for word in doc.title.split(" ")]
# Documents that should not be listed to this user
factories.DocumentFactory(
link_reach="public",
favorited_by=random_favorited_by(),
creator=random.choice([user, other_user]),
)
factories.DocumentFactory(
link_reach="authenticated",
favorited_by=random_favorited_by(),
creator=random.choice([user, other_user]),
)
factories.DocumentFactory(
link_reach="restricted",
favorited_by=random_favorited_by(),
creator=random.choice([user, other_user]),
)
factories.DocumentFactory(
link_reach="restricted",
link_traces=[user],
favorited_by=random_favorited_by(),
creator=random.choice([user, other_user]),
)
filters = {
"link_reach": random.choice([None, *models.LinkReachChoices.values]),
"title": random.choice([None, *word_list]),
"favorite": random.choice([None, True, False]),
"creator": random.choice([None, user, other_user]),
"ordering": random.choice(
[
None,
"created_at",
"-created_at",
"is_favorite",
"-is_favorite",
"title",
"-title",
"updated_at",
"-updated_at",
]
),
}
query_params = {key: value for key, value in filters.items() if value is not None}
querystring = urlencode(query_params)
response = client.get(f"/api/v1.0/documents/?{querystring:s}")
assert response.status_code == 200
results = response.json()["results"]
# Ensure all documents in results respect expected access rights
for result in results:
assert result["id"] in listed_ids
# Filters: ordering
def test_api_documents_list_ordering_default():
"""Documents should be ordered by descending "updated_at" by default"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(5, users=[user])
response = client.get("/api/v1.0/documents/")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 5
# Check that results are sorted by descending "updated_at" as expected
for i in range(4):
assert operator.ge(results[i]["updated_at"], results[i + 1]["updated_at"])
def test_api_documents_list_ordering_by_fields():
"""It should be possible to order by several fields"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(5, users=[user])
for parameter in [
"created_at",
"-created_at",
"is_favorite",
"-is_favorite",
"title",
"-title",
"updated_at",
"-updated_at",
]:
is_descending = parameter.startswith("-")
field = parameter.lstrip("-")
querystring = f"?ordering={parameter}"
response = client.get(f"/api/v1.0/documents/{querystring:s}")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 5
# Check that results are sorted by the field in querystring as expected
compare = operator.ge if is_descending else operator.le
for i in range(4):
assert compare(results[i][field], results[i + 1][field])
# Filters: unknown field
def test_api_documents_list_filter_unknown_field():
"""
Trying to filter by an unknown field should raise a 400 error.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory()
expected_ids = {
str(document.id)
for document in factories.DocumentFactory.create_batch(2, users=[user])
}
response = client.get("/api/v1.0/documents/?unknown=true")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 2
assert {result["id"] for result in results} == expected_ids
# Filters: is_creator_me
def test_api_documents_list_filter_is_creator_me_true():
"""
Authenticated users should be able to filter documents they created.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(3, users=[user], creator=user)
factories.DocumentFactory.create_batch(2, users=[user])
response = client.get("/api/v1.0/documents/?is_creator_me=true")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 3
# Ensure all results are created by the current user
for result in results:
assert result["creator"] == str(user.id)
def test_api_documents_list_filter_is_creator_me_false():
"""
Authenticated users should be able to filter documents created by others.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(3, users=[user], creator=user)
factories.DocumentFactory.create_batch(2, users=[user])
response = client.get("/api/v1.0/documents/?is_creator_me=false")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 2
# Ensure all results are created by other users
for result in results:
assert result["creator"] != str(user.id)
def test_api_documents_list_filter_is_creator_me_invalid():
"""Filtering with an invalid `is_creator_me` value should do nothing."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(3, users=[user], creator=user)
factories.DocumentFactory.create_batch(2, users=[user])
response = client.get("/api/v1.0/documents/?is_creator_me=invalid")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 5
# Filters: is_favorite
def test_api_documents_list_filter_is_favorite_true():
"""
Authenticated users should be able to filter documents they marked as favorite.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(3, users=[user], favorited_by=[user])
factories.DocumentFactory.create_batch(2, users=[user])
response = client.get("/api/v1.0/documents/?is_favorite=true")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 3
# Ensure all results are marked as favorite by the current user
for result in results:
assert result["is_favorite"] is True
def test_api_documents_list_filter_is_favorite_false():
"""
Authenticated users should be able to filter documents they didn't mark as favorite.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(3, users=[user], favorited_by=[user])
factories.DocumentFactory.create_batch(2, users=[user])
response = client.get("/api/v1.0/documents/?is_favorite=false")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 2
# Ensure all results are not marked as favorite by the current user
for result in results:
assert result["is_favorite"] is False
def test_api_documents_list_filter_is_favorite_invalid():
"""Filtering with an invalid `is_favorite` value should do nothing."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(3, users=[user], favorited_by=[user])
factories.DocumentFactory.create_batch(2, users=[user])
response = client.get("/api/v1.0/documents/?is_favorite=invalid")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 5
# Filters: title
@pytest.mark.parametrize(
"query,nb_results",
[
("Project Alpha", 1), # Exact match
("project", 2), # Partial match (case-insensitive)
("Guide", 1), # Word match within a title
("Special", 0), # No match (nonexistent keyword)
("2024", 2), # Match by numeric keyword
("", 5), # Empty string
],
)
def test_api_documents_list_filter_title(query, nb_results):
"""Authenticated users should be able to search documents by their title."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Create documents with predefined titles
titles = [
"Project Alpha Documentation",
"Project Beta Overview",
"User Guide",
"Financial Report 2024",
"Annual Review 2024",
]
for title in titles:
parent = factories.DocumentFactory() if random.choice([True, False]) else None
factories.DocumentFactory(title=title, users=[user], parent=parent)
# Perform the search query
response = client.get(f"/api/v1.0/documents/?title={query:s}")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == nb_results
# Ensure all results contain the query in their title
for result in results:
assert query.lower().strip() in result["title"].lower()

View File

@@ -1,339 +0,0 @@
"""
Test moving documents within the document tree via an detail action API endpoint.
"""
import random
from uuid import uuid4
from django.utils import timezone
import pytest
from rest_framework.test import APIClient
from core import enums, factories, models
pytestmark = pytest.mark.django_db
def test_api_documents_move_anonymous_user():
"""Anonymous users should not be able to move documents."""
document = factories.DocumentFactory()
target = factories.DocumentFactory()
response = APIClient().post(
f"/api/v1.0/documents/{document.id!s}/move/",
data={"target_document_id": str(target.id)},
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
@pytest.mark.parametrize("role", [None, "reader", "editor"])
def test_api_documents_move_authenticated_document_no_permission(role):
"""
Authenticated users should not be able to move documents with insufficient
permissions on the origin document.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
target = factories.UserDocumentAccessFactory(user=user, role="owner").document
if role:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/move/",
data={"target_document_id": str(target.id)},
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
def test_api_documents_move_invalid_target_string():
"""Test for moving a document to an invalid target as a random string."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.UserDocumentAccessFactory(user=user, role="owner").document
response = client.post(
f"/api/v1.0/documents/{document.id!s}/move/",
data={"target_document_id": "non-existent-id"},
)
assert response.status_code == 400
assert response.json() == {"target_document_id": ["Must be a valid UUID."]}
def test_api_documents_move_invalid_target_uuid():
"""Test for moving a document to an invalid target that looks like a UUID."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.UserDocumentAccessFactory(user=user, role="owner").document
response = client.post(
f"/api/v1.0/documents/{document.id!s}/move/",
data={"target_document_id": str(uuid4())},
)
assert response.status_code == 400
assert response.json() == {
"target_document_id": "Target parent document does not exist."
}
def test_api_documents_move_invalid_position():
"""Test moving a document to an invalid position."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.UserDocumentAccessFactory(user=user, role="owner").document
target = factories.UserDocumentAccessFactory(user=user, role="owner").document
response = client.post(
f"/api/v1.0/documents/{document.id!s}/move/",
data={
"target_document_id": str(target.id),
"position": "invalid-position",
},
)
assert response.status_code == 400
assert response.json() == {
"position": ['"invalid-position" is not a valid choice.']
}
@pytest.mark.parametrize("position", enums.MoveNodePositionChoices.values)
@pytest.mark.parametrize("target_parent_role", models.RoleChoices.values)
@pytest.mark.parametrize("target_role", models.RoleChoices.values)
def test_api_documents_move_authenticated_target_roles_mocked(
target_role, target_parent_role, position
):
"""
Authenticated users with insufficient permissions on the target document (or its
parent depending on the position chosen), should not be allowed to move documents.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
power_roles = ["administrator", "owner"]
document = factories.DocumentFactory(users=[(user, random.choice(power_roles))])
children = factories.DocumentFactory.create_batch(3, parent=document)
target_parent = factories.DocumentFactory(users=[(user, target_parent_role)])
sibling1, target, sibling2 = factories.DocumentFactory.create_batch(
3, parent=target_parent
)
models.DocumentAccess.objects.create(document=target, user=user, role=target_role)
target_children = factories.DocumentFactory.create_batch(2, parent=target)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/move/",
data={"target_document_id": str(target.id), "position": position},
)
document.refresh_from_db()
if (
position in ["first-child", "last-child"]
and (target_role in power_roles or target_parent_role in power_roles)
) or (
position in ["first-sibling", "last-sibling", "left", "right"]
and target_parent_role in power_roles
):
assert response.status_code == 200
assert response.json() == {"message": "Document moved successfully."}
match position:
case "first-child":
assert list(target.get_children()) == [document, *target_children]
case "last-child":
assert list(target.get_children()) == [*target_children, document]
case "first-sibling":
assert list(target.get_siblings()) == [
document,
sibling1,
target,
sibling2,
]
case "last-sibling":
assert list(target.get_siblings()) == [
sibling1,
target,
sibling2,
document,
]
case "left":
assert list(target.get_siblings()) == [
sibling1,
document,
target,
sibling2,
]
case "right":
assert list(target.get_siblings()) == [
sibling1,
target,
document,
sibling2,
]
case _:
raise ValueError(f"Invalid position: {position}")
# Verify that the document's children have also been moved
assert list(document.get_children()) == children
else:
assert response.status_code == 400
assert (
"You do not have permission to move documents"
in response.json()["target_document_id"]
)
assert document.is_root() is True
def test_api_documents_move_authenticated_deleted_document():
"""
It should not be possible to move a deleted document or its descendants, even
for an owner.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(
users=[(user, "owner")], deleted_at=timezone.now()
)
child = factories.DocumentFactory(parent=document, users=[(user, "owner")])
target = factories.DocumentFactory(users=[(user, "owner")])
# Try moving the deleted document
response = client.post(
f"/api/v1.0/documents/{document.id!s}/move/",
data={"target_document_id": str(target.id)},
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
# Verify that the document has not moved
document.refresh_from_db()
assert document.is_root() is True
# Try moving the child of the deleted document
response = client.post(
f"/api/v1.0/documents/{child.id!s}/move/",
data={"target_document_id": str(target.id)},
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
# Verify that the child has not moved
child.refresh_from_db()
assert child.is_child_of(document) is True
@pytest.mark.parametrize(
"position",
enums.MoveNodePositionChoices.values,
)
def test_api_documents_move_authenticated_deleted_target_as_child(position):
"""
It should not be possible to move a document as a child of a deleted target
even for a owner.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[(user, "owner")])
target = factories.DocumentFactory(
users=[(user, "owner")], deleted_at=timezone.now()
)
child = factories.DocumentFactory(parent=target, users=[(user, "owner")])
# Try moving the document to the deleted target
response = client.post(
f"/api/v1.0/documents/{document.id!s}/move/",
data={"target_document_id": str(target.id), "position": position},
)
assert response.status_code == 400
assert response.json() == {
"target_document_id": "Target parent document does not exist."
}
# Verify that the document has not moved
document.refresh_from_db()
assert document.is_root() is True
# Try moving the document to the child of the deleted target
response = client.post(
f"/api/v1.0/documents/{document.id!s}/move/",
data={"target_document_id": str(child.id), "position": position},
)
assert response.status_code == 400
assert response.json() == {
"target_document_id": "Target parent document does not exist."
}
# Verify that the document has not moved
document.refresh_from_db()
assert document.is_root() is True
@pytest.mark.parametrize(
"position",
["first-sibling", "last-sibling", "left", "right"],
)
def test_api_documents_move_authenticated_deleted_target_as_sibling(position):
"""
It should not be possible to move a document as a sibling of a deleted target document
if the user has no rigths on its parent.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[(user, "owner")])
target_parent = factories.DocumentFactory(
users=[(user, "owner")], deleted_at=timezone.now()
)
target = factories.DocumentFactory(users=[(user, "owner")], parent=target_parent)
# Try moving the document as a sibling of the target
response = client.post(
f"/api/v1.0/documents/{document.id!s}/move/",
data={"target_document_id": str(target.id), "position": position},
)
assert response.status_code == 400
assert response.json() == {
"target_document_id": "Target parent document does not exist."
}
# Verify that the document has not moved
document.refresh_from_db()
assert document.is_root() is True

View File

@@ -1,126 +0,0 @@
"""
Test restoring documents after a soft delete via the detail action API endpoint.
"""
from datetime import timedelta
from django.utils import timezone
import pytest
from rest_framework.test import APIClient
from core import factories
pytestmark = pytest.mark.django_db
def test_api_documents_restore_anonymous_user():
"""Anonymous users should not be able to restore deleted documents."""
now = timezone.now() - timedelta(days=15)
document = factories.DocumentFactory(deleted_at=now)
response = APIClient().post(f"/api/v1.0/documents/{document.id!s}/restore/")
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
document.refresh_from_db()
assert document.deleted_at == now
assert document.ancestors_deleted_at == now
@pytest.mark.parametrize("role", [None, "reader", "editor", "administrator"])
def test_api_documents_restore_authenticated_no_permission(role):
"""
Authenticated users who are not owners of a deleted document should
not be allowed to restore it.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
now = timezone.now() - timedelta(days=15)
document = factories.DocumentFactory(
deleted_at=now, link_reach="public", link_role="editor"
)
if role:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
response = client.post(f"/api/v1.0/documents/{document.id!s}/restore/")
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
document.refresh_from_db()
assert document.deleted_at == now
assert document.ancestors_deleted_at == now
def test_api_documents_restore_authenticated_owner_success():
"""The owner of a deleted document should be able to restore it."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
now = timezone.now() - timedelta(days=15)
document = factories.DocumentFactory(deleted_at=now)
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
response = client.post(f"/api/v1.0/documents/{document.id!s}/restore/")
assert response.status_code == 200
assert response.json() == {"detail": "Document has been successfully restored."}
document.refresh_from_db()
assert document.deleted_at is None
assert document.ancestors_deleted_at is None
def test_api_documents_restore_authenticated_owner_ancestor_deleted():
"""
The restored document should still be marked as deleted if one of its
ancestors is soft deleted as well.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
grand_parent = factories.DocumentFactory()
parent = factories.DocumentFactory(parent=grand_parent)
document = factories.DocumentFactory(parent=parent)
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
document.soft_delete()
document_deleted_at = document.deleted_at
assert document_deleted_at is not None
grand_parent.soft_delete()
grand_parent_deleted_at = grand_parent.deleted_at
assert grand_parent_deleted_at is not None
response = client.post(f"/api/v1.0/documents/{document.id!s}/restore/")
assert response.status_code == 200
assert response.json() == {"detail": "Document has been successfully restored."}
document.refresh_from_db()
assert document.deleted_at is None
# document is still marked as deleted
assert document.ancestors_deleted_at == grand_parent_deleted_at
assert grand_parent_deleted_at > document_deleted_at
def test_api_documents_restore_authenticated_owner_expired():
"""It should not be possible to restore a document beyond the allowed time limit."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
now = timezone.now() - timedelta(days=40)
document = factories.DocumentFactory(deleted_at=now)
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
response = client.post(f"/api/v1.0/documents/{document.id!s}/restore/")
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}

View File

@@ -2,21 +2,16 @@
Tests for Documents API endpoint in impress's core app: retrieve
"""
import random
from datetime import timedelta
from unittest import mock
from django.utils import timezone
import pytest
from rest_framework.test import APIClient
from core import factories, models
from core.api import serializers
pytestmark = pytest.mark.django_db
def test_api_documents_retrieve_anonymous_public_standalone():
def test_api_documents_retrieve_anonymous_public():
"""Anonymous users should be allowed to retrieve public documents."""
document = factories.DocumentFactory(link_reach="public")
@@ -28,11 +23,9 @@ def test_api_documents_retrieve_anonymous_public_standalone():
"abilities": {
"accesses_manage": False,
"accesses_view": False,
"ai_transform": False,
"ai_translate": False,
"ai_transform": document.link_role == "editor",
"ai_translate": document.link_role == "editor",
"attachment_upload": document.link_role == "editor",
"children_create": False,
"children_list": True,
"collaboration_auth": True,
"destroy": False,
# Anonymous user can't favorite a document even with read access
@@ -40,9 +33,7 @@ def test_api_documents_retrieve_anonymous_public_standalone():
"invite_owner": False,
"link_configuration": False,
"media_auth": True,
"move": False,
"partial_update": document.link_role == "editor",
"restore": False,
"retrieve": True,
"update": document.link_role == "editor",
"versions_destroy": False,
@@ -52,90 +43,12 @@ def test_api_documents_retrieve_anonymous_public_standalone():
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"depth": 1,
"excerpt": document.excerpt,
"is_favorite": False,
"link_reach": "public",
"link_role": document.link_role,
"nb_accesses": 0,
"numchild": 0,
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
}
def test_api_documents_retrieve_anonymous_public_parent():
"""Anonymous users should be allowed to retrieve a document who has a public ancestor."""
grand_parent = factories.DocumentFactory(link_reach="public")
parent = factories.DocumentFactory(
parent=grand_parent, link_reach=random.choice(["authenticated", "restricted"])
)
document = factories.DocumentFactory(
link_reach=random.choice(["authenticated", "restricted"]), parent=parent
)
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/")
assert response.status_code == 200
assert response.json() == {
"id": str(document.id),
"abilities": {
"accesses_manage": False,
"accesses_view": False,
"ai_transform": False,
"ai_translate": False,
"attachment_upload": grand_parent.link_role == "editor",
"children_create": False,
"children_list": True,
"collaboration_auth": True,
"destroy": False,
# Anonymous user can't favorite a document even with read access
"favorite": False,
"invite_owner": False,
"link_configuration": False,
"media_auth": True,
"move": False,
"partial_update": grand_parent.link_role == "editor",
"restore": False,
"retrieve": True,
"update": grand_parent.link_role == "editor",
"versions_destroy": False,
"versions_list": False,
"versions_retrieve": False,
},
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"depth": 3,
"excerpt": document.excerpt,
"is_favorite": False,
"link_reach": document.link_reach,
"link_role": document.link_role,
"nb_accesses": 0,
"numchild": 0,
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
}
def test_api_documents_retrieve_anonymous_public_child():
"""
Anonymous users having access to a document should not gain access to a parent document.
"""
document = factories.DocumentFactory(
link_reach=random.choice(["authenticated", "restricted"])
)
factories.DocumentFactory(link_reach="public", parent=document)
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/")
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
@@ -155,8 +68,8 @@ def test_api_documents_retrieve_anonymous_restricted_or_authenticated(reach):
@pytest.mark.parametrize("reach", ["public", "authenticated"])
def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(reach):
"""
Authenticated users should be able to retrieve a public/authenticated document to
which they are not related.
Authenticated users should be able to retrieve a public document to which they are
not related.
"""
user = factories.UserFactory()
@@ -177,17 +90,13 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
"ai_transform": document.link_role == "editor",
"ai_translate": document.link_role == "editor",
"attachment_upload": document.link_role == "editor",
"children_create": document.link_role == "editor",
"children_list": True,
"collaboration_auth": True,
"destroy": False,
"favorite": True,
"invite_owner": False,
"link_configuration": False,
"media_auth": True,
"move": False,
"link_configuration": False,
"partial_update": document.link_role == "editor",
"restore": False,
"retrieve": True,
"update": document.link_role == "editor",
"versions_destroy": False,
@@ -197,104 +106,18 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"depth": 1,
"excerpt": document.excerpt,
"is_favorite": False,
"link_reach": reach,
"link_role": document.link_role,
"nb_accesses": 0,
"numchild": 0,
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
}
assert (
models.LinkTrace.objects.filter(document=document, user=user).exists() is True
)
@pytest.mark.parametrize("reach", ["public", "authenticated"])
def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(reach):
"""
Authenticated users should be allowed to retrieve a document who has a public or
authenticated ancestor.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
grand_parent = factories.DocumentFactory(link_reach=reach)
parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted")
document = factories.DocumentFactory(link_reach="restricted", parent=parent)
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
assert response.status_code == 200
assert response.json() == {
"id": str(document.id),
"abilities": {
"accesses_manage": False,
"accesses_view": False,
"ai_transform": grand_parent.link_role == "editor",
"ai_translate": grand_parent.link_role == "editor",
"attachment_upload": grand_parent.link_role == "editor",
"children_create": grand_parent.link_role == "editor",
"children_list": True,
"collaboration_auth": True,
"destroy": False,
"favorite": True,
"invite_owner": False,
"link_configuration": False,
"move": False,
"media_auth": True,
"partial_update": grand_parent.link_role == "editor",
"restore": False,
"retrieve": True,
"update": grand_parent.link_role == "editor",
"versions_destroy": False,
"versions_list": False,
"versions_retrieve": False,
},
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"depth": 3,
"excerpt": document.excerpt,
"is_favorite": False,
"link_reach": document.link_reach,
"link_role": document.link_role,
"nb_accesses": 0,
"numchild": 0,
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
}
@pytest.mark.parametrize("reach", ["public", "authenticated"])
def test_api_documents_retrieve_authenticated_public_or_authenticated_child(reach):
"""
Authenticated users having access to a document should not gain access to a parent document.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="restricted")
factories.DocumentFactory(link_reach=reach, parent=document)
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
@pytest.mark.parametrize("reach", ["public", "authenticated"])
def test_api_documents_retrieve_authenticated_trace_twice(reach):
"""
@@ -356,8 +179,10 @@ def test_api_documents_retrieve_authenticated_related_direct():
client.force_login(user)
document = factories.DocumentFactory()
access = factories.UserDocumentAccessFactory(document=document, user=user)
factories.UserDocumentAccessFactory(document=document)
factories.UserDocumentAccessFactory(document=document, user=user)
access2 = factories.UserDocumentAccessFactory(document=document)
serializers.UserSerializer(instance=user)
serializers.UserSerializer(instance=access2.user)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/",
@@ -369,135 +194,12 @@ def test_api_documents_retrieve_authenticated_related_direct():
"content": document.content,
"creator": str(document.creator.id),
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"depth": 1,
"excerpt": document.excerpt,
"is_favorite": False,
"link_reach": document.link_reach,
"link_role": document.link_role,
"nb_accesses": 2,
"numchild": 0,
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role],
}
def test_api_documents_retrieve_authenticated_related_parent():
"""
Authenticated users should be allowed to retrieve a document if they are related
to one of its ancestors whatever the role.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
grand_parent = factories.DocumentFactory(link_reach="restricted")
parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted")
document = factories.DocumentFactory(parent=parent, link_reach="restricted")
access = factories.UserDocumentAccessFactory(document=grand_parent, user=user)
factories.UserDocumentAccessFactory(document=grand_parent)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/",
)
assert response.status_code == 200
assert response.json() == {
"id": str(document.id),
"abilities": {
"accesses_manage": access.role in ["administrator", "owner"],
"accesses_view": True,
"ai_transform": access.role != "reader",
"ai_translate": access.role != "reader",
"attachment_upload": access.role != "reader",
"children_create": access.role != "reader",
"children_list": True,
"collaboration_auth": True,
"destroy": access.role == "owner",
"favorite": True,
"invite_owner": access.role == "owner",
"link_configuration": access.role in ["administrator", "owner"],
"media_auth": True,
"move": access.role in ["administrator", "owner"],
"partial_update": access.role != "reader",
"restore": access.role == "owner",
"retrieve": True,
"update": access.role != "reader",
"versions_destroy": access.role in ["administrator", "owner"],
"versions_list": True,
"versions_retrieve": True,
},
"content": document.content,
"creator": str(document.creator.id),
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"depth": 3,
"excerpt": document.excerpt,
"is_favorite": False,
"link_reach": "restricted",
"link_role": document.link_role,
"nb_accesses": 2,
"numchild": 0,
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role],
}
def test_api_documents_retrieve_authenticated_related_nb_accesses():
"""Validate computation of number of accesses."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
grand_parent = factories.DocumentFactory(link_reach="restricted")
parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted")
document = factories.DocumentFactory(parent=parent, link_reach="restricted")
factories.UserDocumentAccessFactory(document=grand_parent, user=user)
factories.UserDocumentAccessFactory(document=parent)
factories.UserDocumentAccessFactory(document=document)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/",
)
assert response.status_code == 200
assert response.json()["nb_accesses"] == 3
factories.UserDocumentAccessFactory(document=grand_parent)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/",
)
assert response.status_code == 200
assert response.json()["nb_accesses"] == 4
def test_api_documents_retrieve_authenticated_related_child():
"""
Authenticated users should not be allowed to retrieve a document as a result of being
related to one of its children.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="restricted")
child = factories.DocumentFactory(parent=document)
factories.UserDocumentAccessFactory(document=child, user=user)
factories.UserDocumentAccessFactory(document=document)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
@@ -536,16 +238,16 @@ def test_api_documents_retrieve_authenticated_related_team_none(mock_user_teams)
@pytest.mark.parametrize(
"teams,roles",
"teams",
[
[["readers"], ["reader"]],
[["unknown", "readers"], ["reader"]],
[["editors"], ["editor"]],
[["unknown", "editors"], ["editor"]],
["readers"],
["unknown", "readers"],
["editors"],
["unknown", "editors"],
],
)
def test_api_documents_retrieve_authenticated_related_team_members(
teams, roles, mock_user_teams
teams, mock_user_teams
):
"""
Authenticated users should be allowed to retrieve a document to which they
@@ -583,30 +285,25 @@ def test_api_documents_retrieve_authenticated_related_team_members(
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"depth": 1,
"excerpt": document.excerpt,
"is_favorite": False,
"link_reach": "restricted",
"link_role": document.link_role,
"nb_accesses": 5,
"numchild": 0,
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": roles,
}
@pytest.mark.parametrize(
"teams,roles",
"teams",
[
[["administrators"], ["administrator"]],
[["editors", "administrators"], ["administrator", "editor"]],
[["unknown", "administrators"], ["administrator"]],
["administrators"],
["editors", "administrators"],
["unknown", "administrators"],
],
)
def test_api_documents_retrieve_authenticated_related_team_administrators(
teams, roles, mock_user_teams
teams, mock_user_teams
):
"""
Authenticated users should be allowed to retrieve a document to which they
@@ -644,31 +341,26 @@ def test_api_documents_retrieve_authenticated_related_team_administrators(
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"depth": 1,
"excerpt": document.excerpt,
"is_favorite": False,
"link_reach": "restricted",
"link_role": document.link_role,
"nb_accesses": 5,
"numchild": 0,
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": roles,
}
@pytest.mark.parametrize(
"teams,roles",
"teams",
[
[["owners"], ["owner"]],
[["owners", "administrators"], ["owner", "administrator"]],
[["members", "administrators", "owners"], ["owner", "administrator"]],
[["unknown", "owners"], ["owner"]],
["owners"],
["owners", "administrators"],
["members", "administrators", "owners"],
["unknown", "owners"],
],
)
def test_api_documents_retrieve_authenticated_related_team_owners(
teams, roles, mock_user_teams
teams, mock_user_teams
):
"""
Authenticated users should be allowed to retrieve a restricted document to which
@@ -677,6 +369,7 @@ def test_api_documents_retrieve_authenticated_related_team_owners(
mock_user_teams.return_value = teams
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -705,231 +398,10 @@ def test_api_documents_retrieve_authenticated_related_team_owners(
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"depth": 1,
"excerpt": document.excerpt,
"is_favorite": False,
"link_reach": "restricted",
"link_role": document.link_role,
"nb_accesses": 5,
"numchild": 0,
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": roles,
}
def test_api_documents_retrieve_user_roles(django_assert_num_queries):
"""
Roles should be annotated on querysets taking into account all documents ancestors.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
grand_parent = factories.DocumentFactory(
users=factories.UserFactory.create_batch(2)
)
parent = factories.DocumentFactory(
parent=grand_parent, users=factories.UserFactory.create_batch(2)
)
document = factories.DocumentFactory(
parent=parent, users=factories.UserFactory.create_batch(2)
)
accesses = (
factories.UserDocumentAccessFactory(document=grand_parent, user=user),
factories.UserDocumentAccessFactory(document=parent, user=user),
factories.UserDocumentAccessFactory(document=document, user=user),
)
expected_roles = {access.role for access in accesses}
with django_assert_num_queries(10):
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
assert response.status_code == 200
user_roles = response.json()["user_roles"]
assert set(user_roles) == expected_roles
def test_api_documents_retrieve_numqueries_with_link_trace(django_assert_num_queries):
"""If the link traced already exists, the number of queries should be minimal."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[user], link_traces=[user])
with django_assert_num_queries(4):
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
with django_assert_num_queries(3):
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
assert response.status_code == 200
assert response.json()["id"] == str(document.id)
# Soft/permanent delete
@pytest.mark.parametrize("depth", [1, 2, 3])
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
def test_api_documents_retrieve_soft_deleted_anonymous(reach, depth):
"""
A soft/permanently deleted public document should not be accessible via its
detail endpoint for anonymous users, and should return a 404.
"""
documents = []
for i in range(depth):
documents.append(
factories.DocumentFactory(link_reach=reach)
if i == 0
else factories.DocumentFactory(parent=documents[-1])
)
assert models.Document.objects.count() == depth
response = APIClient().get(f"/api/v1.0/documents/{documents[-1].id!s}/")
assert response.status_code == 200 if reach == "public" else 401
# Delete any one of the documents...
deleted_document = random.choice(documents)
deleted_document.soft_delete()
response = APIClient().get(f"/api/v1.0/documents/{documents[-1].id!s}/")
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
fourty_days_ago = timezone.now() - timedelta(days=40)
deleted_document.deleted_at = fourty_days_ago
deleted_document.ancestors_deleted_at = fourty_days_ago
deleted_document.save()
response = APIClient().get(f"/api/v1.0/documents/{documents[-1].id!s}/")
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
@pytest.mark.parametrize("depth", [1, 2, 3])
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
def test_api_documents_retrieve_soft_deleted_authenticated(reach, depth):
"""
A soft/permanently deleted document should not be accessible via its detail endpoint for
authenticated users not related to the document.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
documents = []
for i in range(depth):
documents.append(
factories.DocumentFactory(link_reach=reach)
if i == 0
else factories.DocumentFactory(parent=documents[-1])
)
assert models.Document.objects.count() == depth
response = client.get(f"/api/v1.0/documents/{documents[-1].id!s}/")
assert response.status_code == 200 if reach in ["public", "authenticated"] else 403
# Delete any one of the documents...
deleted_document = random.choice(documents)
deleted_document.soft_delete()
response = client.get(f"/api/v1.0/documents/{documents[-1].id!s}/")
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
fourty_days_ago = timezone.now() - timedelta(days=40)
deleted_document.deleted_at = fourty_days_ago
deleted_document.ancestors_deleted_at = fourty_days_ago
deleted_document.save()
response = client.get(f"/api/v1.0/documents/{documents[-1].id!s}/")
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
@pytest.mark.parametrize("depth", [1, 2, 3])
@pytest.mark.parametrize("role", models.RoleChoices.values)
def test_api_documents_retrieve_soft_deleted_related(role, depth):
"""
A soft deleted document should only be accessible via its detail endpoint by
users with specific "owner" access rights.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
documents = []
for i in range(depth):
documents.append(
factories.UserDocumentAccessFactory(role=role, user=user).document
if i == 0
else factories.DocumentFactory(parent=documents[-1])
)
assert models.Document.objects.count() == depth
document = documents[-1]
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
assert response.status_code == 200
# Delete any one of the documents
deleted_document = random.choice(documents)
deleted_document.soft_delete()
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
if role == "owner":
assert response.status_code == 200
assert response.json()["id"] == str(document.id)
else:
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
@pytest.mark.parametrize("depth", [1, 2, 3])
@pytest.mark.parametrize("role", models.RoleChoices.values)
def test_api_documents_retrieve_permanently_deleted_related(role, depth):
"""
A permanently deleted document should not be accessible via its detail endpoint for
authenticated users with specific access rights whatever their role.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
documents = []
for i in range(depth):
documents.append(
factories.UserDocumentAccessFactory(role=role, user=user).document
if i == 0
else factories.DocumentFactory(parent=documents[-1])
)
assert models.Document.objects.count() == depth
document = documents[-1]
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
assert response.status_code == 200
# Delete any one of the documents
deleted_document = random.choice(documents)
fourty_days_ago = timezone.now() - timedelta(days=40)
with mock.patch("django.utils.timezone.now", return_value=fourty_days_ago):
deleted_document.soft_delete()
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}

View File

@@ -1,276 +0,0 @@
"""
Tests for Documents API endpoint in impress's core app: list
"""
from datetime import timedelta
from unittest import mock
from django.utils import timezone
import pytest
from faker import Faker
from rest_framework.pagination import PageNumberPagination
from rest_framework.test import APIClient
from core import factories, models
fake = Faker()
pytestmark = pytest.mark.django_db
@pytest.mark.parametrize("role", models.LinkRoleChoices.values)
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
def test_api_documents_trashbin_anonymous(reach, role):
"""
Anonymous users should not be allowed to list documents from the trashbin
whatever the link reach and link role
"""
factories.DocumentFactory(
link_reach=reach, link_role=role, deleted_at=timezone.now()
)
response = APIClient().get("/api/v1.0/documents/trashbin/")
assert response.status_code == 200
assert response.json() == {
"count": 0,
"next": None,
"previous": None,
"results": [],
}
def test_api_documents_trashbin_format():
"""Validate the format of documents as returned by the trashbin view."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
other_users = factories.UserFactory.create_batch(3)
document = factories.DocumentFactory(
deleted_at=timezone.now(),
users=factories.UserFactory.create_batch(2),
favorited_by=[user, *other_users],
link_traces=other_users,
)
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
response = client.get("/api/v1.0/documents/trashbin/")
assert response.status_code == 200
content = response.json()
results = content.pop("results")
assert content == {
"count": 1,
"next": None,
"previous": None,
}
assert len(results) == 1
assert results[0] == {
"id": str(document.id),
"abilities": {
"accesses_manage": True,
"accesses_view": True,
"ai_transform": True,
"ai_translate": True,
"attachment_upload": True,
"children_create": True,
"children_list": True,
"collaboration_auth": True,
"destroy": True,
"favorite": True,
"invite_owner": True,
"link_configuration": True,
"media_auth": True,
"move": False, # Can't move a deleted document
"partial_update": True,
"restore": True,
"retrieve": True,
"update": True,
"versions_destroy": True,
"versions_list": True,
"versions_retrieve": True,
},
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"depth": 1,
"excerpt": document.excerpt,
"link_reach": document.link_reach,
"link_role": document.link_role,
"nb_accesses": 3,
"numchild": 0,
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": ["owner"],
}
def test_api_documents_trashbin_authenticated_direct(django_assert_num_queries):
"""
The trashbin should only list deleted documents for which the current user is owner.
"""
now = timezone.now()
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document1, document2 = factories.DocumentFactory.create_batch(2, deleted_at=now)
models.DocumentAccess.objects.create(document=document1, user=user, role="owner")
models.DocumentAccess.objects.create(document=document2, user=user, role="owner")
# Unrelated documents
for reach in models.LinkReachChoices:
for role in models.LinkRoleChoices:
factories.DocumentFactory(link_reach=reach, link_role=role, deleted_at=now)
# Role other than "owner"
for role in models.RoleChoices.values:
if role == "owner":
continue
document_not_owner = factories.DocumentFactory(deleted_at=now)
models.DocumentAccess.objects.create(
document=document_not_owner, user=user, role=role
)
# Nested documents should also get listed
parent = factories.DocumentFactory(parent=document1)
document3 = factories.DocumentFactory(parent=parent, deleted_at=now)
models.DocumentAccess.objects.create(document=parent, user=user, role="owner")
# Permanently deleted documents should not be listed
fourty_days_ago = timezone.now() - timedelta(days=40)
permanently_deleted_document = factories.DocumentFactory(users=[(user, "owner")])
with mock.patch("django.utils.timezone.now", return_value=fourty_days_ago):
permanently_deleted_document.soft_delete()
expected_ids = {str(document1.id), str(document2.id), str(document3.id)}
with django_assert_num_queries(7):
response = client.get("/api/v1.0/documents/trashbin/")
with django_assert_num_queries(4):
response = client.get("/api/v1.0/documents/trashbin/")
assert response.status_code == 200
results = response.json()["results"]
results_ids = {result["id"] for result in results}
assert len(results) == 3
assert expected_ids == results_ids
def test_api_documents_trashbin_authenticated_via_team(
django_assert_num_queries, mock_user_teams
):
"""
Authenticated users should be able to list trashbin documents they own via a team.
"""
now = timezone.now()
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
mock_user_teams.return_value = ["team1", "team2", "unknown"]
deleted_document_team1 = factories.DocumentFactory(
teams=[("team1", "owner")], deleted_at=now
)
factories.DocumentFactory(teams=[("team1", "owner")])
factories.DocumentFactory(teams=[("team1", "administrator")], deleted_at=now)
factories.DocumentFactory(teams=[("team1", "administrator")])
deleted_document_team2 = factories.DocumentFactory(
teams=[("team2", "owner")], deleted_at=now
)
factories.DocumentFactory(teams=[("team2", "owner")])
factories.DocumentFactory(teams=[("team2", "administrator")], deleted_at=now)
factories.DocumentFactory(teams=[("team2", "administrator")])
expected_ids = {str(deleted_document_team1.id), str(deleted_document_team2.id)}
with django_assert_num_queries(5):
response = client.get("/api/v1.0/documents/trashbin/")
with django_assert_num_queries(3):
response = client.get("/api/v1.0/documents/trashbin/")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 2
results_id = {result["id"] for result in results}
assert expected_ids == results_id
@mock.patch.object(PageNumberPagination, "get_page_size", return_value=2)
def test_api_documents_trashbin_pagination(
_mock_page_size,
):
"""Pagination should work as expected."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document_ids = [
str(document.id)
for document in factories.DocumentFactory.create_batch(
3, deleted_at=timezone.now()
)
]
for document_id in document_ids:
models.DocumentAccess.objects.create(
document_id=document_id, user=user, role="owner"
)
# Get page 1
response = client.get("/api/v1.0/documents/trashbin/")
assert response.status_code == 200
content = response.json()
assert content["count"] == 3
assert content["next"] == "http://testserver/api/v1.0/documents/trashbin/?page=2"
assert content["previous"] is None
assert len(content["results"]) == 2
for item in content["results"]:
document_ids.remove(item["id"])
# Get page 2
response = client.get(
"/api/v1.0/documents/trashbin/?page=2",
)
assert response.status_code == 200
content = response.json()
assert content["count"] == 3
assert content["next"] is None
assert content["previous"] == "http://testserver/api/v1.0/documents/trashbin/"
assert len(content["results"]) == 1
document_ids.remove(content["results"][0]["id"])
assert document_ids == []
def test_api_documents_trashbin_distinct():
"""A document with several related users should only be listed once."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
other_user = factories.UserFactory()
document = factories.DocumentFactory(
users=[(user, "owner"), other_user], deleted_at=timezone.now()
)
response = client.get(
"/api/v1.0/documents/trashbin/",
)
assert response.status_code == 200
content = response.json()
assert len(content["results"]) == 1
assert content["results"][0]["id"] == str(document.id)

View File

@@ -16,7 +16,6 @@ from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
@pytest.mark.parametrize("via_parent", [True, False])
@pytest.mark.parametrize(
"reach, role",
[
@@ -27,18 +26,12 @@ pytestmark = pytest.mark.django_db
("public", "reader"),
],
)
def test_api_documents_update_anonymous_forbidden(reach, role, via_parent):
def test_api_documents_update_anonymous_forbidden(reach, role):
"""
Anonymous users should not be allowed to update a document when link
configuration does not allow it.
"""
if via_parent:
grand_parent = factories.DocumentFactory(link_reach=reach, link_role=role)
parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted")
document = factories.DocumentFactory(parent=parent, link_reach="restricted")
else:
document = factories.DocumentFactory(link_reach=reach, link_role=role)
document = factories.DocumentFactory(link_reach=reach, link_role=role)
old_document_values = serializers.DocumentSerializer(instance=document).data
new_document_values = serializers.DocumentSerializer(
@@ -59,7 +52,6 @@ def test_api_documents_update_anonymous_forbidden(reach, role, via_parent):
assert document_values == old_document_values
@pytest.mark.parametrize("via_parent", [True, False])
@pytest.mark.parametrize(
"reach,role",
[
@@ -69,9 +61,7 @@ def test_api_documents_update_anonymous_forbidden(reach, role, via_parent):
("restricted", "editor"),
],
)
def test_api_documents_update_authenticated_unrelated_forbidden(
reach, role, via_parent
):
def test_api_documents_update_authenticated_unrelated_forbidden(reach, role):
"""
Authenticated users should not be allowed to update a document to which
they are not related if the link configuration does not allow it.
@@ -81,12 +71,7 @@ def test_api_documents_update_authenticated_unrelated_forbidden(
client = APIClient()
client.force_login(user)
if via_parent:
grand_parent = factories.DocumentFactory(link_reach=reach, link_role=role)
parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted")
document = factories.DocumentFactory(parent=parent, link_reach="restricted")
else:
document = factories.DocumentFactory(link_reach=reach, link_role=role)
document = factories.DocumentFactory(link_reach=reach, link_role=role)
old_document_values = serializers.DocumentSerializer(instance=document).data
new_document_values = serializers.DocumentSerializer(
@@ -108,7 +93,6 @@ def test_api_documents_update_authenticated_unrelated_forbidden(
assert document_values == old_document_values
@pytest.mark.parametrize("via_parent", [True, False])
@pytest.mark.parametrize(
"is_authenticated,reach,role",
[
@@ -118,10 +102,10 @@ def test_api_documents_update_authenticated_unrelated_forbidden(
],
)
def test_api_documents_update_anonymous_or_authenticated_unrelated(
is_authenticated, reach, role, via_parent
is_authenticated, reach, role
):
"""
Anonymous and authenticated users should be able to update a document to which
Authenticated users should be able to update a document to which
they are not related if the link configuration allows it.
"""
client = APIClient()
@@ -132,12 +116,7 @@ def test_api_documents_update_anonymous_or_authenticated_unrelated(
else:
user = AnonymousUser()
if via_parent:
grand_parent = factories.DocumentFactory(link_reach=reach, link_role=role)
parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted")
document = factories.DocumentFactory(parent=parent, link_reach="restricted")
else:
document = factories.DocumentFactory(link_reach=reach, link_role=role)
document = factories.DocumentFactory(link_reach=reach, link_role=role)
old_document_values = serializers.DocumentSerializer(instance=document).data
new_document_values = serializers.DocumentSerializer(
@@ -158,11 +137,8 @@ def test_api_documents_update_anonymous_or_authenticated_unrelated(
"accesses",
"created_at",
"creator",
"depth",
"link_reach",
"link_role",
"numchild",
"path",
]:
assert value == old_document_values[key]
elif key == "updated_at":
@@ -171,34 +147,24 @@ def test_api_documents_update_anonymous_or_authenticated_unrelated(
assert value == new_document_values[key]
@pytest.mark.parametrize("via_parent", [True, False])
@pytest.mark.parametrize("via", VIA)
def test_api_documents_update_authenticated_reader(via, via_parent, mock_user_teams):
def test_api_documents_update_authenticated_reader(via, mock_user_teams):
"""
Users who are reader of a document should not be allowed to update it.
Users who are reader of a document but not administrators should
not be allowed to update it.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
if via_parent:
grand_parent = factories.DocumentFactory(link_reach="restricted")
parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted")
document = factories.DocumentFactory(parent=parent, link_reach="restricted")
access_document = grand_parent
else:
document = factories.DocumentFactory(link_reach="restricted")
access_document = document
document = factories.DocumentFactory(link_role="reader")
if via == USER:
factories.UserDocumentAccessFactory(
document=access_document, user=user, role="reader"
)
factories.UserDocumentAccessFactory(document=document, user=user, role="reader")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=access_document, team="lasuite", role="reader"
document=document, team="lasuite", role="reader"
)
old_document_values = serializers.DocumentSerializer(instance=document).data
@@ -222,11 +188,10 @@ def test_api_documents_update_authenticated_reader(via, via_parent, mock_user_te
assert document_values == old_document_values
@pytest.mark.parametrize("via_parent", [True, False])
@pytest.mark.parametrize("role", ["editor", "administrator", "owner"])
@pytest.mark.parametrize("via", VIA)
def test_api_documents_update_authenticated_editor_administrator_or_owner(
via, role, via_parent, mock_user_teams
via, role, mock_user_teams
):
"""A user who is editor, administrator or owner of a document should be allowed to update it."""
user = factories.UserFactory(with_owned_document=True)
@@ -234,23 +199,13 @@ def test_api_documents_update_authenticated_editor_administrator_or_owner(
client = APIClient()
client.force_login(user)
if via_parent:
grand_parent = factories.DocumentFactory(link_reach="restricted")
parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted")
document = factories.DocumentFactory(parent=parent, link_reach="restricted")
access_document = grand_parent
else:
document = factories.DocumentFactory(link_reach="restricted")
access_document = document
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(
document=access_document, user=user, role=role
)
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=access_document, team="lasuite", role=role
document=document, team="lasuite", role=role
)
old_document_values = serializers.DocumentSerializer(instance=document).data
@@ -272,12 +227,55 @@ def test_api_documents_update_authenticated_editor_administrator_or_owner(
"id",
"created_at",
"creator",
"depth",
"link_reach",
"link_role",
"nb_accesses",
"numchild",
"path",
]:
assert value == old_document_values[key]
elif key == "updated_at":
assert value > old_document_values[key]
else:
assert value == new_document_values[key]
@pytest.mark.parametrize("via", VIA)
def test_api_documents_update_authenticated_owners(via, mock_user_teams):
"""Administrators of a document should be allowed to update it."""
user = factories.UserFactory(with_owned_document=True)
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_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="owner"
)
old_document_values = serializers.DocumentSerializer(instance=document).data
new_document_values = serializers.DocumentSerializer(
instance=factories.DocumentFactory()
).data
response = client.put(
f"/api/v1.0/documents/{document.id!s}/", new_document_values, format="json"
)
assert response.status_code == 200
document = models.Document.objects.get(pk=document.pk)
document_values = serializers.DocumentSerializer(instance=document).data
for key, value in document_values.items():
if key in [
"id",
"created_at",
"creator",
"link_reach",
"link_role",
"nb_accesses",
]:
assert value == old_document_values[key]
elif key == "updated_at":

View File

@@ -1,35 +0,0 @@
import pytest
from core import factories
@pytest.mark.django_db
def test_update_blank_title_migration(migrator):
"""
Test that the migration fixes the titles of documents that are
"Untitled document", "Unbenanntes Dokument" or "Document sans titre"
"""
migrator.apply_initial_migration(("core", "0017_add_fields_for_soft_delete"))
english_doc = factories.DocumentFactory(title="Untitled document")
german_doc = factories.DocumentFactory(title="Unbenanntes Dokument")
french_doc = factories.DocumentFactory(title="Document sans titre")
other_doc = factories.DocumentFactory(title="My document")
assert english_doc.title == "Untitled document"
assert german_doc.title == "Unbenanntes Dokument"
assert french_doc.title == "Document sans titre"
assert other_doc.title == "My document"
# Apply the migration
migrator.apply_tested_migration(("core", "0018_update_blank_title"))
english_doc.refresh_from_db()
german_doc.refresh_from_db()
french_doc.refresh_from_db()
other_doc.refresh_from_db()
assert english_doc.title == None
assert german_doc.title == None
assert french_doc.title == None
assert other_doc.title == "My document"

View File

@@ -73,14 +73,14 @@ def test_api_template_accesses_list_authenticated_related(via, mock_user_teams):
user_access = models.TemplateAccess.objects.create(
template=template,
user=user,
role=random.choice(models.RoleChoices.values),
role=random.choice(models.RoleChoices.choices)[0],
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
user_access = models.TemplateAccess.objects.create(
template=template,
team="lasuite",
role=random.choice(models.RoleChoices.values),
role=random.choice(models.RoleChoices.choices)[0],
)
access1 = factories.TeamTemplateAccessFactory(template=template)
@@ -219,7 +219,7 @@ def test_api_template_accesses_update_anonymous():
new_values = {
"id": uuid4(),
"user": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.values),
"role": random.choice(models.RoleChoices.choices)[0],
}
api_client = APIClient()
@@ -252,7 +252,7 @@ def test_api_template_accesses_update_authenticated_unrelated():
new_values = {
"id": uuid4(),
"user": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.values),
"role": random.choice(models.RoleChoices.choices)[0],
}
for field, value in new_values.items():
@@ -294,7 +294,7 @@ def test_api_template_accesses_update_authenticated_editor_or_reader(
new_values = {
"id": uuid4(),
"user": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.values),
"role": random.choice(models.RoleChoices.choices)[0],
}
for field, value in new_values.items():
@@ -398,7 +398,7 @@ def test_api_template_accesses_update_administrator_from_owner(via, mock_user_te
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.values),
"role": random.choice(models.RoleChoices.choices)[0],
}
for field, value in new_values.items():
@@ -497,7 +497,7 @@ def test_api_template_accesses_update_owner(via, mock_user_teams):
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.values),
"role": random.choice(models.RoleChoices.choices)[0],
}
for field, value in new_values.items():

View File

@@ -23,7 +23,7 @@ def test_api_template_accesses_create_anonymous():
{
"user": str(other_user.id),
"template": str(template.id),
"role": random.choice(models.RoleChoices.values),
"role": random.choice(models.RoleChoices.choices)[0],
},
format="json",
)

View File

@@ -0,0 +1,208 @@
"""
Test users API endpoints in the impress core app.
"""
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_templates_generate_document_anonymous_public():
"""Anonymous users can generate pdf document with public templates."""
template = factories.TemplateFactory(is_public=True)
data = {
"body": "# Test markdown body",
}
response = APIClient().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/pdf"
def test_api_templates_generate_document_anonymous_not_public():
"""
Anonymous users should not be allowed to generate pdf document with templates
that are not marked as public.
"""
template = factories.TemplateFactory(is_public=False)
data = {
"body": "# Test markdown body",
}
response = APIClient().post(
f"/api/v1.0/templates/{template.id!s}/generate-document/",
data,
format="json",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_templates_generate_document_authenticated_public():
"""Authenticated users can generate pdf document with public templates."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory(is_public=True)
data = {"body": "# Test markdown body"}
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/pdf"
def test_api_templates_generate_document_authenticated_not_public():
"""
Authenticated users should not be allowed to generate pdf document with templates
that are not marked as public.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory(is_public=False)
data = {"body": "# Test markdown body"}
response = client.post(
f"/api/v1.0/templates/{template.id!s}/generate-document/",
data,
format="json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
@pytest.mark.parametrize("via", VIA)
def test_api_templates_generate_document_related(via, mock_user_teams):
"""Users related to a template can generate pdf document."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
access = None
if via == USER:
access = factories.UserTemplateAccessFactory(user=user)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
access = factories.TeamTemplateAccessFactory(team="lasuite")
data = {"body": "# Test markdown body"}
response = client.post(
f"/api/v1.0/templates/{access.template_id!s}/generate-document/",
data,
format="json",
)
assert response.status_code == 200
assert response.headers["content-type"] == "application/pdf"
def test_api_templates_generate_document_type_html():
"""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"}
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/pdf"
def test_api_templates_generate_document_type_markdown():
"""Generate pdf document with the body type markdown."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory(is_public=True)
data = {"body": "# Test markdown body", "body_type": "markdown"}
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/pdf"
def test_api_templates_generate_document_type_unknown():
"""Generate pdf document with the body type unknown."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory(is_public=True)
data = {"body": "# Test markdown body", "body_type": "unknown"}
response = client.post(
f"/api/v1.0/templates/{template.id!s}/generate-document/",
data,
format="json",
)
assert response.status_code == 400
assert response.json() == {
"body_type": [
'"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

@@ -187,9 +187,9 @@ def test_api_templates_list_order_default():
response_template_ids = [template["id"] for template in response_data["results"]]
template_ids.reverse()
assert response_template_ids == template_ids, (
"created_at values are not sorted from newest to oldest"
)
assert (
response_template_ids == template_ids
), "created_at values are not sorted from newest to oldest"
def test_api_templates_list_order_param():
@@ -215,6 +215,6 @@ def test_api_templates_list_order_param():
response_template_ids = [template["id"] for template in response_data["results"]]
assert response_template_ids == templates_ids, (
"created_at values are not sorted from oldest to newest"
)
assert (
response_template_ids == templates_ids
), "created_at values are not sorted from oldest to newest"

View File

@@ -20,7 +20,6 @@ pytestmark = pytest.mark.django_db
CRISP_WEBSITE_ID="123",
FRONTEND_THEME="test-theme",
MEDIA_BASE_URL="http://testserver/",
POSTHOG_KEY={"id": "132456", "host": "https://eu.i.posthog-test.com"},
SENTRY_DSN="https://sentry.test/123",
)
@pytest.mark.parametrize("is_authenticated", [False, True])
@@ -42,6 +41,5 @@ def test_api_config(is_authenticated):
"LANGUAGES": [["en-us", "English"], ["fr-fr", "French"], ["de-de", "German"]],
"LANGUAGE_CODE": "en-us",
"MEDIA_BASE_URL": "http://testserver/",
"POSTHOG_KEY": {"id": "132456", "host": "https://eu.i.posthog-test.com"},
"SENTRY_DSN": "https://sentry.test/123",
}

View File

@@ -42,9 +42,8 @@ def test_api_users_list_authenticated():
def test_api_users_list_query_email():
"""
Authenticated users should be able to list users and filter by email.
Only results with a Levenstein distance less than 3 with the query should be returned.
We want to match by Levenstein distance because we want to prevent typing errors.
Authenticated users should be able to list users
and filter by email.
"""
user = factories.UserFactory()
@@ -52,7 +51,9 @@ def test_api_users_list_query_email():
client.force_login(user)
dave = factories.UserFactory(email="david.bowman@work.com")
factories.UserFactory(email="nicole.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",
@@ -61,53 +62,59 @@ def test_api_users_list_query_email():
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(dave.id)]
response = client.get(
"/api/v1.0/users/?q=davig.bovman@worm.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")
response = client.get(
"/api/v1.0/users/?q=davig.bovman@worm.cop",
)
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == []
assert user_ids == [str(nicole.id), str(frank.id)]
def test_api_users_list_query_email_matching():
"""While filtering by email, results should be filtered and sorted by Levenstein distance."""
"""While filtering by email, results should be filtered and sorted by similarity"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
user1 = factories.UserFactory(email="alice.johnson@example.gouv.fr")
user2 = factories.UserFactory(email="alice.johnnson@example.gouv.fr")
user3 = factories.UserFactory(email="alice.kohlson@example.gouv.fr")
user4 = factories.UserFactory(email="alicia.johnnson@example.gouv.fr")
user5 = factories.UserFactory(email="alicia.johnnson@example.gov.uk")
factories.UserFactory(email="alice.thomson@example.gouv.fr")
alice = factories.UserFactory(email="alice.johnson@example.gouv.fr")
factories.UserFactory(email="jane.smith@example.gouv.fr")
michael_wilson = factories.UserFactory(email="michael.wilson@example.gouv.fr")
factories.UserFactory(email="david.jones@example.gouv.fr")
michael_brown = factories.UserFactory(email="michael.brown@example.gouv.fr")
factories.UserFactory(email="sophia.taylor@example.gouv.fr")
response = client.get(
"/api/v1.0/users/?q=alice.johnson@example.gouv.fr",
"/api/v1.0/users/?q=michael.johnson@example.gouv.f",
)
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(user1.id), str(user2.id), str(user3.id), str(user4.id)]
assert user_ids == [str(michael_wilson.id)]
response = client.get("/api/v1.0/users/?q=alicia.johnnson@example.gouv.fr")
response = client.get("/api/v1.0/users/?q=michael.johnson@example.gouv.fr")
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(user4.id), str(user2.id), str(user1.id), str(user5.id)]
assert user_ids == [str(michael_wilson.id), str(alice.id), str(michael_brown.id)]
response = client.get(
"/api/v1.0/users/?q=ajohnson@example.gouv.f",
)
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(alice.id)]
response = client.get(
"/api/v1.0/users/?q=michael.wilson@example.gouv.f",
)
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(michael_wilson.id)]
def test_api_users_list_query_email_exclude_doc_user():
"""
Authenticated users should be able to list users while filtering by email
and excluding users who have access to a document.
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()
@@ -115,19 +122,17 @@ def test_api_users_list_query_email_exclude_doc_user():
client = APIClient()
client.force_login(user)
nicole_fool = factories.UserFactory(email="nicole_fool@work.com")
nicole_pool = factories.UserFactory(email="nicole_pool@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")
factories.UserDocumentAccessFactory(document=document, user=nicole_pool)
factories.UserDocumentAccessFactory(document=document, user=frank)
response = client.get(
"/api/v1.0/users/?q=nicole_fool@work.com&document_id=" + str(document.id)
)
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_fool.id)]
assert user_ids == [str(nicole.id)]
def test_api_users_retrieve_me_anonymous():

View File

@@ -1,94 +0,0 @@
"""
Unit tests for the filter_root_paths utility function.
"""
from core.api.utils import filter_root_paths
def test_api_utils_filter_root_paths_success():
"""
The `filter_root_paths` function should correctly identify root paths
from a given list of paths.
This test uses a list of paths with missing intermediate paths to ensure that
only the minimal set of root paths is returned.
"""
paths = [
"0001",
"00010001",
"000100010001",
"000100010002",
# missing 00010002
"000100020001",
"000100020002",
"0002",
"00020001",
"00020002",
# missing 0003
"00030001",
"000300010001",
"00030002",
# missing 0004
# missing 00040001
# missing 000400010001
# missing 000400010002
"000400010003",
"0004000100030001",
"000400010004",
]
filtered_paths = filter_root_paths(paths, skip_sorting=True)
assert filtered_paths == [
"0001",
"0002",
"00030001",
"00030002",
"000400010003",
"000400010004",
]
def test_api_utils_filter_root_paths_sorting():
"""
The `filter_root_paths` function should fail is sorting is skipped and paths are not sorted.
This test verifies that when sorting is skipped, the function respects the input order, and
when sorting is enabled, the result is correctly ordered and minimal.
"""
paths = [
"0001",
"00010001",
"000100010001",
"000100020002",
"000100010002",
"000100020001",
"00020001",
"0002",
"00020002",
"000300010001",
"00030001",
"00030002",
"0004000100030001",
"000400010003",
"000400010004",
]
filtered_paths = filter_root_paths(paths, skip_sorting=True)
assert filtered_paths == [
"0001",
"00020001",
"0002",
"000300010001",
"00030001",
"00030002",
"0004000100030001",
"000400010003",
"000400010004",
]
filtered_paths = filter_root_paths(paths)
assert filtered_paths == [
"0001",
"0002",
"00030001",
"00030002",
"000400010003",
"000400010004",
]

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