Compare commits

..

3 Commits

Author SHA1 Message Date
lebaudantoine c6a45ce58a 📈(frontend) track user interactions from left side panel
Add analytics tracking for actions initiated from the sidebar to monitor
feature usage patterns and user engagement metrics.
2025-05-25 01:07:25 +02:00
lebaudantoine 3cfcdcf036 📈(frontend) add tracking for AI actions usage and success rates
Implement analytics tracking on AI actions to collect insights on usage
patterns and monitor error/success rates. Enables data-driven improvements.

Temporary solution until backend implements more precise tracking for
comprehensive AI feature analytics.
2025-05-25 01:07:19 +02:00
lebaudantoine 69006f0ef0 (frontend) add capture method support to PostHogAnalytics wrapper
Implement capture method in PostHogAnalytics wrapper to support direct
event tracking through PostHog SDK. Enables more granular analytics
event handling within the application's analytics abstraction layer.
2025-05-25 01:00:57 +02:00
590 changed files with 25808 additions and 51711 deletions
-1
View File
@@ -34,4 +34,3 @@ db.sqlite3
# Frontend # Frontend
node_modules node_modules
.next
-23
View File
@@ -1,23 +0,0 @@
# Set the default behavior for all files
* text=auto eol=lf
# Binary files (should not be modified)
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.mov binary
*.mp4 binary
*.mp3 binary
*.flv binary
*.fla binary
*.swf binary
*.gz binary
*.zip binary
*.7z binary
*.ttf binary
*.woff binary
*.woff2 binary
*.eot binary
*.pdf binary
+6
View File
@@ -0,0 +1,6 @@
<!---
Thanks for filing an issue 😄 ! Before you submit, please read the following:
Check the other issue templates if you are trying to submit a bug report, feature request, or question
Search open/closed issues before submitting since someone might have asked the same thing before!
-->
-4
View File
@@ -6,10 +6,6 @@ labels: ["bug", "triage"]
## Bug Report ## Bug Report
**Before you file your issue**
- Check the other [issues](https://github.com/suitenumerique/docs/issues) before filing your own
- If your report is related to the ([BlockNote](https://github.com/TypeCellOS/BlockNote)) text editor, [file it on their repo](https://github.com/TypeCellOS/BlockNote/issues). If you're not sure whether your issue is with BlockNote or Docs, file it on our repo: if we support it, we'll backport it upstream ourselves 😊, otherwise we'll ask you to do so.
**Problematic behavior** **Problematic behavior**
A clear and concise description of the behavior. A clear and concise description of the behavior.
+4 -15
View File
@@ -1,22 +1,11 @@
## Purpose ## Purpose
Describe the purpose of this pull request. Description...
## Proposal ## Proposal
- [ ] item 1... Description...
- [ ] item 2...
## External contributions - [] item 1...
- [] item 2...
Thank you for your contribution! 🎉
Please ensure the following items are checked before submitting your pull request:
- [ ] I have read and followed the [contributing guidelines](https://github.com/suitenumerique/docs/blob/main/CONTRIBUTING.md)
- [ ] I have read and agreed to the [Code of Conduct](https://github.com/suitenumerique/docs/blob/main/CODE_OF_CONDUCT.md)
- [ ] I have signed off my commits with `git commit --signoff` (DCO compliance)
- [ ] I have signed my commits with my SSH or GPG key (`git commit -S`)
- [ ] My commit messages follow the required format: `<gitmoji>(type) title description`
- [ ] I have added a changelog entry under `## [Unreleased]` section (if noticeable change)
- [ ] I have added corresponding tests for new features or bug fixes (if applicable)
+1 -1
View File
@@ -10,7 +10,7 @@ jobs:
install-dependencies: install-dependencies:
uses: ./.github/workflows/dependencies.yml uses: ./.github/workflows/dependencies.yml
with: with:
node_version: '22.x' node_version: '20.x'
with-front-dependencies-installation: true with-front-dependencies-installation: true
synchronize-with-crowdin: synchronize-with-crowdin:
+2 -3
View File
@@ -10,7 +10,7 @@ jobs:
install-dependencies: install-dependencies:
uses: ./.github/workflows/dependencies.yml uses: ./.github/workflows/dependencies.yml
with: with:
node_version: '22.x' node_version: '20.x'
with-front-dependencies-installation: true with-front-dependencies-installation: true
with-build_mails: true with-build_mails: true
@@ -23,10 +23,9 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
# Backend i18n # Backend i18n
- name: Install Python - name: Install Python
uses: actions/setup-python@v5 uses: actions/setup-python@v3
with: with:
python-version: "3.13.3" python-version: "3.13.3"
cache: "pip"
- name: Upgrade pip and setuptools - name: Upgrade pip and setuptools
run: pip install --upgrade pip setuptools run: pip install --upgrade pip setuptools
- name: Install development dependencies - name: Install development dependencies
+1 -1
View File
@@ -5,7 +5,7 @@ on:
inputs: inputs:
node_version: node_version:
required: false required: false
default: '22.x' default: '20.x'
type: string type: string
with-front-dependencies-installation: with-front-dependencies-installation:
type: boolean type: boolean
+10 -25
View File
@@ -31,11 +31,8 @@ jobs:
images: lasuite/impress-backend images: lasuite/impress-backend
- -
name: Login to DockerHub name: Login to DockerHub
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview') if: github.event_name != 'pull_request'
uses: docker/login-action@v3 run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- -
name: Run trivy scan name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main uses: numerique-gouv/action-trivy-cache@main
@@ -49,7 +46,7 @@ jobs:
context: . context: .
target: backend-production target: backend-production
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000 build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview') }} push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
@@ -59,14 +56,6 @@ jobs:
- -
name: Checkout repository name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Checkout custom code repository
uses: actions/checkout@v4
with:
repository: 'AntoLC/docs-customized'
ref: 'main'
path: docs-custom
- -
name: Docker meta name: Docker meta
id: meta id: meta
@@ -75,16 +64,13 @@ jobs:
images: lasuite/impress-frontend images: lasuite/impress-frontend
- -
name: Login to DockerHub name: Login to DockerHub
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview') if: github.event_name != 'pull_request'
uses: docker/login-action@v3 run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- -
name: Run trivy scan name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main uses: numerique-gouv/action-trivy-cache@main
with: with:
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production --build-arg CUSTOM_CODE=docs-custom' docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
docker-image-name: 'docker.io/lasuite/impress-frontend:${{ github.sha }}' docker-image-name: 'docker.io/lasuite/impress-frontend:${{ github.sha }}'
- -
name: Build and push name: Build and push
@@ -95,9 +81,8 @@ jobs:
target: frontend-production target: frontend-production
build-args: | build-args: |
DOCKER_USER=${{ env.DOCKER_USER }}:-1000 DOCKER_USER=${{ env.DOCKER_USER }}:-1000
CUSTOM_CODE=docs-custom
PUBLISH_AS_MIT=false PUBLISH_AS_MIT=false
push: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview') }} push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
@@ -115,7 +100,7 @@ jobs:
images: lasuite/impress-y-provider images: lasuite/impress-y-provider
- -
name: Login to DockerHub name: Login to DockerHub
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview') if: github.event_name != 'pull_request'
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
- -
name: Run trivy scan name: Run trivy scan
@@ -131,7 +116,7 @@ jobs:
file: ./src/frontend/servers/y-provider/Dockerfile file: ./src/frontend/servers/y-provider/Dockerfile
target: y-provider target: y-provider
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000 build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview') }} push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
@@ -140,7 +125,7 @@ jobs:
- build-and-push-frontend - build-and-push-frontend
- build-and-push-backend - build-and-push-backend
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview') if: github.event_name != 'pull_request'
steps: steps:
- uses: numerique-gouv/action-argocd-webhook-notification@main - uses: numerique-gouv/action-argocd-webhook-notification@main
id: notify id: notify
+3 -3
View File
@@ -21,10 +21,10 @@ jobs:
shell: bash shell: bash
run: | run: |
set -e set -e
HELMFILE=src/helm/helmfile.yaml.gotmpl HELMFILE=src/helm/helmfile.yaml
environments=$(awk 'BEGIN {in_env=0} /^environments:/ {in_env=1; next} /^---/ {in_env=0} in_env && /^ [^ ]/ {gsub(/^ /,""); gsub(/:.*$/,""); print}' "$HELMFILE") environments=$(awk 'BEGIN {in_env=0} /^environments:/ {in_env=1; next} /^---/ {in_env=0} in_env && /^ [^ ]/ {gsub(/^ /,""); gsub(/:.*$/,""); print}' "$HELMFILE")
for env in $environments; do for env in $environments; do
echo "################### $env lint ###################" echo "################### $env lint ###################"
helmfile -e $env lint -f $HELMFILE || exit 1 helmfile -e $env -f $HELMFILE lint || exit 1
echo -e "\n" echo -e "\n"
done done
+10 -61
View File
@@ -13,7 +13,7 @@ jobs:
install-dependencies: install-dependencies:
uses: ./.github/workflows/dependencies.yml uses: ./.github/workflows/dependencies.yml
with: with:
node_version: '22.x' node_version: '20.x'
with-front-dependencies-installation: true with-front-dependencies-installation: true
test-front: test-front:
@@ -26,7 +26,7 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: "22.x" node-version: "20.x"
- name: Restore the frontend cache - name: Restore the frontend cache
uses: actions/cache@v4 uses: actions/cache@v4
@@ -48,7 +48,7 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: "22.x" node-version: "20.x"
- name: Restore the frontend cache - name: Restore the frontend cache
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
@@ -70,7 +70,7 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: "22.x" node-version: "20.x"
- name: Restore the frontend cache - name: Restore the frontend cache
uses: actions/cache@v4 uses: actions/cache@v4
@@ -80,13 +80,13 @@ jobs:
fail-on-cache-miss: true fail-on-cache-miss: true
- name: Set e2e env variables - name: Set e2e env variables
run: cat env.d/development/common.e2e >> env.d/development/common.local run: cat env.d/development/common.e2e.dist >> env.d/development/common.dist
- name: Install Playwright Browsers - name: Install Playwright Browsers
run: cd src/frontend/apps/e2e && yarn install --frozen-lockfile && yarn install-playwright chromium run: cd src/frontend/apps/e2e && yarn install --frozen-lockfile && yarn install-playwright chromium
- name: Start Docker services - name: Start Docker services
run: make bootstrap-e2e FLUSH_ARGS='--no-input' run: make bootstrap FLUSH_ARGS='--no-input' cache=
- name: Run e2e tests - name: Run e2e tests
run: cd src/frontend/ && yarn e2e:test --project='chromium' run: cd src/frontend/ && yarn e2e:test --project='chromium'
@@ -101,7 +101,7 @@ jobs:
test-e2e-other-browser: test-e2e-other-browser:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: test-e2e-chromium needs: test-e2e-chromium
timeout-minutes: 30 timeout-minutes: 20
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -109,7 +109,7 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: "22.x" node-version: "20.x"
- name: Restore the frontend cache - name: Restore the frontend cache
uses: actions/cache@v4 uses: actions/cache@v4
@@ -119,13 +119,13 @@ jobs:
fail-on-cache-miss: true fail-on-cache-miss: true
- name: Set e2e env variables - name: Set e2e env variables
run: cat env.d/development/common.e2e >> env.d/development/common.local run: cat env.d/development/common.e2e.dist >> env.d/development/common.dist
- name: Install Playwright Browsers - name: Install Playwright Browsers
run: cd src/frontend/apps/e2e && yarn install --frozen-lockfile && yarn install-playwright firefox webkit chromium run: cd src/frontend/apps/e2e && yarn install --frozen-lockfile && yarn install-playwright firefox webkit chromium
- name: Start Docker services - name: Start Docker services
run: make bootstrap-e2e FLUSH_ARGS='--no-input' run: make bootstrap FLUSH_ARGS='--no-input' cache=
- name: Run e2e tests - name: Run e2e tests
run: cd src/frontend/ && yarn e2e:test --project=firefox --project=webkit run: cd src/frontend/ && yarn e2e:test --project=firefox --project=webkit
@@ -136,54 +136,3 @@ jobs:
name: playwright-other-report name: playwright-other-report
path: src/frontend/apps/e2e/report/ path: src/frontend/apps/e2e/report/
retention-days: 7 retention-days: 7
bundle-size-check:
runs-on: ubuntu-latest
needs: install-dependencies
if: github.event_name == 'pull_request'
permissions:
contents: read
pull-requests: write
issues: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Detect relevant changes
id: changes
uses: dorny/paths-filter@v2
with:
filters: |
lock:
- 'src/frontend/**/yarn.lock'
app:
- 'src/frontend/apps/impress/**'
- 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: Setup Node.js
if: steps.changes.outputs.lock == 'true' || steps.changes.outputs.app == 'true'
uses: actions/setup-node@v4
with:
node-version: "22.x"
- name: Check bundle size changes
if: steps.changes.outputs.lock == 'true' || steps.changes.outputs.app == 'true'
uses: preactjs/compressed-size-action@v2
with:
repo-token: "${{ secrets.GITHUB_TOKEN }}"
build-script: "app:build"
pattern: "apps/impress/out/**/*.{css,js,html}"
exclude: "{**/*.map,**/node_modules/**}"
minimum-change-threshold: 500
compression: "gzip"
cwd: "./src/frontend"
show-total: true
strip-hash: "[-_.][a-f0-9]{8,}(?=\\.(?:js|css|html)$)"
omit-unchanged: true
install-script: "yarn install --frozen-lockfile"
+2 -9
View File
@@ -25,18 +25,14 @@ jobs:
- name: show - name: show
run: git log run: git log
- name: Enforce absence of print statements in code - name: Enforce absence of print statements in code
if: always()
run: | run: |
! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- . ':(exclude)**/impress.yml' | grep "print(" ! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- . ':(exclude)**/impress.yml' | grep "print("
- name: Check absence of fixup commits - name: Check absence of fixup commits
if: always()
run: | run: |
! git log | grep 'fixup!' ! git log | grep 'fixup!'
- name: Install gitlint - name: Install gitlint
if: always()
run: pip install --user requests gitlint run: pip install --user requests gitlint
- name: Lint commit messages added to main - name: Lint commit messages added to main
if: always()
run: ~/.local/bin/gitlint --commits origin/${{ github.event.pull_request.base.ref }}..HEAD run: ~/.local/bin/gitlint --commits origin/${{ github.event.pull_request.base.ref }}..HEAD
check-changelog: check-changelog:
@@ -79,7 +75,6 @@ jobs:
--check-filenames \ --check-filenames \
--ignore-words-list "Dokument,afterAll,excpt,statics" \ --ignore-words-list "Dokument,afterAll,excpt,statics" \
--skip "./git/" \ --skip "./git/" \
--skip "**/*.pdf" \
--skip "**/*.po" \ --skip "**/*.po" \
--skip "**/*.pot" \ --skip "**/*.pot" \
--skip "**/*.json" \ --skip "**/*.json" \
@@ -94,10 +89,9 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v2 uses: actions/checkout@v2
- name: Install Python - name: Install Python
uses: actions/setup-python@v5 uses: actions/setup-python@v3
with: with:
python-version: "3.13.3" python-version: "3.13.3"
cache: "pip"
- name: Upgrade pip and setuptools - name: Upgrade pip and setuptools
run: pip install --upgrade pip setuptools run: pip install --upgrade pip setuptools
- name: Install development dependencies - name: Install development dependencies
@@ -190,10 +184,9 @@ jobs:
mc version enable impress/impress-media-storage" mc version enable impress/impress-media-storage"
- name: Install Python - name: Install Python
uses: actions/setup-python@v5 uses: actions/setup-python@v3
with: with:
python-version: "3.13.3" python-version: "3.13.3"
cache: "pip"
- name: Install development dependencies - name: Install development dependencies
run: pip install --user .[dev] run: pip install --user .[dev]
-27
View File
@@ -1,27 +0,0 @@
name: Label Preview
on:
pull_request:
types: [labeled, opened]
permissions:
pull-requests: write
jobs:
comment:
runs-on: ubuntu-latest
if: contains(github.event.pull_request.labels.*.name, 'preview')
steps:
- uses: thollander/actions-comment-pull-request@v3
with:
message: |
:rocket: Preview will be available at [https://${{ github.event.pull_request.number }}-docs.ppr-docs.beta.numerique.gouv.fr/](https://${{ github.event.pull_request.number }}-docs.ppr-docs.beta.numerique.gouv.fr/)
You can use the existing account with these credentials:
- username: `docs`
- password: `docs`
You can also create a new account if you want to.
Once this Pull Request is merged, the preview will be destroyed.
comment-tag: preview-url
+2 -4
View File
@@ -40,7 +40,8 @@ venv/
ENV/ ENV/
env.bak/ env.bak/
venv.bak/ venv.bak/
env.d/development/*.local env.d/development/*
!env.d/development/*.dist
env.d/terraform env.d/terraform
# npm # npm
@@ -75,6 +76,3 @@ db.sqlite3
.vscode/ .vscode/
*.iml *.iml
.devcontainer .devcontainer
# Cursor rules
.cursorrules
+42 -349
View File
@@ -1,3 +1,5 @@
# Changelog
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0),
@@ -6,309 +8,6 @@ and this project adheres to
## [Unreleased] ## [Unreleased]
### Fixed
- ♿(frontend) improve accessibility:
- ♿(frontend) improve share modal button accessibility #1626
## [3.10.0] - 2025-11-18
### Added
- ✨(export) enable ODT export for documents #1524
- ✨(frontend) improve mobile UX by showing subdocs count #1540
### Changed
- ♻️(frontend) preserve @ character when esc is pressed after typing it #1512
- ♻️(frontend) make summary button fixed to remain visible during scroll #1581
- ♻️(frontend) pdf embed use full width #1526
### Fixed
- ♿(frontend) improve accessibility:
- ♿(frontend) improve ARIA in doc grid and editor for a11y #1519
- ♿(frontend) improve accessibility and styling of summary table #1528
- ♿(frontend) add focus trap and enter key support to remove doc modal #1531
- 🐛(frontend) fix alignment of side menu #1597
- 🐛(frontend) fix fallback translations with Trans #1620
- 🐛(export) fix image overflow by limiting width to 600px during export #1525
- 🐛(export) fix table cell alignment issue in exported documents #1582
- 🐛(export) preserve image aspect ratio in PDF export #1622
- 🐛(export) Export fails when paste with style #1552
### Security
- mitigate role escalation in the ask_for_access viewset #1580
- 🐛(frontend) preserve left panel width on window resize #1588
### Removed
- 🔥(backend) remove api managing templates
## [3.9.0] - 2025-11-10
### Added
- ✨(frontend) create skeleton component for DocEditor #1491
- ✨(frontend) add an EmojiPicker in the document tree and title #1381
- ✨(frontend) ajustable left panel #1456
- ✨ Add comments feature to the editor #1330
### Changed
- ♻️(frontend) adapt custom blocks to new implementation #1375
- ♻️(backend) increase user short_name field length #1510
- 🚸(frontend) separate viewers from editors #1509
### Fixed
- 🐛(frontend) fix duplicate document entries in grid #1479
- 🐛(backend) fix trashbin list #1520
- ♿(frontend) improve accessibility:
- ♿(frontend) remove empty alt on logo due to Axe a11y error #1516
- 🐛(backend) fix s3 version_id validation #1543
- 🐛(frontend) retry check media status after page reload #1555
- 🐛(frontend) fix Interlinking memory leak #1560
- 🐛(frontend) button new doc UI fix #1557
- 🐛(frontend) interlinking UI fix #1557
## [3.8.2] - 2025-10-17
### Fixed
- 🐛(service-worker) fix sw registration and page reload logic #1500
## [3.8.1] - 2025-10-17
### Fixed
- ⚡️(backend) improve trashbin endpoint performance #1495
- 🐛(backend) manage invitation partial update without email #1494
- ♿(frontend) improve accessibility:
- ♿ add missing aria-label to add sub-doc button for accessibility #1480
- ♿ add missing aria-label to more options button on sub-docs #1481
### Removed
- 🔥(backend) remove treebeard form for the document admin #1470
## [3.8.0] - 2025-10-14
### Added
- ✨(frontend) add pdf block to the editor #1293
- ✨List and restore deleted docs #1450
### Changed
- ♻️(frontend) Refactor Auth component for improved redirection logic #1461
- ♻️(frontend) replace Arial font-family with token font #1411
- ♿(frontend) improve accessibility:
- ♿(frontend) enable enter key to open documentss #1354
- ♿(frontend) improve modal a11y: structure, labels, title #1349
- ♿improve NVDA navigation in DocShareModal #1396
- ♿ improve accessibility by adding landmark roles to layout #1394
- ♿ add document visible in list and openable via enter key #1365
- ♿ add pdf outline property to enable bookmarks display #1368
- ♿ hide decorative icons from assistive tech with aria-hidden #1404
- ♿ fix rgaa 1.9.1: convert to figure/figcaption structure #1426
- ♿ remove redundant aria-label to avoid over-accessibility #1420
- ♿ remove redundant aria-label on hidden icons and update tests #1432
- ♿ improve semantic structure and aria roles of leftpanel #1431
- ♿ add default background to left panel for better accessibility #1423
- ♿ restyle checked checkboxes: removing strikethrough #1439
- ♿ add h1 for SR on 40X pages and remove alt texts #1438
- ♿ update labels and shared document icon accessibility #1442
- 🍱(frontend) Fonts GDPR compliants #1453
- ♻️(service-worker) improve SW registration and update handling #1473
### Fixed
- 🐛(backend) duplicate sub docs as root for reader users #1385
- ⚗️(service-worker) remove index from cache first strategy #1395
- 🐛(frontend) fix 404 page when reload 403 page #1402
- 🐛(frontend) fix legacy role computation #1376
- 🛂(frontend) block editing title when not allowed #1412
- 🐛(frontend) scroll back to top when navigate to a document #1406
- 🐛(frontend) fix export pdf emoji problem #1453
- 🐛(frontend) fix attachment download filename #1447
- 🐛(frontend) exclude h4-h6 headings from table of contents #1441
- 🔒(frontend) prevent readers from changing callout emoji #1449
- 🐛(frontend) fix overlapping placeholders in multi-column layout #1455
- 🐛(backend) filter invitation with case insensitive email #1457
- 🐛(frontend) reduce no access image size from 450 to 300 #1463
- 🐛(frontend) preserve interlink style on drag-and-drop in editor #1460
- ✨(frontend) load docs logo from public folder via url #1462
- 🔧(keycloak) Fix https required issue in dev mode #1286
## Removed
- 🔥(frontend) remove custom DividerBlock ##1375
## [3.7.0] - 2025-09-12
### Added
- ✨(api) add API route to fetch document content #1206
- ✨(frontend) doc emojis improvements #1381
- add an EmojiPicker in the document tree and document title
- remove emoji buttons in menus
### Changed
- 🔒️(backend) configure throttle on every viewsets #1343
- ⬆️ Bump eslint to V9 #1071
- ♿(frontend) improve accessibility:
- ♿fix major accessibility issues reported by wave and axe #1344
- ✨unify tab focus style for better visual consistency #1341
- ✨improve modal a11y: structure, labels, and title #1349
- ✨improve accessibility of cdoc content with correct aria tags #1271
- ✨unify tab focus style for better visual consistency #1341
- ♿hide decorative icons, label menus, avoid accessible name… #1362
- ♻️(tilt) use helm dev-backend chart
- 🩹(frontend) on main pages do not display leading emoji as page icon #1381
- 🩹(frontend) handle properly emojis in interlinking #1381
### Removed
- 🔥(frontend) remove multi column drop cursor #1370
### Fixed
- 🐛(frontend) fix callout emoji list #1366
## [3.6.0] - 2025-09-04
### Added
- ✨(backend) Comments on text editor #1309
- 👷(CI) add bundle size check job #1268
- ✨(frontend) use title first emoji as doc icon in tree #1289
### Changed
- ♻️(docs-app) Switch from Jest tests to Vitest #1269
- ♿(frontend) improve accessibility:
- 🌐(frontend) set html lang attribute dynamically #1248
- ♿(frontend) inject language attribute to pdf export #1235
- ♿(frontend) improve accessibility of search modal #1275
- ♿(frontend) add correct attributes to icons #1255
- 🎨(frontend) improve nav structure #1262
- ♿️(frontend) keyboard interaction with menu #1244
- ♿(frontend) improve header accessibility #1270
- ♿(frontend) improve accessibility for decorative images in editor #1282
- #1338
- #1281
- ♻️(backend) fallback to email identifier when no name #1298
- 🐛(backend) allow ASCII characters in user sub field #1295
- ⚡️(frontend) improve fallback width calculation #1333
### Fixed
- 🐛(makefile) Windows compatibility fix for Docker volume mounting #1263
- 🐛(minio) fix user permission error with Minio and Windows #1263
- 🐛(frontend) fix export when quote block and inline code #1319
- 🐛(frontend) fix base64 font #1324
- 🐛(backend) allow creator to delete subpages #1297
- 🐛(frontend) fix dnd conflict with tree and Blocknote #1328
- 🐛(frontend) fix display bug on homepage #1332
- 🐛link role update #1287
## [3.5.0] - 2025-07-31
### Added
- ✨(helm) Service Account support for K8s Resources in Helm Charts #780
- ✨(backend) allow masking documents from the list view #1172
- ✨(frontend) subdocs can manage link reach #1190
- ✨(frontend) add duplicate action to doc tree #1175
- ✨(frontend) Interlinking doc #904
- ✨(frontend) add multi columns support for editor #1219
### Changed
- ♻️(frontend) search on all docs if no children #1184
- ♻️(frontend) redirect to doc after duplicate #1175
- 🔧(project) change env.d system by using local files #1200
- ⚡️(frontend) improve tree stability #1207
- ⚡️(frontend) improve accessibility #1232
- 🛂(frontend) block drag n drop when not desktop #1239
### Fixed
- 🐛(service-worker) Fix useOffline Maximum update depth exceeded #1196
- 🐛(frontend) fix empty left panel after deleting root doc #1197
- 🐛(helm) charts generate invalid YAML for collaboration API / WS #890
- 🐛(frontend) 401 redirection overridden #1214
- 🐛(frontend) include root parent in search #1243
## [3.4.2] - 2025-07-18
### Changed
- ⚡️(docker) Optimize Dockerfile to use apk with --no-cache #743
### Fixed
- 🐛(backend) improve prompt to not use code blocks delimiter #1188
## [3.4.1] - 2025-07-15
### Fixed
- 🌐(frontend) keep simple tag during export #1154
- 🐛(back) manage can-edit endpoint without created room
in the ws #1152
- 🐛(frontend) fix action buttons not clickable #1162
- 🐛(frontend) fix crash share modal on grid options #1174
- 🐛(frontend) fix unfold subdocs not clickable at the bottom #1179
## [3.4.0] - 2025-07-09
### Added
- ✨(frontend) multi-pages #701
- ✨(frontend) Duplicate a doc #1078
- ✨Ask for access #1081
- ✨(frontend) add customization for translations #857
- ✨(backend) add ancestors links definitions to document abilities #846
- ✨(backend) include ancestors accesses on document accesses list view #846
- ✨(backend) add ancestors links reach and role to document API #846
- 📝(project) add troubleshoot doc #1066
- 📝(project) add system-requirement doc #1066
- 🔧(frontend) configure x-frame-options to DENY in nginx conf #1084
- ✨(backend) allow to disable checking unsafe mimetype on
attachment upload #1099
- ✨(doc) add documentation to install with compose #855
- ✨ Give priority to users connected to collaboration server
(aka no websocket feature) #1093
### Changed
- ♻️(backend) stop requiring owner for non-root documents #846
- ♻️(backend) simplify roles by ranking them and return only the max role #846
- 📌(yjs) stop pinning node to minor version on yjs docker image #1005
- 🧑‍💻(docker) add .next to .dockerignore #1055
- 🧑‍💻(docker) handle frontend development images with docker compose #1033
- 🧑‍💻(docker) add y-provider config to development environment #1057
- ⚡️(frontend) optimize document fetch error handling #1089
### Fixed
- 🐛(backend) fix link definition select options linked to ancestors #846
- 🐛(frontend) table of content disappearing #982
- 🐛(frontend) fix multiple EmojiPicker #1012
- 🐛(frontend) fix meta title #1017
- 🔧(git) set LF line endings for all text files #1032
- 📝(docs) minor fixes to docs/env.md
- ✨support `_FILE` environment variables for secrets #912
### Removed
- 🔥(frontend) remove Beta from logo #1095
## [3.3.0] - 2025-05-06 ## [3.3.0] - 2025-05-06
### Added ### Added
@@ -334,13 +33,13 @@ and this project adheres to
- ⬆️(docker) upgrade node images to alpine 3.21 #973 - ⬆️(docker) upgrade node images to alpine 3.21 #973
### Fixed ### Fixed
- 🐛(y-provider) increase JSON size limits for transcription conversion #989 - 🐛(y-provider) increase JSON size limits for transcription conversion #989
### Removed ### Removed
- 🔥(back) remove footer endpoint #948 - 🔥(back) remove footer endpoint #948
## [3.2.1] - 2025-05-06 ## [3.2.1] - 2025-05-06
## Fixed ## Fixed
@@ -348,6 +47,7 @@ and this project adheres to
- 🐛(frontend) fix list copy paste #943 - 🐛(frontend) fix list copy paste #943
- 📝(doc) update contributing policy (commit signatures are now mandatory) #895 - 📝(doc) update contributing policy (commit signatures are now mandatory) #895
## [3.2.0] - 2025-05-05 ## [3.2.0] - 2025-05-05
## Added ## Added
@@ -358,7 +58,7 @@ and this project adheres to
- ✨(settings) Allow configuring PKCE for the SSO #886 - ✨(settings) Allow configuring PKCE for the SSO #886
- 🌐(i18n) activate chinese and spanish languages #884 - 🌐(i18n) activate chinese and spanish languages #884
- 🔧(backend) allow overwriting the data directory #893 - 🔧(backend) allow overwriting the data directory #893
- (backend) add `django-lasuite` dependency #839 - (backend) add `django-lasuite` dependency #839
- ✨(frontend) advanced table features #908 - ✨(frontend) advanced table features #908
## Changed ## Changed
@@ -409,6 +109,7 @@ and this project adheres to
- 🐛(backend) compute ancestor_links in get_abilities if needed #725 - 🐛(backend) compute ancestor_links in get_abilities if needed #725
- 🔒️(back) restrict access to document accesses #801 - 🔒️(back) restrict access to document accesses #801
## [2.6.0] - 2025-03-21 ## [2.6.0] - 2025-03-21
## Added ## Added
@@ -427,6 +128,7 @@ and this project adheres to
- 🔒️(back) throttle user list endpoint #636 - 🔒️(back) throttle user list endpoint #636
- 🔒️(back) remove pagination and limit to 5 for user list endpoint #636 - 🔒️(back) remove pagination and limit to 5 for user list endpoint #636
## [2.5.0] - 2025-03-18 ## [2.5.0] - 2025-03-18
## Added ## Added
@@ -449,14 +151,15 @@ and this project adheres to
## Fixed ## Fixed
- 🐛(frontend) SVG export #706 - 🐛(frontend) SVG export #706
- 🐛(frontend) remove scroll listener table content #688 - 🐛(frontend) remove scroll listener table content #688
- 🔒️(back) restrict access to favorite_list endpoint #690 - 🔒️(back) restrict access to favorite_list endpoint #690
- 🐛(backend) refactor to fix filtering on children - 🐛(backend) refactor to fix filtering on children
and descendants views #695 and descendants views #695
- 🐛(action) fix notify-argocd workflow #713 - 🐛(action) fix notify-argocd workflow #713
- 🚨(helm) fix helmfile lint #736 - 🚨(helm) fix helmfile lint #736
- 🚚(frontend) redirect to 401 page when 401 error #759 - 🚚(frontend) redirect to 401 page when 401 error #759
## [2.4.0] - 2025-03-06 ## [2.4.0] - 2025-03-06
## Added ## Added
@@ -471,6 +174,7 @@ and this project adheres to
- 🐛(frontend) fix collaboration error #684 - 🐛(frontend) fix collaboration error #684
## [2.3.0] - 2025-03-03 ## [2.3.0] - 2025-03-03
## Added ## Added
@@ -886,44 +590,33 @@ and this project adheres to
- ✨(frontend) Coming Soon page (#67) - ✨(frontend) Coming Soon page (#67)
- 🚀 Impress, project to manage your documents easily and collaboratively. - 🚀 Impress, project to manage your documents easily and collaboratively.
[unreleased]: https://github.com/suitenumerique/docs/compare/v3.10.0...main [unreleased]: https://github.com/numerique-gouv/impress/compare/v3.3.0...main
[v3.10.0]: https://github.com/suitenumerique/docs/releases/v3.10.0 [v3.3.0]: https://github.com/numerique-gouv/impress/releases/v3.3.0
[v3.9.0]: https://github.com/suitenumerique/docs/releases/v3.9.0 [v3.2.1]: https://github.com/numerique-gouv/impress/releases/v3.2.1
[v3.8.2]: https://github.com/suitenumerique/docs/releases/v3.8.2 [v3.2.0]: https://github.com/numerique-gouv/impress/releases/v3.2.0
[v3.8.1]: https://github.com/suitenumerique/docs/releases/v3.8.1 [v3.1.0]: https://github.com/numerique-gouv/impress/releases/v3.1.0
[v3.8.0]: https://github.com/suitenumerique/docs/releases/v3.8.0 [v3.0.0]: https://github.com/numerique-gouv/impress/releases/v3.0.0
[v3.7.0]: https://github.com/suitenumerique/docs/releases/v3.7.0 [v2.6.0]: https://github.com/numerique-gouv/impress/releases/v2.6.0
[v3.6.0]: https://github.com/suitenumerique/docs/releases/v3.6.0 [v2.5.0]: https://github.com/numerique-gouv/impress/releases/v2.5.0
[v3.5.0]: https://github.com/suitenumerique/docs/releases/v3.5.0 [v2.4.0]: https://github.com/numerique-gouv/impress/releases/v2.4.0
[v3.4.2]: https://github.com/suitenumerique/docs/releases/v3.4.2 [v2.3.0]: https://github.com/numerique-gouv/impress/releases/v2.3.0
[v3.4.1]: https://github.com/suitenumerique/docs/releases/v3.4.1 [v2.2.0]: https://github.com/numerique-gouv/impress/releases/v2.2.0
[v3.4.0]: https://github.com/suitenumerique/docs/releases/v3.4.0 [v2.1.0]: https://github.com/numerique-gouv/impress/releases/v2.1.0
[v3.3.0]: https://github.com/suitenumerique/docs/releases/v3.3.0 [v2.0.1]: https://github.com/numerique-gouv/impress/releases/v2.0.1
[v3.2.1]: https://github.com/suitenumerique/docs/releases/v3.2.1 [v2.0.0]: https://github.com/numerique-gouv/impress/releases/v2.0.0
[v3.2.0]: https://github.com/suitenumerique/docs/releases/v3.2.0 [v1.10.0]: https://github.com/numerique-gouv/impress/releases/v1.10.0
[v3.1.0]: https://github.com/suitenumerique/docs/releases/v3.1.0 [v1.9.0]: https://github.com/numerique-gouv/impress/releases/v1.9.0
[v3.0.0]: https://github.com/suitenumerique/docs/releases/v3.0.0 [v1.8.2]: https://github.com/numerique-gouv/impress/releases/v1.8.2
[v2.6.0]: https://github.com/suitenumerique/docs/releases/v2.6.0 [v1.8.1]: https://github.com/numerique-gouv/impress/releases/v1.8.1
[v2.5.0]: https://github.com/suitenumerique/docs/releases/v2.5.0 [v1.8.0]: https://github.com/numerique-gouv/impress/releases/v1.8.0
[v2.4.0]: https://github.com/suitenumerique/docs/releases/v2.4.0 [v1.7.0]: https://github.com/numerique-gouv/impress/releases/v1.7.0
[v2.3.0]: https://github.com/suitenumerique/docs/releases/v2.3.0 [v1.6.0]: https://github.com/numerique-gouv/impress/releases/v1.6.0
[v2.2.0]: https://github.com/suitenumerique/docs/releases/v2.2.0 [1.5.1]: https://github.com/numerique-gouv/impress/releases/v1.5.1
[v2.1.0]: https://github.com/suitenumerique/docs/releases/v2.1.0 [1.5.0]: https://github.com/numerique-gouv/impress/releases/v1.5.0
[v2.0.1]: https://github.com/suitenumerique/docs/releases/v2.0.1 [1.4.0]: https://github.com/numerique-gouv/impress/releases/v1.4.0
[v2.0.0]: https://github.com/suitenumerique/docs/releases/v2.0.0 [1.3.0]: https://github.com/numerique-gouv/impress/releases/v1.3.0
[v1.10.0]: https://github.com/suitenumerique/docs/releases/v1.10.0 [1.2.1]: https://github.com/numerique-gouv/impress/releases/v1.2.1
[v1.9.0]: https://github.com/suitenumerique/docs/releases/v1.9.0 [1.2.0]: https://github.com/numerique-gouv/impress/releases/v1.2.0
[v1.8.2]: https://github.com/suitenumerique/docs/releases/v1.8.2 [1.1.0]: https://github.com/numerique-gouv/impress/releases/v1.1.0
[v1.8.1]: https://github.com/suitenumerique/docs/releases/v1.8.1 [1.0.0]: https://github.com/numerique-gouv/impress/releases/v1.0.0
[v1.8.0]: https://github.com/suitenumerique/docs/releases/v1.8.0 [0.1.0]: https://github.com/numerique-gouv/impress/releases/v0.1.0
[v1.7.0]: https://github.com/suitenumerique/docs/releases/v1.7.0
[v1.6.0]: https://github.com/suitenumerique/docs/releases/v1.6.0
[1.5.1]: https://github.com/suitenumerique/docs/releases/v1.5.1
[1.5.0]: https://github.com/suitenumerique/docs/releases/v1.5.0
[1.4.0]: https://github.com/suitenumerique/docs/releases/v1.4.0
[1.3.0]: https://github.com/suitenumerique/docs/releases/v1.3.0
[1.2.1]: https://github.com/suitenumerique/docs/releases/v1.2.1
[1.2.0]: https://github.com/suitenumerique/docs/releases/v1.2.0
[1.1.0]: https://github.com/suitenumerique/docs/releases/v1.1.0
[1.0.0]: https://github.com/suitenumerique/docs/releases/v1.0.0
[0.1.0]: https://github.com/suitenumerique/docs/releases/v0.1.0
+5 -12
View File
@@ -7,7 +7,8 @@ FROM python:3.13.3-alpine AS base
RUN python -m pip install --upgrade pip setuptools RUN python -m pip install --upgrade pip setuptools
# Upgrade system packages to install security updates # Upgrade system packages to install security updates
RUN apk update && apk upgrade --no-cache RUN apk update && \
apk upgrade
# ---- Back-end builder image ---- # ---- Back-end builder image ----
FROM base AS back-builder FROM base AS back-builder
@@ -44,7 +45,7 @@ FROM base AS link-collector
ARG IMPRESS_STATIC_ROOT=/data/static ARG IMPRESS_STATIC_ROOT=/data/static
# Install pango & rdfind # Install pango & rdfind
RUN apk add --no-cache \ RUN apk add \
pango \ pango \
rdfind rdfind
@@ -70,7 +71,7 @@ FROM base AS core
ENV PYTHONUNBUFFERED=1 ENV PYTHONUNBUFFERED=1
# Install required system libs # Install required system libs
RUN apk add --no-cache \ RUN apk add \
cairo \ cairo \
file \ file \
font-noto \ font-noto \
@@ -94,14 +95,6 @@ RUN chmod g=u /etc/passwd
# Copy installed python dependencies # Copy installed python dependencies
COPY --from=back-builder /install /usr/local COPY --from=back-builder /install /usr/local
# Link certifi certificate from a static path /cert/cacert.pem to avoid issues
# when python is upgraded and the path to the certificate changes.
# The space between print and the ( is intended otherwise the git lint is failing
RUN mkdir /cert && \
path=`python -c 'import certifi;print (certifi.where())'` && \
mv $path /cert/ && \
ln -s /cert/cacert.pem $path
# Copy impress application (see .dockerignore) # Copy impress application (see .dockerignore)
COPY ./src/backend /app/ COPY ./src/backend /app/
@@ -124,7 +117,7 @@ FROM core AS backend-development
USER root:root USER root:root
# Install psql # Install psql
RUN apk add --no-cache postgresql-client RUN apk add postgresql-client
# Uninstall impress and re-install it in editable mode along with development # Uninstall impress and re-install it in editable mode along with development
# dependencies # dependencies
+41 -132
View File
@@ -35,15 +35,10 @@ DB_PORT = 5432
# -- Docker # -- Docker
# Get the current user ID to use for docker run and docker exec commands # Get the current user ID to use for docker run and docker exec commands
ifeq ($(OS),Windows_NT) DOCKER_UID = $(shell id -u)
DOCKER_USER := 0:0 # run containers as root on Windows DOCKER_GID = $(shell id -g)
else DOCKER_USER = $(DOCKER_UID):$(DOCKER_GID)
DOCKER_UID := $(shell id -u)
DOCKER_GID := $(shell id -g)
DOCKER_USER := $(DOCKER_UID):$(DOCKER_GID)
endif
COMPOSE = DOCKER_USER=$(DOCKER_USER) docker compose COMPOSE = DOCKER_USER=$(DOCKER_USER) docker compose
COMPOSE_E2E = DOCKER_USER=$(DOCKER_USER) docker compose -f compose.yml -f compose-e2e.yml
COMPOSE_EXEC = $(COMPOSE) exec COMPOSE_EXEC = $(COMPOSE) exec
COMPOSE_EXEC_APP = $(COMPOSE_EXEC) app-dev COMPOSE_EXEC_APP = $(COMPOSE_EXEC) app-dev
COMPOSE_RUN = $(COMPOSE) run --rm COMPOSE_RUN = $(COMPOSE) run --rm
@@ -52,7 +47,7 @@ COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
# -- Backend # -- Backend
MANAGE = $(COMPOSE_RUN_APP) python manage.py MANAGE = $(COMPOSE_RUN_APP) python manage.py
MAIL_YARN = $(COMPOSE_RUN) -w //app/src/mail node yarn MAIL_YARN = $(COMPOSE_RUN) -w /app/src/mail node yarn
# -- Frontend # -- Frontend
PATH_FRONT = ./src/frontend PATH_FRONT = ./src/frontend
@@ -71,111 +66,30 @@ data/static:
# -- Project # -- Project
create-env-local-files: ## create env.local files in env.d/development create-env-files: ## Copy the dist env files to env files
create-env-local-files: create-env-files: \
@touch env.d/development/crowdin.local env.d/development/common \
@touch env.d/development/common.local env.d/development/crowdin \
@touch env.d/development/postgresql.local env.d/development/postgresql \
@touch env.d/development/kc_postgresql.local env.d/development/kc_postgresql
.PHONY: create-env-local-files .PHONY: create-env-files
pre-bootstrap: \ bootstrap: ## Prepare Docker images for the project
bootstrap: \
data/media \ data/media \
data/static \ data/static \
create-env-local-files create-env-files \
.PHONY: pre-bootstrap build \
post-bootstrap: \
migrate \ migrate \
demo \ demo \
back-i18n-compile \ back-i18n-compile \
mails-install \ mails-install \
mails-build mails-build \
.PHONY: post-bootstrap run
pre-beautiful-bootstrap: ## Display a welcome message before bootstrap
ifeq ($(OS),Windows_NT)
@echo ""
@echo "================================================================================"
@echo ""
@echo " Welcome to Docs - Collaborative Text Editing from La Suite!"
@echo ""
@echo " This will set up your development environment with:"
@echo " - Docker containers for all services"
@echo " - Database migrations and static files"
@echo " - Frontend dependencies and build"
@echo " - Environment configuration files"
@echo ""
@echo " Services will be available at:"
@echo " - Frontend: http://localhost:3000"
@echo " - API: http://localhost:8071"
@echo " - Admin: http://localhost:8071/admin"
@echo ""
@echo "================================================================================"
@echo ""
@echo "Starting bootstrap process..."
else
@echo "$(BOLD)"
@echo "╔══════════════════════════════════════════════════════════════════════════════╗"
@echo "║ ║"
@echo "║ 🚀 Welcome to Docs - Collaborative Text Editing from La Suite ! 🚀 ║"
@echo "║ ║"
@echo "║ This will set up your development environment with : ║"
@echo "║ • Docker containers for all services ║"
@echo "║ • Database migrations and static files ║"
@echo "║ • Frontend dependencies and build ║"
@echo "║ • Environment configuration files ║"
@echo "║ ║"
@echo "║ Services will be available at: ║"
@echo "║ • Frontend: http://localhost:3000 ║"
@echo "║ • API: http://localhost:8071 ║"
@echo "║ • Admin: http://localhost:8071/admin ║"
@echo "║ ║"
@echo "╚══════════════════════════════════════════════════════════════════════════════╝"
@echo "$(RESET)"
@echo "$(GREEN)Starting bootstrap process...$(RESET)"
endif
@echo ""
.PHONY: pre-beautiful-bootstrap
post-beautiful-bootstrap: ## Display a success message after bootstrap
@echo ""
ifeq ($(OS),Windows_NT)
@echo "Bootstrap completed successfully!"
@echo ""
@echo "Next steps:"
@echo " - Visit http://localhost:3000 to access the application"
@echo " - Run 'make help' to see all available commands"
else
@echo "$(GREEN)🎉 Bootstrap completed successfully!$(RESET)"
@echo ""
@echo "$(BOLD)Next steps:$(RESET)"
@echo " • Visit http://localhost:3000 to access the application"
@echo " • Run 'make help' to see all available commands"
endif
@echo ""
.PHONY: post-beautiful-bootstrap
bootstrap: ## Prepare the project for local development
bootstrap: \
pre-beautiful-bootstrap \
pre-bootstrap \
build \
post-bootstrap \
run \
post-beautiful-bootstrap
.PHONY: bootstrap .PHONY: bootstrap
bootstrap-e2e: ## Prepare Docker production images to be used for e2e tests
bootstrap-e2e: \
pre-bootstrap \
build-e2e \
post-bootstrap \
run-e2e
.PHONY: bootstrap-e2e
# -- Docker/compose # -- Docker/compose
build: cache ?= build: cache ?= --no-cache
build: ## build the project containers build: ## build the project containers
@$(MAKE) build-backend cache=$(cache) @$(MAKE) build-backend cache=$(cache)
@$(MAKE) build-yjs-provider cache=$(cache) @$(MAKE) build-yjs-provider cache=$(cache)
@@ -189,23 +103,16 @@ build-backend: ## build the app-dev container
build-yjs-provider: cache ?= build-yjs-provider: cache ?=
build-yjs-provider: ## build the y-provider container build-yjs-provider: ## build the y-provider container
@$(COMPOSE) build y-provider-development $(cache) @$(COMPOSE) build y-provider $(cache)
.PHONY: build-yjs-provider .PHONY: build-yjs-provider
build-frontend: cache ?= build-frontend: cache ?=
build-frontend: ## build the frontend container build-frontend: ## build the frontend container
@$(COMPOSE) build frontend-development $(cache) @$(COMPOSE) build frontend $(cache)
.PHONY: build-frontend .PHONY: build-frontend
build-e2e: cache ?=
build-e2e: ## build the e2e container
@$(MAKE) build-backend cache=$(cache)
@$(COMPOSE_E2E) build frontend $(cache)
@$(COMPOSE_E2E) build y-provider $(cache)
.PHONY: build-e2e
down: ## stop and remove containers, networks, images, and volumes down: ## stop and remove containers, networks, images, and volumes
@$(COMPOSE_E2E) down @$(COMPOSE) down
.PHONY: down .PHONY: down
logs: ## display app-dev logs (follow mode) logs: ## display app-dev logs (follow mode)
@@ -214,30 +121,22 @@ logs: ## display app-dev logs (follow mode)
run-backend: ## Start only the backend application and all needed services run-backend: ## Start only the backend application and all needed services
@$(COMPOSE) up --force-recreate -d celery-dev @$(COMPOSE) up --force-recreate -d celery-dev
@$(COMPOSE) up --force-recreate -d y-provider-development @$(COMPOSE) up --force-recreate -d y-provider
@$(COMPOSE) up --force-recreate -d nginx @$(COMPOSE) up --force-recreate -d nginx
.PHONY: run-backend .PHONY: run-backend
run: ## start the wsgi (production) and development server run: ## start the wsgi (production) and development server
run: run:
@$(MAKE) run-backend @$(MAKE) run-backend
@$(COMPOSE) up --force-recreate -d frontend-development @$(COMPOSE) up --force-recreate -d frontend
.PHONY: run .PHONY: run
run-e2e: ## start the e2e server
run-e2e:
@$(MAKE) run-backend
@$(COMPOSE_E2E) stop y-provider-development
@$(COMPOSE_E2E) up --force-recreate -d frontend
@$(COMPOSE_E2E) up --force-recreate -d y-provider
.PHONY: run-e2e
status: ## an alias for "docker compose ps" status: ## an alias for "docker compose ps"
@$(COMPOSE_E2E) ps @$(COMPOSE) ps
.PHONY: status .PHONY: status
stop: ## stop the development server using Docker stop: ## stop the development server using Docker
@$(COMPOSE_E2E) stop @$(COMPOSE) stop
.PHONY: stop .PHONY: stop
# -- Backend # -- Backend
@@ -326,6 +225,20 @@ resetdb: ## flush database and create a superuser "admin"
@${MAKE} superuser @${MAKE} superuser
.PHONY: resetdb .PHONY: resetdb
env.d/development/common:
cp -n env.d/development/common.dist env.d/development/common
env.d/development/postgresql:
cp -n env.d/development/postgresql.dist env.d/development/postgresql
env.d/development/kc_postgresql:
cp -n env.d/development/kc_postgresql.dist env.d/development/kc_postgresql
# -- Internationalization
env.d/development/crowdin:
cp -n env.d/development/crowdin.dist env.d/development/crowdin
crowdin-download: ## Download translated message from crowdin crowdin-download: ## Download translated message from crowdin
@$(COMPOSE_RUN_CROWDIN) download -c crowdin/config.yml @$(COMPOSE_RUN_CROWDIN) download -c crowdin/config.yml
.PHONY: crowdin-download .PHONY: crowdin-download
@@ -402,14 +315,10 @@ frontend-lint: ## run the frontend linter
.PHONY: frontend-lint .PHONY: frontend-lint
run-frontend-development: ## Run the frontend in development mode run-frontend-development: ## Run the frontend in development mode
@$(COMPOSE) stop frontend-development @$(COMPOSE) stop frontend
cd $(PATH_FRONT_IMPRESS) && yarn dev cd $(PATH_FRONT_IMPRESS) && yarn dev
.PHONY: run-frontend-development .PHONY: run-frontend-development
frontend-test: ## Run the frontend tests
cd $(PATH_FRONT_IMPRESS) && yarn test
.PHONY: frontend-test
frontend-i18n-extract: ## Extract the frontend translation inside a json to be used for crowdin frontend-i18n-extract: ## Extract the frontend translation inside a json to be used for crowdin
cd $(PATH_FRONT) && yarn i18n:extract cd $(PATH_FRONT) && yarn i18n:extract
.PHONY: frontend-i18n-extract .PHONY: frontend-i18n-extract
@@ -440,6 +349,6 @@ bump-packages-version: ## bump the version of the project - VERSION_TYPE can be
cd ./src/frontend/apps/e2e/ && yarn version --no-git-tag-version --$(VERSION_TYPE) cd ./src/frontend/apps/e2e/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
cd ./src/frontend/apps/impress/ && yarn version --no-git-tag-version --$(VERSION_TYPE) cd ./src/frontend/apps/impress/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
cd ./src/frontend/servers/y-provider/ && yarn version --no-git-tag-version --$(VERSION_TYPE) cd ./src/frontend/servers/y-provider/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
cd ./src/frontend/packages/eslint-plugin-docs/ && yarn version --no-git-tag-version --$(VERSION_TYPE) cd ./src/frontend/packages/eslint-config-impress/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
cd ./src/frontend/packages/i18n/ && yarn version --no-git-tag-version --$(VERSION_TYPE) cd ./src/frontend/packages/i18n/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
.PHONY: bump-packages-version .PHONY: bump-packages-version
+13 -28
View File
@@ -11,7 +11,7 @@
<img alt="GitHub commit activity" src="https://img.shields.io/github/commit-activity/m/suitenumerique/docs"/> <img alt="GitHub commit activity" src="https://img.shields.io/github/commit-activity/m/suitenumerique/docs"/>
<img alt="GitHub closed issues" src="https://img.shields.io/github/issues-closed/suitenumerique/docs"/> <img alt="GitHub closed issues" src="https://img.shields.io/github/issues-closed/suitenumerique/docs"/>
<a href="https://github.com/suitenumerique/docs/blob/main/LICENSE"> <a href="https://github.com/suitenumerique/docs/blob/main/LICENSE">
<img alt="MIT License" src="https://img.shields.io/github/license/suitenumerique/docs"/> <img alt="GitHub closed issues" src="https://img.shields.io/github/license/suitenumerique/docs"/>
</a> </a>
</p> </p>
<p align="center"> <p align="center">
@@ -34,6 +34,8 @@ Docs, where your notes can become knowledge through live collaboration.
## Why use Docs ❓ ## Why use Docs ❓
Docs is a collaborative text editor designed to address common challenges in knowledge building and sharing. Docs is a collaborative text editor designed to address common challenges in knowledge building and sharing.
It offers a scalable and secure alternative to tools such as Google Docs, Notion (without the dbs), Outline, or Confluence.
### Write ### Write
* 😌 Get simple, accessible online editing for your team. * 😌 Get simple, accessible online editing for your team.
* 💅 Create clean documents with beautiful formatting options. * 💅 Create clean documents with beautiful formatting options.
@@ -49,24 +51,13 @@ Docs is a collaborative text editor designed to address common challenges in kno
* 📚 Turn your team's collaborative work into organized knowledge with Subpages. * 📚 Turn your team's collaborative work into organized knowledge with Subpages.
### Self-host ### Self-host
🚀 Docs is easy to install on your own servers
#### 🚀 Docs is easy to install on your own servers Available methods: Helm chart, Nix package
We use Kubernetes for our [production instance](https://docs.numerique.gouv.fr/) but also support Docker Compose. The community contributed a couple other methods (Nix, YunoHost etc.) check out the [docs](/docs/installation/README.md) to get detailed instructions and examples.
#### 🌍 Known instances In the works: Docker Compose, YunoHost
We hope to see many more, here is an incomplete list of public Docs instances. Feel free to make a PR to add ones that are not listed below🙏
| Url | Org | Public | ⚠️ For some advanced features (ex: Export as PDF) Docs relies on XL packages from BlockNote. These are licenced under AGPL-3.0 and are not MIT compatible. You can perfectly use Docs without these packages by setting the environment variable `PUBLISH_AS_MIT` to true. That way you'll build an image of the application without the features that are not MIT compatible. Read the [environment variables documentation](/docs/docs/env.md) for more information.
| --- | --- | ------- |
| [docs.numerique.gouv.fr](https://docs.numerique.gouv.fr/) | DINUM | French public agents working for the central administration and the extended public sphere. ProConnect is required to login in or sign up|
| [docs.suite.anct.gouv.fr](https://docs.suite.anct.gouv.fr/) | ANCT | French public agents working for the territorial administration and the extended public sphere. ProConnect is required to login in or sign up|
| [notes.demo.opendesk.eu](https://notes.demo.opendesk.eu) | ZenDiS | Demo instance of OpenDesk. Request access to get credentials |
| [notes.liiib.re](https://notes.liiib.re/) | lasuite.coop | Free and open demo to all. Content and accounts are reset after one month |
| [docs.federated.nexus](https://docs.federated.nexus/) | federated.nexus | Public instance, but you have to [sign up for a Federated Nexus account](https://federated.nexus/register/). |
| [docs.demo.mosacloud.eu](https://docs.demo.mosacloud.eu/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. |
#### ⚠️ Advanced features
For some advanced features (ex: Export as PDF) Docs relies on XL packages from BlockNote. These are licenced under GPL and are not MIT compatible. You can perfectly use Docs without these packages by setting the environment variable `PUBLISH_AS_MIT` to true. That way you'll build an image of the application without the features that are not MIT compatible. Read the [environment variables documentation](/docs/env.md) for more information.
## Getting started 🔧 ## Getting started 🔧
@@ -102,11 +93,11 @@ The easiest way to start working on the project is to use [GNU Make](https://www
$ make bootstrap FLUSH_ARGS='--no-input' $ make bootstrap FLUSH_ARGS='--no-input'
``` ```
This command builds the `app-dev` and `frontend-dev` containers, installs dependencies, performs database migrations and compiles 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 compiles 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.
Your Docker services should now be up and running 🎉 Your Docker services should now be up and running 🎉
You can access 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: You will be prompted to log in. The default credentials are:
@@ -115,7 +106,7 @@ username: impress
password: impress password: impress
``` ```
📝 Note that if you need to run them afterwards, you can use the eponymous Make rule: 📝 Note that if you need to run them afterwards, you can use the eponym Make rule:
```shellscript ```shellscript
$ make run $ make run
@@ -141,12 +132,6 @@ To start all the services, except the frontend container, you can use the follow
$ make run-backend $ make run-backend
``` ```
To execute frontend tests & linting only
```shellscript
$ make frontend-test
$ make frontend-lint
```
**Adding content** **Adding content**
You can create a basic demo site by running this command: You can create a basic demo site by running this command:
@@ -177,15 +162,15 @@ $ make superuser
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). 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).
## Roadmap 💡 ## Roadmap
Want to know where the project is headed? [🗺️ Checkout our roadmap](https://github.com/orgs/numerique-gouv/projects/13/views/11) Want to know where the project is headed? [🗺️ Checkout our roadmap](https://github.com/orgs/numerique-gouv/projects/13/views/11)
## License 📝 ## Licence 📝
This work is released under the MIT License (see [LICENSE](https://github.com/suitenumerique/docs/blob/main/LICENSE)). 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 license choice is an invitation for private sector actors to use, sell and contribute to the project. 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 🙌 ## Contributing 🙌
+1 -1
View File
@@ -18,7 +18,7 @@ the following command inside your docker container:
## [3.3.0] - 2025-05-22 ## [3.3.0] - 2025-05-22
⚠️ For some advanced features (ex: Export as PDF) Docs relies on XL packages from BlockNote. These are licenced under AGPL-3.0 and are not MIT compatible. You can perfectly use Docs without these packages by setting the environment variable `PUBLISH_AS_MIT` to true. That way you'll build an image of the application without the features that are not MIT compatible. Read the [environment variables documentation](/docs/env.md) for more information. ⚠️ For some advanced features (ex: Export as PDF) Docs relies on XL packages from BlockNote. These are licenced under AGPL-3.0 and are not MIT compatible. You can perfectly use Docs without these packages by setting the environment variable `PUBLISH_AS_MIT` to true. That way you'll build an image of the application without the features that are not MIT compatible. Read the [environment variables documentation](/docs/docs/env.md) for more information.
The footer is now configurable from a customization file. To override the default one, you can The footer is now configurable from a customization file. To override the default one, you can
use the `THEME_CUSTOMIZATION_FILE_PATH` environment variable to point to your customization file. use the `THEME_CUSTOMIZATION_FILE_PATH` environment variable to point to your customization file.
+2 -3
View File
@@ -39,10 +39,9 @@ docker_build(
] ]
) )
k8s_resource('impress-docs-backend-migrate', resource_deps=['dev-backend-postgres']) k8s_resource('impress-docs-backend-migrate', resource_deps=['postgres-postgresql'])
k8s_resource('impress-docs-backend-createsuperuser', resource_deps=['impress-docs-backend-migrate']) k8s_resource('impress-docs-backend-createsuperuser', resource_deps=['impress-docs-backend-migrate'])
k8s_resource('dev-backend-keycloak', resource_deps=['dev-backend-keycloak-pg']) k8s_resource('impress-docs-backend', resource_deps=['impress-docs-backend-migrate'])
k8s_resource('impress-docs-backend', resource_deps=['impress-docs-backend-migrate', 'dev-backend-redis', 'dev-backend-keycloak', 'dev-backend-postgres', 'dev-backend-minio:statefulset'])
k8s_yaml(local('cd ../src/helm && helmfile -n impress -e dev template .')) k8s_yaml(local('cd ../src/helm && helmfile -n impress -e dev template .'))
migration = ''' migration = '''
+1 -5
View File
@@ -6,7 +6,7 @@ REPO_DIR="$(cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd)"
UNSET_USER=0 UNSET_USER=0
TERRAFORM_DIRECTORY="./env.d/terraform" TERRAFORM_DIRECTORY="./env.d/terraform"
COMPOSE_FILE="${REPO_DIR}/compose.yml" COMPOSE_FILE="${REPO_DIR}/docker-compose.yml"
# _set_user: set (or unset) default user id used to run docker commands # _set_user: set (or unset) default user id used to run docker commands
@@ -38,10 +38,6 @@ function _set_user() {
# options: docker compose command options # options: docker compose command options
# ARGS : docker compose command arguments # ARGS : docker compose command arguments
function _docker_compose() { function _docker_compose() {
# Set DOCKER_USER for Windows compatibility with MinIO
if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" || -n "${WSL_DISTRO_NAME:-}" ]]; then
export DOCKER_USER="0:0"
fi
echo "🐳(compose) file: '${COMPOSE_FILE}'" echo "🐳(compose) file: '${COMPOSE_FILE}'"
docker compose \ docker compose \
-29
View File
@@ -1,29 +0,0 @@
services:
frontend:
user: "${DOCKER_USER:-1000}"
build:
context: .
dockerfile: ./src/frontend/Dockerfile
target: frontend-production
args:
API_ORIGIN: "http://localhost:8071"
PUBLISH_AS_MIT: "false"
SW_DEACTIVATED: "true"
image: impress:frontend-production
ports:
- "3000:3000"
y-provider:
user: ${DOCKER_USER:-1000}
build:
context: .
dockerfile: ./src/frontend/servers/y-provider/Dockerfile
target: y-provider
image: impress:y-provider-production
restart: unless-stopped
env_file:
- env.d/development/common
- env.d/development/common.local
ports:
- "4444:4444"
+47 -26
View File
@@ -10,7 +10,6 @@ services:
retries: 300 retries: 300
env_file: env_file:
- env.d/development/postgresql - env.d/development/postgresql
- env.d/development/postgresql.local
ports: ports:
- "15432:5432" - "15432:5432"
@@ -67,9 +66,7 @@ services:
- DJANGO_CONFIGURATION=Development - DJANGO_CONFIGURATION=Development
env_file: env_file:
- env.d/development/common - env.d/development/common
- env.d/development/common.local
- env.d/development/postgresql - env.d/development/postgresql
- env.d/development/postgresql.local
ports: ports:
- "8071:8000" - "8071:8000"
volumes: volumes:
@@ -94,15 +91,47 @@ services:
- DJANGO_CONFIGURATION=Development - DJANGO_CONFIGURATION=Development
env_file: env_file:
- env.d/development/common - env.d/development/common
- env.d/development/common.local
- env.d/development/postgresql - env.d/development/postgresql
- env.d/development/postgresql.local
volumes: volumes:
- ./src/backend:/app - ./src/backend:/app
- ./data/static:/data/static - ./data/static:/data/static
depends_on: depends_on:
- app-dev - app-dev
app:
build:
context: .
target: backend-production
args:
DOCKER_USER: ${DOCKER_USER:-1000}
user: ${DOCKER_USER:-1000}
image: impress:backend-production
environment:
- DJANGO_CONFIGURATION=Demo
env_file:
- env.d/development/common
- env.d/development/postgresql
depends_on:
postgresql:
condition: service_healthy
restart: true
redis:
condition: service_started
minio:
condition: service_started
celery:
user: ${DOCKER_USER:-1000}
image: impress:backend-production
command: ["celery", "-A", "impress.celery_app", "worker", "-l", "INFO"]
environment:
- DJANGO_CONFIGURATION=Demo
env_file:
- env.d/development/common
- env.d/development/postgresql
depends_on:
- app
nginx: nginx:
image: nginx:1.25 image: nginx:1.25
ports: ports:
@@ -112,25 +141,23 @@ services:
depends_on: depends_on:
app-dev: app-dev:
condition: service_started condition: service_started
y-provider:
condition: service_started
keycloak: keycloak:
condition: service_healthy condition: service_healthy
restart: true restart: true
frontend-development: frontend:
user: "${DOCKER_USER:-1000}" user: "${DOCKER_USER:-1000}"
build: build:
context: . context: .
dockerfile: ./src/frontend/Dockerfile dockerfile: ./src/frontend/Dockerfile
target: impress-dev target: frontend-production
args: args:
API_ORIGIN: "http://localhost:8071" API_ORIGIN: "http://localhost:8071"
PUBLISH_AS_MIT: "false" PUBLISH_AS_MIT: "false"
SW_DEACTIVATED: "true" SW_DEACTIVATED: "true"
image: impress:frontend-development image: impress:frontend-development
volumes:
- ./src/frontend:/home/frontend
- /home/frontend/node_modules
- /home/frontend/apps/impress/node_modules
ports: ports:
- "3000:3000" - "3000:3000"
@@ -140,35 +167,28 @@ services:
- ".:/app" - ".:/app"
env_file: env_file:
- env.d/development/crowdin - env.d/development/crowdin
- env.d/development/crowdin.local
user: "${DOCKER_USER:-1000}" user: "${DOCKER_USER:-1000}"
working_dir: /app working_dir: /app
node: node:
image: node:22 image: node:18
user: "${DOCKER_USER:-1000}" user: "${DOCKER_USER:-1000}"
environment: environment:
HOME: /tmp HOME: /tmp
volumes: volumes:
- ".:/app" - ".:/app"
y-provider-development: y-provider:
user: ${DOCKER_USER:-1000} user: ${DOCKER_USER:-1000}
build: build:
context: . context: .
dockerfile: ./src/frontend/servers/y-provider/Dockerfile dockerfile: ./src/frontend/servers/y-provider/Dockerfile
target: y-provider-development target: y-provider
image: impress:y-provider-development
restart: unless-stopped restart: unless-stopped
env_file: env_file:
- env.d/development/common - env.d/development/common
- env.d/development/common.local
ports: ports:
- "4444:4444" - "4444:4444"
volumes:
- ./src/frontend/:/home/frontend
- /home/frontend/node_modules
- /home/frontend/servers/y-provider/node_modules
kc_postgresql: kc_postgresql:
image: postgres:14.3 image: postgres:14.3
@@ -181,23 +201,24 @@ services:
- "5433:5432" - "5433:5432"
env_file: env_file:
- env.d/development/kc_postgresql - env.d/development/kc_postgresql
- env.d/development/kc_postgresql.local
keycloak: keycloak:
image: quay.io/keycloak/keycloak:26.3 image: quay.io/keycloak/keycloak:20.0.1
volumes: volumes:
- ./docker/auth/realm.json:/opt/keycloak/data/import/realm.json - ./docker/auth/realm.json:/opt/keycloak/data/import/realm.json
command: command:
- start-dev - start-dev
- --features=preview - --features=preview
- --import-realm - --import-realm
- --hostname=http://localhost:8083 - --proxy=edge
- --hostname-url=http://localhost:8083
- --hostname-admin-url=http://localhost:8083/
- --hostname-strict=false - --hostname-strict=false
- --hostname-strict-https=false
- --health-enabled=true - --health-enabled=true
- --metrics-enabled=true - --metrics-enabled=true
healthcheck: healthcheck:
test: ['CMD-SHELL', 'exec 3<>/dev/tcp/localhost/9000; echo -e "GET /health/live HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n" >&3; grep "HTTP/1.1 200 OK" <&3'] test: ["CMD", "curl", "--head", "-fsS", "http://localhost:8080/health/ready"]
start_period: 5s
interval: 1s interval: 1s
timeout: 2s timeout: 2s
retries: 300 retries: 300
+5 -5
View File
@@ -26,7 +26,7 @@
"oauth2DeviceCodeLifespan": 600, "oauth2DeviceCodeLifespan": 600,
"oauth2DevicePollingInterval": 5, "oauth2DevicePollingInterval": 5,
"enabled": true, "enabled": true,
"sslRequired": "none", "sslRequired": "external",
"registrationAllowed": true, "registrationAllowed": true,
"registrationEmailAsUsername": false, "registrationEmailAsUsername": false,
"rememberMe": true, "rememberMe": true,
@@ -60,7 +60,7 @@
}, },
{ {
"username": "user-e2e-chromium", "username": "user-e2e-chromium",
"email": "user.test@chromium.test", "email": "user@chromium.e2e",
"firstName": "E2E", "firstName": "E2E",
"lastName": "Chromium", "lastName": "Chromium",
"enabled": true, "enabled": true,
@@ -74,7 +74,7 @@
}, },
{ {
"username": "user-e2e-webkit", "username": "user-e2e-webkit",
"email": "user.test@webkit.test", "email": "user@webkit.e2e",
"firstName": "E2E", "firstName": "E2E",
"lastName": "Webkit", "lastName": "Webkit",
"enabled": true, "enabled": true,
@@ -88,7 +88,7 @@
}, },
{ {
"username": "user-e2e-firefox", "username": "user-e2e-firefox",
"email": "user.test@firefox.test", "email": "user@firefox.e2e",
"firstName": "E2E", "firstName": "E2E",
"lastName": "Firefox", "lastName": "Firefox",
"enabled": true, "enabled": true,
@@ -2270,7 +2270,7 @@
"cibaInterval": "5", "cibaInterval": "5",
"realmReusableOtpCode": "false" "realmReusableOtpCode": "false"
}, },
"keycloakVersion": "26.3.2", "keycloakVersion": "20.0.1",
"userManagedAccessAllowed": false, "userManagedAccessAllowed": false,
"clientProfiles": { "clientProfiles": {
"profiles": [] "profiles": []
@@ -1,115 +0,0 @@
upstream docs_backend {
server ${BACKEND_HOST}:8000 fail_timeout=0;
}
upstream docs_frontend {
server ${FRONTEND_HOST}:3000 fail_timeout=0;
}
server {
listen 8083;
server_name localhost;
charset utf-8;
# increase max upload size
client_max_body_size 10m;
# Disables server version feedback on pages and in headers
server_tokens off;
proxy_ssl_server_name on;
location @proxy_to_docs_backend {
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_redirect off;
proxy_pass http://docs_backend;
}
location @proxy_to_docs_frontend {
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_redirect off;
proxy_pass http://docs_frontend;
}
location / {
try_files $uri @proxy_to_docs_frontend;
}
location /api {
try_files $uri @proxy_to_docs_backend;
}
location /admin {
try_files $uri @proxy_to_docs_backend;
}
location /static {
try_files $uri @proxy_to_docs_backend;
}
# Proxy auth for collaboration server
location /collaboration/ws/ {
# Ensure WebSocket upgrade
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
# Collaboration server
proxy_pass http://${YPROVIDER_HOST}:4444;
# Set appropriate timeout for WebSocket
proxy_read_timeout 86400;
proxy_send_timeout 86400;
# Preserve original host and additional headers
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Origin $http_origin;
proxy_set_header Host $host;
}
location /collaboration/api/ {
# Collaboration server
proxy_pass http://${YPROVIDER_HOST}:4444;
proxy_set_header Host $host;
}
# Proxy auth for media
location /media/ {
# Auth request configuration
auth_request /media-auth;
auth_request_set $authHeader $upstream_http_authorization;
auth_request_set $authDate $upstream_http_x_amz_date;
auth_request_set $authContentSha256 $upstream_http_x_amz_content_sha256;
# Pass specific headers from the auth response
proxy_set_header Authorization $authHeader;
proxy_set_header X-Amz-Date $authDate;
proxy_set_header X-Amz-Content-SHA256 $authContentSha256;
# Get resource from Minio
proxy_pass https://${S3_HOST}/${BUCKET_NAME}/;
proxy_set_header Host ${S3_HOST};
proxy_ssl_name ${S3_HOST};
add_header Content-Security-Policy "default-src 'none'" always;
}
location /media-auth {
proxy_pass http://docs_backend/api/v1.0/documents/media-auth/;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Original-URL $request_uri;
# Prevent the body from being passed
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Original-Method $request_method;
}
}
+99 -101
View File
@@ -6,103 +6,102 @@ Here we describe all environment variables that can be set for the docs applicat
These are the environment variables you can set for the `impress-backend` container. These are the environment variables you can set for the `impress-backend` container.
| Option | Description | default | | Option | Description | default |
|-------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------| | ----------------------------------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
| AI_ALLOW_REACH_FROM | Users that can use AI must be this level. options are "public", "authenticated", "restricted" | authenticated | | DJANGO_ALLOWED_HOSTS | allowed hosts | [] |
| AI_API_KEY | AI key to be used for AI Base url | | | DJANGO_SECRET_KEY | secret key | |
| AI_BASE_URL | OpenAI compatible AI base url | | | DJANGO_SERVER_TO_SERVER_API_TOKENS | | [] |
| AI_FEATURE_ENABLED | Enable AI options | false | | DB_ENGINE | engine to use for database connections | django.db.backends.postgresql_psycopg2 |
| AI_MODEL | AI Model to use | | | DB_NAME | name of the database | impress |
| ALLOW_LOGOUT_GET_METHOD | Allow get logout method | true | | DB_USER | user to authenticate with | dinum |
| API_USERS_LIST_LIMIT | Limit on API users | 5 | | DB_PASSWORD | password to authenticate with | pass |
| API_USERS_LIST_THROTTLE_RATE_BURST | Throttle rate for api on burst | 30/minute | | DB_HOST | host of the database | localhost |
| API_USERS_LIST_THROTTLE_RATE_SUSTAINED | Throttle rate for api | 180/hour | | DB_PORT | port of the database | 5432 |
| AWS_S3_ACCESS_KEY_ID | Access id for s3 endpoint | | | MEDIA_BASE_URL | | |
| AWS_S3_ENDPOINT_URL | S3 endpoint | | | STORAGES_STATICFILES_BACKEND | | whitenoise.storage.CompressedManifestStaticFilesStorage |
| AWS_S3_REGION_NAME | Region name for s3 endpoint | | | AWS_S3_ENDPOINT_URL | S3 endpoint | |
| AWS_S3_SECRET_ACCESS_KEY | Access key for s3 endpoint | | | AWS_S3_ACCESS_KEY_ID | access id for s3 endpoint | |
| AWS_STORAGE_BUCKET_NAME | Bucket name for s3 endpoint | impress-media-storage | | AWS_S3_SECRET_ACCESS_KEY | access key for s3 endpoint | |
| CACHES_DEFAULT_TIMEOUT | Cache default timeout | 30 | | AWS_S3_REGION_NAME | region name for s3 endpoint | |
| CACHES_KEY_PREFIX | The prefix used to every cache keys. | docs | | AWS_STORAGE_BUCKET_NAME | bucket name for s3 endpoint | impress-media-storage |
| COLLABORATION_API_URL | Collaboration api host | | | DOCUMENT_IMAGE_MAX_SIZE | maximum size of document in bytes | 10485760 |
| COLLABORATION_SERVER_SECRET | Collaboration api secret | | | LANGUAGE_CODE | default language | en-us |
| COLLABORATION_WS_NOT_CONNECTED_READY_ONLY | Users not connected to the collaboration server cannot edit | false | | API_USERS_LIST_THROTTLE_RATE_SUSTAINED | throttle rate for api | 180/hour |
| COLLABORATION_WS_URL | Collaboration websocket url | | | API_USERS_LIST_THROTTLE_RATE_BURST | throttle rate for api on burst | 30/minute |
| CONVERSION_API_CONTENT_FIELD | Conversion api content field | content | | SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | | false |
| CONVERSION_API_ENDPOINT | Conversion API endpoint | convert | | TRASHBIN_CUTOFF_DAYS | trashbin cutoff | 30 |
| CONVERSION_API_SECURE | Require secure conversion api | false | | DJANGO_EMAIL_BACKEND | email backend library | django.core.mail.backends.smtp.EmailBackend |
| CONVERSION_API_TIMEOUT | Conversion api timeout | 30 | | DJANGO_EMAIL_BRAND_NAME | brand name for email | |
| CRISP_WEBSITE_ID | Crisp website id for support | | | DJANGO_EMAIL_HOST | host name of email | |
| DB_ENGINE | Engine to use for database connections | django.db.backends.postgresql_psycopg2 | | DJANGO_EMAIL_HOST_USER | user to authenticate with on the email host | |
| DB_HOST | Host of the database | localhost | | DJANGO_EMAIL_HOST_PASSWORD | password to authenticate with on the email host | |
| DB_NAME | Name of the database | impress | | DJANGO_EMAIL_LOGO_IMG | logo for the email | |
| DB_PASSWORD | Password to authenticate with | pass | | DJANGO_EMAIL_PORT | port used to connect to email host | |
| DB_PORT | Port of the database | 5432 | | DJANGO_EMAIL_USE_TLS | use tls for email host connection | false |
| DB_USER | User to authenticate with | dinum | | DJANGO_EMAIL_USE_SSL | use sstl for email host connection | false |
| DJANGO_ALLOWED_HOSTS | Allowed hosts | [] | | DJANGO_EMAIL_FROM | email address used as sender | from@example.com |
| DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | Celery broker transport options | {} | | DJANGO_CORS_ALLOW_ALL_ORIGINS | allow all CORS origins | true |
| DJANGO_CELERY_BROKER_URL | Celery broker url | redis://redis:6379/0 | | DJANGO_CORS_ALLOWED_ORIGINS | list of origins allowed for CORS | [] |
| DJANGO_CORS_ALLOW_ALL_ORIGINS | Allow all CORS origins | false | | DJANGO_CORS_ALLOWED_ORIGIN_REGEXES | list of origins allowed for CORS using regulair expressions | [] |
| DJANGO_CORS_ALLOWED_ORIGIN_REGEXES | List of origins allowed for CORS using regulair expressions | [] | | SENTRY_DSN | sentry host | |
| DJANGO_CORS_ALLOWED_ORIGINS | List of origins allowed for CORS | [] | | COLLABORATION_API_URL | collaboration api host | |
| DJANGO_CSRF_TRUSTED_ORIGINS | CSRF trusted origins | [] | | COLLABORATION_SERVER_SECRET | collaboration api secret | |
| DJANGO_EMAIL_BACKEND | Email backend library | django.core.mail.backends.smtp.EmailBackend | | COLLABORATION_WS_URL | collaboration websocket url | |
| DJANGO_EMAIL_BRAND_NAME | Brand name for email | | | COLLABORATION_WS_NOT_CONNECTED_READY_ONLY | Users not connected to the collaboration server cannot edit | false |
| DJANGO_EMAIL_FROM | Email address used as sender | from@example.com | | FRONTEND_CSS_URL | To add a external css file to the app | |
| DJANGO_EMAIL_HOST | Hostname of email | | | FRONTEND_HOMEPAGE_FEATURE_ENABLED | frontend feature flag to display the homepage | false |
| DJANGO_EMAIL_HOST_PASSWORD | Password to authenticate with on the email host | | | FRONTEND_THEME | frontend theme to use | |
| DJANGO_EMAIL_HOST_USER | User to authenticate with on the email host | | | POSTHOG_KEY | posthog key for analytics | |
| DJANGO_EMAIL_LOGO_IMG | Logo for the email | | | CRISP_WEBSITE_ID | crisp website id for support | |
| DJANGO_EMAIL_PORT | Port used to connect to email host | | | DJANGO_CELERY_BROKER_URL | celery broker url | redis://redis:6379/0 |
| DJANGO_EMAIL_USE_SSL | Use ssl for email host connection | false | | DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | celery broker transport options | {} |
| DJANGO_EMAIL_USE_TLS | Use tls for email host connection | false | | SESSION_COOKIE_AGE | duration of the cookie session | 60*60*12 |
| DJANGO_SECRET_KEY | Secret key | | | OIDC_CREATE_USER | create used on OIDC | false |
| DJANGO_SERVER_TO_SERVER_API_TOKENS | | [] | | OIDC_RP_SIGN_ALGO | verification algorithm used OIDC tokens | RS256 |
| DOCUMENT_IMAGE_MAX_SIZE | Maximum size of document in bytes | 10485760 | | OIDC_RP_CLIENT_ID | client id used for OIDC | impress |
| FRONTEND_CSS_URL | To add a external css file to the app | | | OIDC_RP_CLIENT_SECRET | client secret used for OIDC | |
| FRONTEND_HOMEPAGE_FEATURE_ENABLED | Frontend feature flag to display the homepage | false | | OIDC_OP_JWKS_ENDPOINT | JWKS endpoint for OIDC | |
| FRONTEND_THEME | Frontend theme to use | | | OIDC_OP_AUTHORIZATION_ENDPOINT | Authorization endpoint for OIDC | |
| LANGUAGE_CODE | Default language | en-us | | OIDC_OP_TOKEN_ENDPOINT | Token endpoint for OIDC | |
| LOGGING_LEVEL_LOGGERS_APP | Application logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO | | OIDC_OP_USER_ENDPOINT | User endpoint for OIDC | |
| LOGGING_LEVEL_LOGGERS_ROOT | Default logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO | | OIDC_OP_LOGOUT_ENDPOINT | Logout endpoint for OIDC | |
| LOGIN_REDIRECT_URL | Login redirect url | | | OIDC_AUTH_REQUEST_EXTRA_PARAMS | OIDC extra auth parameters | {} |
| LOGIN_REDIRECT_URL_FAILURE | Login redirect url on failure | | | OIDC_RP_SCOPES | scopes requested for OIDC | openid email |
| LOGOUT_REDIRECT_URL | Logout redirect url | | | LOGIN_REDIRECT_URL | login redirect url | |
| MALWARE_DETECTION_BACKEND | The malware detection backend use from the django-lasuite package | lasuite.malware_detection.backends.dummy.DummyBackend | | LOGIN_REDIRECT_URL_FAILURE | login redirect url on failure | |
| MALWARE_DETECTION_PARAMETERS | A dict containing all the parameters to initiate the malware detection backend | {"callback_path": "core.malware_detection.malware_detection_callback",} | | LOGOUT_REDIRECT_URL | logout redirect url | |
| MEDIA_BASE_URL | | | | OIDC_USE_NONCE | use nonce for OIDC | true |
| NO_WEBSOCKET_CACHE_TIMEOUT | Cache used to store current editor session key when only users without websocket are editing a document | 120 | | OIDC_REDIRECT_REQUIRE_HTTPS | Require https for OIDC redirect url | false |
| OIDC_ALLOW_DUPLICATE_EMAILS | Allow duplicate emails | false | | OIDC_REDIRECT_ALLOWED_HOSTS | Allowed hosts for OIDC redirect url | [] |
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | OIDC extra auth parameters | {} | | OIDC_STORE_ID_TOKEN | Store OIDC token | true |
| OIDC_CREATE_USER | Create used on OIDC | false | | OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | faillback to email for identification | true |
| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | Fallback to email for identification | true | | OIDC_ALLOW_DUPLICATE_EMAILS | Allow duplicate emails | false |
| OIDC_OP_AUTHORIZATION_ENDPOINT | Authorization endpoint for OIDC | | | USER_OIDC_ESSENTIAL_CLAIMS | essential claims in OIDC token | [] |
| OIDC_OP_JWKS_ENDPOINT | JWKS endpoint for OIDC | | | OIDC_USERINFO_FULLNAME_FIELDS | OIDC token claims to create full name | ["first_name", "last_name"] |
| OIDC_OP_LOGOUT_ENDPOINT | Logout endpoint for OIDC | | | OIDC_USERINFO_SHORTNAME_FIELD | OIDC token claims to create shortname | first_name |
| OIDC_OP_TOKEN_ENDPOINT | Token endpoint for OIDC | | | ALLOW_LOGOUT_GET_METHOD | Allow get logout method | true |
| OIDC_OP_USER_ENDPOINT | User endpoint for OIDC | | | AI_API_KEY | AI key to be used for AI Base url | |
| OIDC_REDIRECT_ALLOWED_HOSTS | Allowed hosts for OIDC redirect url | [] | | AI_BASE_URL | OpenAI compatible AI base url | |
| OIDC_REDIRECT_REQUIRE_HTTPS | Require https for OIDC redirect url | false | | AI_MODEL | AI Model to use | |
| OIDC_RP_CLIENT_ID | Client id used for OIDC | impress | | AI_ALLOW_REACH_FROM | Users that can use AI must be this level. options are "public", "authenticated", "restricted" | authenticated |
| OIDC_RP_CLIENT_SECRET | Client secret used for OIDC | | | AI_FEATURE_ENABLED | Enable AI options | false |
| OIDC_RP_SCOPES | Scopes requested for OIDC | openid email | | Y_PROVIDER_API_KEY | Y provider API key | |
| OIDC_RP_SIGN_ALGO | verification algorithm used OIDC tokens | RS256 | | Y_PROVIDER_API_BASE_URL | Y Provider url | |
| OIDC_STORE_ID_TOKEN | Store OIDC token | true | | CONVERSION_API_ENDPOINT | Conversion API endpoint | convert-markdown |
| OIDC_USE_NONCE | Use nonce for OIDC | true | | CONVERSION_API_CONTENT_FIELD | Conversion api content field | content |
| OIDC_USERINFO_FULLNAME_FIELDS | OIDC token claims to create full name | ["first_name", "last_name"] | | CONVERSION_API_TIMEOUT | Conversion api timeout | 30 |
| OIDC_USERINFO_SHORTNAME_FIELD | OIDC token claims to create shortname | first_name | | CONVERSION_API_SECURE | Require secure conversion api | false |
| POSTHOG_KEY | Posthog key for analytics | | | LOGGING_LEVEL_LOGGERS_ROOT | default logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO |
| REDIS_URL | Cache url | redis://redis:6379/1 | | LOGGING_LEVEL_LOGGERS_APP | application logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO |
| SENTRY_DSN | Sentry host | | | API_USERS_LIST_LIMIT | Limit on API users | 5 |
| SESSION_COOKIE_AGE | duration of the cookie session | 60*60*12 | | DJANGO_CSRF_TRUSTED_ORIGINS | CSRF trusted origins | [] |
| SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | | false | | REDIS_URL | cache url | redis://redis:6379/1 |
| STORAGES_STATICFILES_BACKEND | | whitenoise.storage.CompressedManifestStaticFilesStorage | | CACHES_DEFAULT_TIMEOUT | cache default timeout | 30 |
| THEME_CUSTOMIZATION_CACHE_TIMEOUT | Cache duration for the customization settings | 86400 | | CACHES_KEY_PREFIX | The prefix used to every cache keys. | docs |
| THEME_CUSTOMIZATION_FILE_PATH | Full path to the file customizing the theme. An example is provided in src/backend/impress/configuration/theme/default.json | BASE_DIR/impress/configuration/theme/default.json | | MALWARE_DETECTION_BACKEND | The malware detection backend use from the django-lasuite package | lasuite.malware_detection.backends.dummy.DummyBackend |
| TRASHBIN_CUTOFF_DAYS | Trashbin cutoff | 30 | | MALWARE_DETECTION_PARAMETERS | A dict containing all the parameters to initiate the malware detection backend | {"callback_path": "core.malware_detection.malware_detection_callback",} |
| USER_OIDC_ESSENTIAL_CLAIMS | Essential claims in OIDC token | [] | | THEME_CUSTOMIZATION_FILE_PATH | full path to the file customizing the theme. An example is provided in src/backend/impress/configuration/theme/default.json | BASE_DIR/impress/configuration/theme/default.json |
| Y_PROVIDER_API_BASE_URL | Y Provider url | | | THEME_CUSTOMIZATION_CACHE_TIMEOUT | Cache duration for the customization settings | 86400 |
| Y_PROVIDER_API_KEY | Y provider API key | |
## impress-frontend image ## impress-frontend image
@@ -135,11 +134,10 @@ NODE_ENV=production NEXT_PUBLIC_PUBLISH_AS_MIT=false yarn build
| PUBLISH_AS_MIT | Removes packages whose licences are incompatible with the MIT licence (see below) | true | | PUBLISH_AS_MIT | Removes packages whose licences are incompatible with the MIT licence (see below) | true |
Packages with licences incompatible with the MIT licence: Packages with licences incompatible with the MIT licence:
* `xl-docx-exporter`: [GPL](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-docx-exporter/LICENSE), * `xl-docx-exporter`: [AGPL-3.0](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-docx-exporter/LICENSE),
* `xl-pdf-exporter`: [GPL](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-pdf-exporter/LICENSE), * `xl-pdf-exporter`: [AGPL-3.0](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-pdf-exporter/LICENSE)
* `xl-multi-column`: [GPL](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-multi-column/LICENSE).
In `.env.development`, `PUBLISH_AS_MIT` is set to `false`, allowing developers to test Docs with all its features. In `.env.development`, `PUBLISH_AS_MIT` is set to `false`, allowing developers to test Docs with all its features.
⚠️ If you run Docs in production with `PUBLISH_AS_MIT` set to `false` make sure you fulfill your BlockNote licensing or [subscription](https://www.blocknotejs.org/about#partner-with-us) obligations. ⚠️ If you run Docs in production with `PUBLISH_AS_MIT` set to `false` make sure you fulfill your [BlockNote licensing](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-pdf-exporter/LICENSE) or [subscription](https://www.blocknotejs.org/about#partner-with-us) obligations.
-78
View File
@@ -1,78 +0,0 @@
services:
postgresql:
image: postgres:16
healthcheck:
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
interval: 1s
timeout: 2s
retries: 300
env_file:
- env.d/postgresql
- env.d/common
environment:
- PGDATA=/var/lib/postgresql/data/pgdata
volumes:
- ./data/databases/backend:/var/lib/postgresql/data/pgdata
redis:
image: redis:8
backend:
image: lasuite/impress-backend:latest
user: ${DOCKER_USER:-1000}
restart: always
environment:
- DJANGO_CONFIGURATION=Production
env_file:
- env.d/common
- env.d/backend
- env.d/yprovider
- env.d/postgresql
healthcheck:
test: ["CMD", "python", "manage.py", "check"]
interval: 15s
timeout: 30s
retries: 20
start_period: 10s
depends_on:
postgresql:
condition: service_healthy
restart: true
redis:
condition: service_started
y-provider:
image: lasuite/impress-y-provider:latest
user: ${DOCKER_USER:-1000}
env_file:
- env.d/common
- env.d/yprovider
frontend:
image: lasuite/impress-frontend:latest
user: "101"
entrypoint:
- /docker-entrypoint.sh
command: ["nginx", "-g", "daemon off;"]
env_file:
- env.d/common
# Uncomment and set your values if using our nginx proxy example
#environment:
# - VIRTUAL_HOST=${DOCS_HOST} # used by nginx proxy
# - VIRTUAL_PORT=8083 # used by nginx proxy
# - LETSENCRYPT_HOST=${DOCS_HOST} # used by lets encrypt to generate TLS certificate
volumes:
- ./default.conf.template:/etc/nginx/templates/docs.conf.template
depends_on:
backend:
condition: service_healthy
# Uncomment if using our nginx proxy example
# networks:
# - proxy-tier
# - default
# Uncomment if using our nginx proxy example
#networks:
# proxy-tier:
# external: true
-88
View File
@@ -1,88 +0,0 @@
# Deploy and Configure Keycloak for Docs
## Installation
> \[!CAUTION\]
> We provide those instructions as an example, for production environments, you should follow the [official documentation](https://www.keycloak.org/documentation).
### Step 1: Prepare your working environment:
```bash
mkdir keycloak
curl -o keycloak/compose.yaml https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/docs/examples/compose/keycloak/compose.yaml
curl -o keycloak/env.d/kc_postgresql https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/env.d/production.dist/kc_postgresql
curl -o keycloak/env.d/keycloak https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/env.d/production.dist/keycloak
```
### Step 2:. Update `env.d/` files
The following variables need to be updated with your own values, others can be left as is:
```env
POSTGRES_PASSWORD=<generate postgres password>
KC_HOSTNAME=https://id.yourdomain.tld # Change with your own URL
KC_BOOTSTRAP_ADMIN_PASSWORD=<generate your password>
```
### Step 3: Expose keycloak instance on https
> \[!NOTE\]
> You can skip this section if you already have your own setup.
To access your Keycloak instance on the public network, it needs to be exposed on a domain with SSL termination. You can use our [example with nginx proxy and Let's Encrypt companion](../nginx-proxy/README.md) for automated creation/renewal of certificates using [acme.sh](http://acme.sh).
If following our example, uncomment the environment and network sections in compose file and update it with your values.
```yaml
version: '3'
services:
keycloak:
...
# Uncomment and set your values if using our nginx proxy example
# environment:
# - VIRTUAL_HOST=id.yourdomain.tld # used by nginx proxy
# - VIRTUAL_PORT=8080 # used by nginx proxy
# - LETSENCRYPT_HOST=id.yourdomain.tld # used by lets encrypt to generate TLS certificate
...
# Uncomment if using our nginx proxy example
# networks:
# - proxy-tier
# - default
# Uncomment if using our nginx proxy example
#networks:
# proxy-tier:
# external: true
```
### Step 4: Start the service
```bash
`docker compose up -d`
```
Your keycloak instance is now available on https://doc.yourdomain.tld
## Creating an OIDC Client for Docs Application
### Step 1: Create a New Realm
1. Log in to the Keycloak administration console.
2. Navigate to the realm tab and click on the "Create realm" button.
3. Enter the name of the realm - `docs`.
4. Click "Create".
#### Step 2: Create a New Client
1. Navigate to the "Clients" tab.
2. Click on the "Create client" button.
3. Enter the client ID - e.g. `docs`.
4. Enable "Client authentication" option.
6. Set the "Valid redirect URIs" to the URL of your docs application suffixed with `/*` - e.g., "https://docs.example.com/*".
1. Set the "Web Origins" to the URL of your docs application - e.g. `https://docs.example.com`.
1. Click "Save".
#### Step 3: Get Client Credentials
1. Go to the "Credentials" tab.
2. Copy the client ID (`docs` in this example) and the client secret.
@@ -1,36 +0,0 @@
services:
kc_postgresql:
image: postgres:16
healthcheck:
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
interval: 1s
timeout: 2s
retries: 300
env_file:
- env.d/kc_postgresql
volumes:
- ./data/keycloak:/var/lib/postgresql/data/pgdata
keycloak:
image: quay.io/keycloak/keycloak:26.1.3
command: ["start"]
env_file:
- env.d/kc_postgresql
- env.d/keycloak
# Uncomment and set your values if using our nginx proxy example
# environment:
# - VIRTUAL_HOST=id.yourdomain.tld # used by nginx proxy
# - VIRTUAL_PORT=8080 # used by nginx proxy
# - LETSENCRYPT_HOST=id.yourdomain.tld # used by lets encrypt to generate TLS certificate
depends_on:
kc_postgresql:
condition: service_healthy
restart: true
# Uncomment if using our nginx proxy example
# networks:
# - proxy-tier
# - default
#
#networks:
# proxy-tier:
# external: true
-103
View File
@@ -1,103 +0,0 @@
# Deploy and Configure Minio for Docs
## Installation
> \[!CAUTION\]
> We provide those instructions as an example, it should not be run in production. For production environments, deploy MinIO [in a Multi-Node Multi-Drive (Distributed)](https://min.io/docs/minio/linux/operations/install-deploy-manage/deploy-minio-multi-node-multi-drive.html#minio-mnmd) topology
### Step 1: Prepare your working environment:
```bash
mkdir minio
curl -o minio/compose.yaml https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/docs/examples/compose/minio/compose.yaml
```
### Step 2:. Update compose file with your own values
```yaml
version: '3'
services:
minio:
...
environment:
- MINIO_ROOT_USER=<Set minio root username>
- MINIO_ROOT_PASSWORD=<Set minio root password>
```
### Step 3: Expose MinIO instance
#### Option 1: Internal network
You may not need to expose your MinIO instance to the public if only services hosted on the same private network need to access to your MinIO instance.
You should create a docker network that will be shared between those services
```bash
docker network create storage-tier
```
#### Option 2: Public network
If you want to expose your MinIO instance to the public, it needs to be exposed on a domain with SSL termination. You can use our [example](../nginx-proxy/README.md) with an nginx proxy and Let's Encrypt companion for automated creation/renewal of Let's Encrypt certificates using [acme.sh](http://acme.sh).
If following our example, uncomment the environment and network sections in compose file and update it with your values.
```yaml
version: '3'
services:
docs:
...
minio:
...
environment:
...
# - VIRTUAL_HOST=storage.yourdomain.tld # used by nginx proxy
# - VIRTUAL_PORT=9000 # used by nginx proxy
# - LETSENCRYPT_HOST=storage.yourdomain.tld # used by lets encrypt to generate TLS certificate
...
# Uncomment if using our nginx proxy example
# networks:
# - proxy-tier
# - default
# Uncomment if using our nginx proxy example
#networks:
# proxy-tier:
# external: true
```
In this example we are only exposing MinIO API service. Follow the official documentation to configure Minio WebUI.
### Step 4: Start the service
```bash
`docker compose up -d`
```
Your minio instance is now available on https://storage.yourdomain.tld
## Creating a user and bucket for your Docs instance
### Installing mc
Follow the [official documentation](https://min.io/docs/minio/linux/reference/minio-mc.html#install-mc) to install mc
### Step 1: Configure `mc` to connect to your MinIO Server with your root user
```shellscript
mc alias set minio <MINIO_SERVER_URL> <MINIO_ROOT_USER> <MINIO_ROOT_PASSWORD>
```
Replace the values with those you have set in the previous steps
### Step 2: Create a new bucket with versioning enabled
```shellscript
mc mb --with-versioning minio/<your-bucket-name>
```
Replace `your-bucket-name` with the desired name for your bucket e.g. `docs-media-storage`
### Additional notes:
For increased security you should create a dedicated user with `readwrite` access to the Bucket. In the following example we will use MinIO root user.
-27
View File
@@ -1,27 +0,0 @@
services:
minio:
image: minio/minio
environment:
- MINIO_ROOT_USER=<set minio root username>
- MINIO_ROOT_PASSWORD=<set minio root password>
# Uncomment and set your values if using our nginx proxy example
# - VIRTUAL_HOST=storage.yourdomain.tld # used by nginx proxy
# - VIRTUAL_PORT=9000 # used by nginx proxy
# - LETSENCRYPT_HOST=storage.yourdomain.tld # used by lets encrypt to generate TLS certificate
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 1s
timeout: 20s
retries: 300
entrypoint: ""
command: minio server /data
volumes:
- ./data/minio:/data
# Uncomment if using our nginx proxy example
# networks:
# - proxy-tier
# Uncomment if using our nginx proxy example
#networks:
# proxy-tier:
# external: true
@@ -1,39 +0,0 @@
# Nginx proxy with automatic SSL certificates
> \[!CAUTION\]
> We provide those instructions as an example, for extended development or production environments, you should follow the [official documentation](https://github.com/nginx-proxy/acme-companion/tree/main/docs).
Nginx-proxy sets up a container running nginx and docker-gen. docker-gen generates reverse proxy configs for nginx and reloads nginx when containers are started and stopped.
Acme-companion is a lightweight companion container for nginx-proxy. It handles the automated creation, renewal and use of SSL certificates for proxied Docker containers through the ACME protocol.
## Installation
### Step 1: Prepare your working environment:
```bash
mkdir nginx-proxy
curl -o nginx-proxy/compose.yaml https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/docs/examples/compose/nginx-proxy/compose.yaml
```
### Step 2: Edit `DEFAULT_EMAIL` in the compose file.
Albeit optional, it is recommended to provide a valid default email address through the `DEFAULT_EMAIL` environment variable, so that Let's Encrypt can warn you about expiring certificates and allow you to recover your account.
### Step 3: Create docker network
Containers need share the same network for auto-discovery.
```bash
docker network create proxy-tier
```
### Step 4: Start service
```bash
docker compose up -d
```
## Usage
Once both nginx-proxy and acme-companion containers are up and running, start any container you want proxied with environment variables `VIRTUAL_HOST` and `LETSENCRYPT_HOST` both set to the domain(s) your proxied container is going to use.
@@ -1,36 +0,0 @@
services:
nginx-proxy:
image: nginxproxy/nginx-proxy
container_name: nginx-proxy
ports:
- "80:80"
- "443:443"
volumes:
- html:/usr/share/nginx/html
- certs:/etc/nginx/certs:ro
- /var/run/docker.sock:/tmp/docker.sock:ro
networks:
- proxy-tier
acme-companion:
image: nginxproxy/acme-companion
container_name: nginx-proxy-acme
environment:
- DEFAULT_EMAIL=mail@yourdomain.tld
volumes_from:
- nginx-proxy
volumes:
- certs:/etc/nginx/certs:rw
- acme:/etc/acme.sh
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
- proxy-tier
networks:
proxy-tier:
external: true
volumes:
html:
certs:
acme:
@@ -27,7 +27,7 @@ backend:
OIDC_OP_AUTHORIZATION_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/auth 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_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_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/logout 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_ID: impress
OIDC_RP_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly OIDC_RP_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
OIDC_RP_SIGN_ALGO: RS256 OIDC_RP_SIGN_ALGO: RS256
@@ -46,6 +46,9 @@ backend:
DB_USER: dinum DB_USER: dinum
DB_PASSWORD: pass DB_PASSWORD: pass
DB_PORT: 5432 DB_PORT: 5432
POSTGRES_DB: impress
POSTGRES_USER: dinum
POSTGRES_PASSWORD: pass
REDIS_URL: redis://default:pass@redis-master:6379/1 REDIS_URL: redis://default:pass@redis-master:6379/1
AWS_S3_ENDPOINT_URL: http://minio.impress.svc.cluster.local:9000 AWS_S3_ENDPOINT_URL: http://minio.impress.svc.cluster.local:9000
AWS_S3_ACCESS_KEY_ID: root AWS_S3_ACCESS_KEY_ID: root
@@ -82,7 +85,7 @@ backend:
# Extra volume to manage our local custom CA and avoid to set ssl_verify: false # Extra volume to manage our local custom CA and avoid to set ssl_verify: false
extraVolumeMounts: extraVolumeMounts:
- name: certs - name: certs
mountPath: /cert/cacert.pem mountPath: /usr/local/lib/python3.12/site-packages/certifi/cacert.pem
subPath: cacert.pem subPath: cacert.pem
# Extra volume to manage our local custom CA and avoid to set ssl_verify: false # Extra volume to manage our local custom CA and avoid to set ssl_verify: false
@@ -118,22 +121,6 @@ yProvider:
COLLABORATION_SERVER_ORIGIN: https://impress.127.0.0.1.nip.io COLLABORATION_SERVER_ORIGIN: https://impress.127.0.0.1.nip.io
COLLABORATION_SERVER_SECRET: my-secret COLLABORATION_SERVER_SECRET: my-secret
Y_PROVIDER_API_KEY: my-secret Y_PROVIDER_API_KEY: my-secret
COLLABORATION_BACKEND_BASE_URL: https://impress.127.0.0.1.nip.io
NODE_EXTRA_CA_CERTS: /usr/local/share/ca-certificates/cacert.pem
# Mount the certificate so yProvider can establish tls with the backend
extraVolumeMounts:
- name: certs
mountPath: /usr/local/share/ca-certificates/cacert.pem
subPath: cacert.pem
extraVolumes:
- name: certs
configMap:
name: certifi
items:
- key: cacert.pem
path: cacert.pem
posthog: posthog:
ingress: ingress:
@@ -148,6 +135,9 @@ ingress:
ingressCollaborationWS: ingressCollaborationWS:
enabled: true enabled: true
host: impress.127.0.0.1.nip.io 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: ingressCollaborationApi:
enabled: true enabled: true
@@ -91,7 +91,7 @@ extraDeploy:
}, },
{ {
"username": "user-e2e-chromium", "username": "user-e2e-chromium",
"email": "user@chromium.test", "email": "user@chromium.e2e",
"firstName": "E2E", "firstName": "E2E",
"lastName": "Chromium", "lastName": "Chromium",
"enabled": "true", "enabled": "true",
@@ -105,7 +105,7 @@ extraDeploy:
}, },
{ {
"username": "user-e2e-webkit", "username": "user-e2e-webkit",
"email": "user@webkit.test", "email": "user@webkit.e2e",
"firstName": "E2E", "firstName": "E2E",
"lastName": "Webkit", "lastName": "Webkit",
"enabled": "true", "enabled": "true",
@@ -119,7 +119,7 @@ extraDeploy:
}, },
{ {
"username": "user-e2e-firefox", "username": "user-e2e-firefox",
"email": "user@firefox.test", "email": "user@firefox.e2e",
"firstName": "E2E", "firstName": "E2E",
"lastName": "Firefox", "lastName": "Firefox",
"enabled": "true", "enabled": "true",
@@ -124,7 +124,7 @@ OIDC_OP_JWKS_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol
OIDC_OP_AUTHORIZATION_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/auth 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_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_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/logout 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_ID: impress
OIDC_RP_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly OIDC_RP_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
OIDC_RP_SIGN_ALGO: RS256 OIDC_RP_SIGN_ALGO: RS256
@@ -168,6 +168,9 @@ DB_NAME: impress
DB_USER: dinum DB_USER: dinum
DB_PASSWORD: pass DB_PASSWORD: pass
DB_PORT: 5432 DB_PORT: 5432
POSTGRES_DB: impress
POSTGRES_USER: dinum
POSTGRES_PASSWORD: pass
``` ```
### Find s3 bucket connection values ### Find s3 bucket connection values
-32
View File
@@ -1,32 +0,0 @@
# Installation
If you want to install Docs you've come to the right place.
Here are a bunch of resources to help you install the project.
## Kubernetes
We (Docs maintainers) are only using the Kubernetes deployment method in production. We can only provide advanced support for this method.
Please follow the instructions laid out [here](/docs/installation/kubernetes.md).
## Docker Compose
We are aware that not everyone has Kubernetes Cluster laying around 😆.
We also provide [Docker images](https://hub.docker.com/u/lasuite?page=1&search=impress) that you can deploy using Compose.
Please follow the instructions [here](/docs/installation/compose.md).
⚠️ Please keep in mind that we do not use it ourselves in production. Let us know in the issues if you run into troubles, we'll try to help.
## Other ways to install Docs
Community members have contributed several other ways to install Docs. While we owe them a big thanks 🙏, please keep in mind we (Docs maintainers) can't provide support on these installation methods as we don't use them ourselves and there are two many options out there for us to keep track of. Of course you can contact the contributors and the broader community for assistance.
Here is the list of other methods in alphabetical order:
- Coop-Cloud: [code](https://git.coopcloud.tech/coop-cloud/lasuite-docs)
- Nix: [Packages](https://search.nixos.org/packages?channel=unstable&query=lasuite-docs), ⚠️ unstable
- Podman: [code][https://codeberg.org/philo/lasuite-docs-podman], ⚠️ experimental
- YunoHost: [code](https://github.com/YunoHost-Apps/lasuite-docs_ynh), [app store](https://apps.yunohost.org/app/lasuite-docs)
Feel free to make a PR to add ones that are not listed above 🙏
## Cloud providers
Some cloud providers are making it easy to deploy Docs on their infrastructure.
Here is the list in alphabetical order:
- Clever Cloud 🇫🇷 : [market place][https://www.clever-cloud.com/product/docs/], [technical doc](https://www.clever.cloud/developers/guides/docs/#deploy-docs)
Feel free to make a PR to add ones that are not listed above 🙏
-232
View File
@@ -1,232 +0,0 @@
# Installation with docker compose
We provide a sample configuration for running Docs using Docker Compose. Please note that this configuration is experimental, and the official way to deploy Docs in production is to use [k8s](../installation/kubernetes.md)
## Requirements
- A modern version of Docker and its Compose plugin.
- A domain name and DNS configured to your server.
- An Identity Provider that supports OpenID Connect protocol - we provide [an example to deploy Keycloak](../examples/compose/keycloak/README.md).
- An Object Storage that implements S3 API - we provide [an example to deploy Minio](../examples/compose/minio/README.md).
- A Postgresql database - we provide [an example in the compose file](../examples/compose/compose.yaml).
- A Redis database - we provide [an example in the compose file](../examples/compose/compose.yaml).
## Software Requirements
Ensure you have Docker Compose(v2) installed on your host server. Follow the official guidelines for a reliable setup:
Docker Compose is included with Docker Engine:
- **Docker Engine:** We suggest adhering to the instructions provided by Docker
for [installing Docker Engine](https://docs.docker.com/engine/install/).
For older versions of Docker Engine that do not include Docker Compose:
- **Docker Compose:** Install it as per the [official documentation](https://docs.docker.com/compose/install/).
> [!NOTE]
> `docker-compose` may not be supported. You are advised to use `docker compose` instead.
## Step 1: Prepare your working environment:
```bash
mkdir -p docs/env.d
cd docs
curl -o compose.yaml https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/docs/examples/compose/compose.yaml
curl -o env.d/common https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/env.d/production.dist/common
curl -o env.d/backend https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/env.d/production.dist/backend
curl -o env.d/yprovider https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/env.d/production.dist/yprovider
curl -o env.d/postgresql https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/env.d/production.dist/postgresql
```
If you are using the sample nginx-proxy configuration:
```bash
curl -o default.conf.template https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/docker/files/production/etc/nginx/conf.d/default.conf.template
```
## Step 2: Configuration
Docs configuration is achieved through environment variables. We provide a [detailed description of all variables](../env.md).
In this example, we assume the following services:
- OIDC provider on https://id.yourdomain.tld
- Object Storage on https://storage.yourdomain.tld
- Docs on https://docs.yourdomain.tld
- Bucket name is docs-media-storage
**Set your own values in `env.d/common`**
### OIDC
Authentication in Docs is managed through Open ID Connect protocol. A functional Identity Provider implementing this protocol is required.
For guidance, refer to our [Keycloak deployment example](../examples/compose/keycloak/README.md).
If using Keycloak as your Identity Provider, set `OIDC_RP_CLIENT_ID` and `OIDC_RP_CLIENT_SECRET` variables with those of the OIDC client created for Docs. By default we have set `docs` as the realm name, if you have named your realm differently, update the value `REALM_NAME` in `env.d/common`
For others OIDC providers, update the variables in `env.d/backend`.
### Object Storage
Files and media are stored in an Object Store that supports the S3 API.
For guidance, refer to our [Minio deployment example](../examples/compose/minio/README.md).
Set `AWS_S3_ACCESS_KEY_ID` and `AWS_S3_SECRET_ACCESS_KEY` with the credentials of a user with `readwrite` access to the bucket created for Docs.
### Postgresql
Docs uses PostgreSQL as its database. Although an external PostgreSQL can be used, our example provides a deployment method.
If you are using the example provided, you need to generate a secure key for `DB_PASSWORD` and set it in `env.d/postgresql`.
If you are using an external service or not using our default values, you should update the variables in `env.d/postgresql`
### Redis
Docs uses Redis for caching. While an external Redis can be used, our example provides a deployment method.
If you are using an external service, you need to set `REDIS_URL` environment variable in `env.d/backend`.
### Y Provider
The Y provider service enables collaboration through websockets.
Generates a secure key for `Y_PROVIDER_API_KEY` and `COLLABORATION_SERVER_SECRET` in ``env.d/yprovider``.
### Docs
The Docs backend is built on the Django Framework.
Generates a secure key for `DJANGO_SECRET_KEY` in `env.d/backend`.
### Logging
Update the following variables in `env.d/backend` if you want to change the logging levels:
```env
LOGGING_LEVEL_HANDLERS_CONSOLE=DEBUG
LOGGING_LEVEL_LOGGERS_ROOT=DEBUG
LOGGING_LEVEL_LOGGERS_APP=DEBUG
```
### Mail
The following environment variables are required in `env.d/backend` for the mail service to send invitations :
```env
DJANGO_EMAIL_HOST=<smtp host>
DJANGO_EMAIL_HOST_USER=<smtp user>
DJANGO_EMAIL_HOST_PASSWORD=<smtp password>
DJANGO_EMAIL_PORT=<smtp port>
DJANGO_EMAIL_FROM=<your email address>
#DJANGO_EMAIL_USE_TLS=true # A flag to enable or disable TLS for email sending.
#DJANGO_EMAIL_USE_SSL=true # A flag to enable or disable SSL for email sending.
DJANGO_EMAIL_BRAND_NAME=<brand name used in email templates> # e.g. "La Suite Numérique"
DJANGO_EMAIL_LOGO_IMG=<logo image to use in email templates.> # e.g. "https://docs.yourdomain.tld/assets/logo-suite-numerique.png"
```
### AI
Built-in AI actions let users generate, summarize, translate, and correct content.
AI is disabled by default. To enable it, the following environment variables must be set in in `env.d/backend`:
```env
AI_FEATURE_ENABLED=true # is false by default
AI_BASE_URL=https://openaiendpoint.com
AI_API_KEY=<API key>
AI_MODEL=<model used> e.g. llama
```
### Frontend theme
You can [customize your Docs instance](../theming.md) with your own theme and custom css.
The following environment variables must be set in `env.d/backend`:
```env
FRONTEND_THEME=default # name of your theme built with cuningham
FRONTEND_CSS_URL=https://storage.yourdomain.tld/themes/custom.css # custom css
```
## Step 3: Reverse proxy and SSL/TLS
> [!WARNING]
> In a production environment, configure SSL/TLS termination to run your instance on https.
If you have your own certificates and proxy setup, you can skip this part.
You can follow our [nginx proxy example](../examples/compose/nginx-proxy/README.md) with automatic generation and renewal of certificate with Let's Encrypt.
You will need to uncomment the environment and network sections in compose file and update it with your values.
```yaml
frontend:
...
# Uncomment and set your values if using our nginx proxy example
#environment:
# - VIRTUAL_HOST=${DOCS_HOST} # used by nginx proxy
# - VIRTUAL_PORT=8083 # used by nginx proxy
# - LETSENCRYPT_HOST=${DOCS_HOST} # used by lets encrypt to generate TLS certificate
...
# Uncomment if using our nginx proxy example
# networks:
# - proxy-tier
#
#networks:
# proxy-tier:
# external: true
```
## Step 4: Start Docs
You are ready to start your Docs application !
```bash
docker compose up -d
```
> [!NOTE]
> Version of the images are set to latest, you should pin it to the desired version to avoid unwanted upgrades when pulling latest image.
## Step 5: Run the database migration and create Django admin user
```bash
docker compose run --rm backend python manage.py migrate
docker compose run --rm backend python manage.py createsuperuser --email <admin email> --password <admin password>
```
Replace `<admin email>` with the email of your admin user and generate a secure password.
Your docs instance is now available on the domain you defined, https://docs.yourdomain.tld.
THe admin interface is available on https://docs.yourdomain.tld/admin with the admin user you just created.
## How to upgrade your Docs application
Before running an upgrade you must check the [Upgrade document](../../UPGRADE.md) for specific procedures that might be needed.
You can also check the [Changelog](../../CHANGELOG.md) for brief summary of the changes.
### Step 1: Edit the images tag with the desired version
### Step 2: Pull the images
```bash
docker compose pull
```
### Step 3: Restart your containers
```bash
docker compose restart
```
### Step 4: Run the database migration
Your database schema may need to be updated, run:
```bash
docker compose run --rm backend python manage.py migrate
```
-110
View File
@@ -1,110 +0,0 @@
# La Suite Docs System & Requirements (2025-06)
## 1. Quick-Reference Matrix (single VM / laptop)
| Scenario | RAM | vCPU | SSD | Notes |
| ------------------------- | ----- | ---- | ------- | ------------------------- |
| **Solo dev** | 8 GB | 4 | 15 GB | Hot-reload + one IDE |
| **Team QA** | 16 GB | 6 | 30 GB | Runs integration tests |
| **Prod ≤ 100 live users** | 32 GB | 8 + | 50 GB + | Scale linearly above this |
Memory is the first bottleneck; CPU matters only when Celery or the Next.js build is saturated.
> **Note:** Memory consumption varies by operating system. Windows tends to be more memory-hungry than Linux, so consider adding 10-20% extra RAM when running on Windows compared to Linux-based systems.
## 2. Development Environment Memory Requirements
| Service | Typical use | Rationale / source |
| ------------------------ | ----------------------------- | --------------------------------------------------------------------------------------- |
| PostgreSQL | **1 2 GB** | `shared_buffers` starting point ≈ 25% RAM ([postgresql.org][1]) |
| Keycloak | **≈ 1.3 GB** | 70% of limit for heap + ~300 MB non-heap ([keycloak.org][2]) |
| Redis | **≤ 256 MB** | Empty instance ≈ 3 MB; budget 256 MB to allow small datasets ([stackoverflow.com][3]) |
| MinIO | **2 GB (dev) / 32 GB (prod)**| Pre-allocates 12 GiB; docs recommend 32 GB per host for ≤ 100 Ti storage ([min.io][4]) |
| Django API (+ Celery) | **0.8 1.5 GB** | Empirical in-house metrics |
| Next.js frontend | **0.5 1 GB** | Dev build chain |
| Y-Provider (y-websocket) | **< 200 MB** | Large 40 MB YDoc called “big” in community thread ([discuss.yjs.dev][5]) |
| Nginx | **< 100 MB** | Static reverse-proxy footprint |
[1]: https://www.postgresql.org/docs/9.1/runtime-config-resource.html "PostgreSQL: Documentation: 9.1: Resource Consumption"
[2]: https://www.keycloak.org/high-availability/concepts-memory-and-cpu-sizing "Concepts for sizing CPU and memory resources - Keycloak"
[3]: https://stackoverflow.com/questions/45233052/memory-footprint-for-redis-empty-instance "Memory footprint for Redis empty instance - Stack Overflow"
[4]: https://min.io/docs/minio/kubernetes/upstream/operations/checklists/hardware.html "Hardware Checklist — MinIO Object Storage for Kubernetes"
[5]: https://discuss.yjs.dev/t/understanding-memory-requirements-for-production-usage/198 "Understanding memory requirements for production usage - Yjs Community"
> **Rule of thumb:** add 2 GB for OS/overhead, then sum only the rows you actually run.
## 3. Production Environment Memory Requirements
Production deployments differ significantly from development environments. The table below shows typical memory usage for production services:
| Service | Typical use | Rationale / notes |
| ------------------------ | ----------------------------- | --------------------------------------------------------------------------------------- |
| PostgreSQL | **2 8 GB** | Higher `shared_buffers` and connection pooling for concurrent users |
| OIDC Provider (optional) | **Variable** | Any OIDC-compatible provider (Keycloak, Auth0, Azure AD, etc.) - external or self-hosted |
| Redis | **256 MB 2 GB** | Session storage and caching; scales with active user sessions |
| Object Storage (optional)| **External or self-hosted** | Can use AWS S3, Azure Blob, Google Cloud Storage, or self-hosted MinIO |
| Django API (+ Celery) | **1 3 GB** | Production workloads with background tasks and higher concurrency |
| Static Files (Nginx) | **< 200 MB** | Serves Next.js build output and static assets; no development overhead |
| Y-Provider (y-websocket) | **200 MB 1 GB** | Scales with concurrent document editing sessions |
| Nginx (Load Balancer) | **< 200 MB** | Reverse proxy, SSL termination, static file serving |
### Production Architecture Notes
- **Frontend**: Uses pre-built Next.js static assets served by Nginx (no Node.js runtime needed)
- **Authentication**: Any OIDC-compatible provider can be used instead of self-hosted Keycloak
- **Object Storage**: External services (S3, Azure Blob) or self-hosted solutions (MinIO) are both viable
- **Database**: Consider PostgreSQL clustering or managed database services for high availability
- **Scaling**: Horizontal scaling is recommended for Django API and Y-Provider services
### Minimal Production Setup (Core Services Only)
| Service | Memory | Notes |
| ------------------------ | --------- | --------------------------------------- |
| PostgreSQL | **2 GB** | Core database |
| Django API (+ Celery) | **1.5 GB**| Backend services |
| Y-Provider | **200 MB**| Real-time collaboration |
| Nginx | **100 MB**| Static files + reverse proxy |
| Redis | **256 MB**| Session storage |
| **Total (without auth/storage)** | **≈ 4 GB** | External OIDC + object storage assumed |
## 4. Recommended Software Versions
| Tool | Minimum |
| ----------------------- | ------- |
| Docker Engine / Desktop | 24.0 |
| Docker Compose | v2 |
| Git | 2.40 |
| **Node.js** | 22+ |
| **Python** | 3.13+ |
| GNU Make | 4.4 |
| Kind | 0.22 |
| Helm | 3.14 |
| kubectl | 1.29 |
| mkcert | 1.4 |
## 5. Ports (dev defaults)
| Port | Service |
| --------- | --------------------- |
| 3000 | Next.js |
| 8071 | Django |
| 4444 | Y-Provider |
| 8080 | Keycloak |
| 8083 | Nginx proxy |
| 9000/9001 | MinIO |
| 15432 | PostgreSQL (main) |
| 5433 | PostgreSQL (Keycloak) |
| 1081 | MailCatcher |
## 6. Sizing Guidelines
**RAM** start at 8 GB dev / 16 GB staging / 32 GB prod. Postgres and Keycloak are the first to OOM; scale them first.
> **OS considerations:** Windows systems typically require 10-20% more RAM than Linux due to higher OS overhead. Docker Desktop on Windows also uses additional memory compared to native Linux Docker.
**CPU** budget one vCPU per busy container until Celery or Next.js builds saturate.
**Disk** SSD; add 10 GB extra for the Docker layer cache.
**MinIO** for demos, mount a local folder instead of running MinIO to save 2 GB+ of RAM.
-32
View File
@@ -32,24 +32,6 @@ Then, set the `FRONTEND_CSS_URL` environment variable to the URL of your custom
---- ----
# **Your logo** 📝
You can add your own logo in the header from the theme customization file.
### Settings 🔧
```shellscript
THEME_CUSTOMIZATION_FILE_PATH=<path>
```
### Example of JSON
You can activate it with the `header.logo` configuration: https://github.com/suitenumerique/docs/blob/main/src/helm/env.d/dev/configuration/theme/demo.json
This configuration is optional. If not set, the default logo will be used.
----
# **Footer Configuration** 📝 # **Footer Configuration** 📝
The footer is configurable from the theme customization file. The footer is configurable from the theme customization file.
@@ -71,18 +53,4 @@ Below is a visual example of a configured footer ⬇️:
![Footer Configuration Example](./assets/footer-configurable.png) ![Footer Configuration Example](./assets/footer-configurable.png)
----
# **Custom Translations** 📝
The translations can be partially overridden from the theme customization file.
### Settings 🔧
```shellscript
THEME_CUSTOMIZATION_FILE_PATH=<path>
```
### Example of JSON
The json must follow some rules: https://github.com/suitenumerique/docs/blob/main/src/helm/env.d/dev/configuration/theme/demo.json
-145
View File
@@ -1,145 +0,0 @@
# Troubleshooting Guide
## Line Ending Issues on Windows (LF/CRLF)
### Problem Description
This project uses **LF (Line Feed: `\n`) line endings** exclusively. Windows users may encounter issues because:
- **Windows** defaults to CRLF (Carriage Return + Line Feed: `\r\n`) for line endings
- **This project** uses LF line endings for consistency across all platforms
- **Git** may automatically convert line endings, causing conflicts or build failures
### Common Symptoms
- Git shows files as modified even when no changes were made
- Error messages like "warning: LF will be replaced by CRLF"
- Build failures or linting errors due to line ending mismatches
### Solutions for Windows Users
#### Configure Git to Preserve LF (Recommended)
Configure Git to NOT convert line endings and preserve LF:
```bash
git config core.autocrlf false
git config core.eol lf
```
This tells Git to:
- Never convert line endings automatically
- Always use LF for line endings in working directory
#### Fix Existing Repository with Wrong Line Endings
If you already have CRLF line endings in your local repository, the **best approach** is to configure Git properly and clone the project again:
1. **Configure Git first**:
```bash
git config --global core.autocrlf false
git config --global core.eol lf
```
2. **Clone the project fresh** (recommended):
```bash
# Navigate to parent directory
cd ..
# Remove current repository (backup your changes first!)
rm -rf docs
# Clone again with correct line endings
git clone git@github.com:suitenumerique/docs.git
```
**Alternative**: If you have uncommitted changes and cannot re-clone:
1. **Backup your changes**:
```bash
git add .
git commit -m "Save changes before fixing line endings"
```
2. **Remove all files from Git's index**:
```bash
git rm --cached -r .
```
3. **Reset Git configuration** (if not done globally):
```bash
git config core.autocrlf false
git config core.eol lf
```
4. **Re-add all files** (Git will use LF line endings):
```bash
git add .
```
5. **Commit the changes**:
```bash
git commit -m "✏️(project) Fix line endings to LF"
```
## Frontend File Watching Issues on Windows
### Problem Description
Windows users may experience issues with file watching in the frontend-development container. This typically happens because:
- **Docker on Windows** has known limitations with file change detection
- **Node.js file watchers** may not detect changes properly on Windows filesystem
- **Hot reloading** fails to trigger when files are modified
### Common Symptoms
- Changes to frontend code aren't detected automatically
- Hot module replacement doesn't work as expected
- Need to manually restart the frontend container after code changes
- Console shows no reaction when saving files
### Solution: Enable WATCHPACK_POLLING
Add the `WATCHPACK_POLLING=true` environment variable to the frontend-development service in your local environment:
1. **Modify the `compose.yml` file** by adding the environment variable to the frontend-development service:
```yaml
frontend-development:
user: "${DOCKER_USER:-1000}"
build:
context: .
dockerfile: ./src/frontend/Dockerfile
target: impress-dev
args:
API_ORIGIN: "http://localhost:8071"
PUBLISH_AS_MIT: "false"
SW_DEACTIVATED: "true"
image: impress:frontend-development
environment:
- WATCHPACK_POLLING=true # Add this line for Windows users
volumes:
- ./src/frontend:/home/frontend
- /home/frontend/node_modules
- /home/frontend/apps/impress/node_modules
ports:
- "3000:3000"
```
2. **Restart your containers**:
```bash
make run
```
### Why This Works
- `WATCHPACK_POLLING=true` forces the file watcher to use polling instead of filesystem events
- Polling periodically checks for file changes rather than relying on OS-level file events
- This is more reliable on Windows but slightly increases CPU usage
- Changes to your frontend code should now be detected properly, enabling hot reloading
### Note
This setting is primarily needed for Windows users. Linux and macOS users typically don't need this setting as file watching works correctly by default on those platforms.
@@ -56,16 +56,8 @@ AI_API_KEY=password
AI_MODEL=llama AI_MODEL=llama
# Collaboration # Collaboration
COLLABORATION_API_URL=http://y-provider-development:4444/collaboration/api/ COLLABORATION_API_URL=http://y-provider:4444/collaboration/api/
COLLABORATION_BACKEND_BASE_URL=http://app-dev:8000 COLLABORATION_BACKEND_BASE_URL=http://app-dev:8000
COLLABORATION_SERVER_ORIGIN=http://localhost:3000 COLLABORATION_SERVER_ORIGIN=http://localhost:3000
COLLABORATION_SERVER_SECRET=my-secret COLLABORATION_SERVER_SECRET=my-secret
COLLABORATION_WS_NOT_CONNECTED_READY_ONLY=true
COLLABORATION_WS_URL=ws://localhost:4444/collaboration/ws/ COLLABORATION_WS_URL=ws://localhost:4444/collaboration/ws/
DJANGO_SERVER_TO_SERVER_API_TOKENS=server-api-token
Y_PROVIDER_API_BASE_URL=http://y-provider-development:4444/api/
Y_PROVIDER_API_KEY=yprovider-api-key
# Theme customization
THEME_CUSTOMIZATION_CACHE_TIMEOUT=15
@@ -1,9 +1,6 @@
# For the CI job test-e2e # For the CI job test-e2e
BURST_THROTTLE_RATES="200/minute" BURST_THROTTLE_RATES="200/minute"
COLLABORATION_API_URL=http://y-provider:4444/collaboration/api/ DJANGO_SERVER_TO_SERVER_API_TOKENS=test-e2e
SUSTAINED_THROTTLE_RATES="200/hour" SUSTAINED_THROTTLE_RATES="200/hour"
Y_PROVIDER_API_KEY=yprovider-api-key
Y_PROVIDER_API_BASE_URL=http://y-provider:4444/api/ Y_PROVIDER_API_BASE_URL=http://y-provider:4444/api/
# Throttle
API_DOCUMENT_THROTTLE_RATE=1000/min
API_CONFIG_THROTTLE_RATE=1000/min
-65
View File
@@ -1,65 +0,0 @@
## Django
DJANGO_ALLOWED_HOSTS=${DOCS_HOST}
DJANGO_SECRET_KEY=<generate a random key>
DJANGO_SETTINGS_MODULE=impress.settings
DJANGO_CONFIGURATION=Production
# Logging
# Set to DEBUG level for dev only
LOGGING_LEVEL_HANDLERS_CONSOLE=ERROR
LOGGING_LEVEL_LOGGERS_ROOT=INFO
LOGGING_LEVEL_LOGGERS_APP=INFO
# Python
PYTHONPATH=/app
# Mail
DJANGO_EMAIL_HOST=<smtp host>
DJANGO_EMAIL_HOST_USER=<smtp user>
DJANGO_EMAIL_HOST_PASSWORD=<smtp password>
DJANGO_EMAIL_PORT=<smtp port>
DJANGO_EMAIL_FROM=<your email address>
#DJANGO_EMAIL_USE_TLS=true # A flag to enable or disable TLS for email sending.
#DJANGO_EMAIL_USE_SSL=true # A flag to enable or disable SSL for email sending.
DJANGO_EMAIL_BRAND_NAME="La Suite Numérique"
DJANGO_EMAIL_LOGO_IMG="https://${DOCS_HOST}/assets/logo-suite-numerique.png"
# Media
AWS_S3_ENDPOINT_URL=https://${S3_HOST}
AWS_S3_ACCESS_KEY_ID=<s3 access key>
AWS_S3_SECRET_ACCESS_KEY=<s3 secret key>
AWS_STORAGE_BUCKET_NAME=${BUCKET_NAME}
MEDIA_BASE_URL=https://${DOCS_HOST}
# OIDC
OIDC_OP_JWKS_ENDPOINT=https://${KEYCLOAK_HOST}/realms/${REALM_NAME}/protocol/openid-connect/certs
OIDC_OP_AUTHORIZATION_ENDPOINT=https://${KEYCLOAK_HOST}/realms/${REALM_NAME}/protocol/openid-connect/auth
OIDC_OP_TOKEN_ENDPOINT=https://${KEYCLOAK_HOST}/realms/${REALM_NAME}/protocol/openid-connect/token
OIDC_OP_USER_ENDPOINT=https://${KEYCLOAK_HOST}/realms/${REALM_NAME}/protocol/openid-connect/userinfo
OIDC_OP_LOGOUT_ENDPOINT=https://${KEYCLOAK_HOST}/realms/${REALM_NAME}/protocol/openid-connect/logout
OIDC_RP_CLIENT_ID=<client_id>
OIDC_RP_CLIENT_SECRET=<client secret>
OIDC_RP_SIGN_ALGO=RS256
OIDC_RP_SCOPES="openid email"
#OIDC_USERINFO_SHORTNAME_FIELD
#OIDC_USERINFO_FULLNAME_FIELDS
LOGIN_REDIRECT_URL=https://${DOCS_HOST}
LOGIN_REDIRECT_URL_FAILURE=https://${DOCS_HOST}
LOGOUT_REDIRECT_URL=https://${DOCS_HOST}
OIDC_REDIRECT_ALLOWED_HOSTS=["https://${DOCS_HOST}"]
# AI
#AI_FEATURE_ENABLED=true # is false by default
#AI_BASE_URL=https://openaiendpoint.com
#AI_API_KEY=<API key>
#AI_MODEL=<model used> e.g. llama
# Frontend
#FRONTEND_THEME=mytheme
#FRONTEND_CSS_URL=https://storage.yourdomain.tld/themes/custom.css
#FRONTEND_FOOTER_FEATURE_ENABLED=true
#FRONTEND_URL_JSON_FOOTER=https://docs.domain.tld/contents/footer-demo.json
-9
View File
@@ -1,9 +0,0 @@
DOCS_HOST=docs.domain.tld
KEYCLOAK_HOST=id.domain.tld
S3_HOST=storage.domain.tld
BACKEND_HOST=backend
FRONTEND_HOST=frontend
YPROVIDER_HOST=y-provider
BUCKET_NAME=docs-media-storage
REALM_NAME=docs
#COLLABORATION_WS_URL=wss://${DOCS_HOST}/collaboration/ws/
-13
View File
@@ -1,13 +0,0 @@
# Postgresql db container configuration
POSTGRES_DB=keycloak
POSTGRES_USER=keycloak
POSTGRES_PASSWORD=<generate postgres password>
PGDATA=/var/lib/postgresql/data/pgdata
# Keycloak postgresql configuration
KC_DB=postgres
KC_DB_SCHEMA=public
KC_DB_HOST=postgresql
KC_DB_NAME=${POSTGRES_DB}
KC_DB_USER=${POSTGRES_USER}
KC_DB_PASSWORD=${POSTGRES_PASSWORD}
-8
View File
@@ -1,8 +0,0 @@
# Keycloak admin user
KC_BOOTSTRAP_ADMIN_USERNAME=admin
KC_BOOTSTRAP_ADMIN_PASSWORD=<generate your password>
# Keycloak configuration
KC_HOSTNAME=https://id.yourdomain.tld # Change with your own URL
KC_PROXY_HEADERS=xforwarded # in this example we are running behind an nginx proxy
KC_HTTP_ENABLED=true # in this example we are running behind an nginx proxy
-11
View File
@@ -1,11 +0,0 @@
# App database configuration
DB_HOST=postgresql
DB_NAME=docs
DB_USER=docs
DB_PASSWORD=<generate a secure password>
DB_PORT=5432
# Postgresql db container configuration
POSTGRES_DB=docs
POSTGRES_USER=docs
POSTGRES_PASSWORD=${DB_PASSWORD}
-7
View File
@@ -1,7 +0,0 @@
Y_PROVIDER_API_BASE_URL=http://${YPROVIDER_HOST}:4444/api/
Y_PROVIDER_API_KEY=<generate a random key>
COLLABORATION_SERVER_SECRET=<generate a random key>
COLLABORATION_SERVER_ORIGIN=https://${DOCS_HOST}
COLLABORATION_API_URL=https://${DOCS_HOST}/collaboration/api/
COLLABORATION_BACKEND_BASE_URL=https://${DOCS_HOST}
COLLABORATION_LOGGING=true
+10 -12
View File
@@ -1,11 +1,7 @@
{ {
"extends": ["github>numerique-gouv/renovate-configuration"], "extends": ["github>numerique-gouv/renovate-configuration"],
"dependencyDashboard": true, "dependencyDashboard": true,
"labels": ["dependencies", "noChangeLog", "automated"], "labels": ["dependencies", "noChangeLog"],
"schedule": ["before 7am on monday"],
"prCreation": "not-pending",
"rebaseWhen": "conflicted",
"updateNotScheduled": false,
"packageRules": [ "packageRules": [
{ {
"enabled": false, "enabled": false,
@@ -13,24 +9,26 @@
"matchManagers": ["pep621"], "matchManagers": ["pep621"],
"matchPackageNames": [] "matchPackageNames": []
}, },
{
"groupName": "allowed django versions",
"matchManagers": ["pep621"],
"matchPackageNames": ["Django"],
"allowedVersions": "<5.2"
},
{ {
"groupName": "allowed redis versions", "groupName": "allowed redis versions",
"matchManagers": ["pep621"], "matchManagers": ["pep621"],
"matchPackageNames": ["redis"], "matchPackageNames": ["redis"],
"allowedVersions": "<6.0.0" "allowedVersions": "<6.0.0"
}, },
{
"groupName": "allowed pylint versions",
"matchManagers": ["pep621"],
"matchPackageNames": ["pylint"],
"allowedVersions": "<4.0.0"
},
{ {
"enabled": false, "enabled": false,
"groupName": "ignored js dependencies", "groupName": "ignored js dependencies",
"matchManagers": ["npm"], "matchManagers": ["npm"],
"matchPackageNames": [ "matchPackageNames": [
"docx", "@hocuspocus/provider",
"@hocuspocus/server",
"eslint",
"fetch-mock", "fetch-mock",
"node", "node",
"node-fetch", "node-fetch",
+2
View File
@@ -5,6 +5,7 @@ from django.contrib.auth import admin as auth_admin
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from treebeard.admin import TreeAdmin from treebeard.admin import TreeAdmin
from treebeard.forms import movenodeform_factory
from . import models from . import models
@@ -156,6 +157,7 @@ class DocumentAdmin(TreeAdmin):
}, },
), ),
) )
form = movenodeform_factory(models.Document)
inlines = (DocumentAccessInline,) inlines = (DocumentAccessInline,)
list_display = ( list_display = (
"id", "id",
-30
View File
@@ -60,9 +60,6 @@ class ListDocumentFilter(DocumentFilter):
is_creator_me = django_filters.BooleanFilter( is_creator_me = django_filters.BooleanFilter(
method="filter_is_creator_me", label=_("Creator is me") method="filter_is_creator_me", label=_("Creator is me")
) )
is_masked = django_filters.BooleanFilter(
method="filter_is_masked", label=_("Masked")
)
is_favorite = django_filters.BooleanFilter( is_favorite = django_filters.BooleanFilter(
method="filter_is_favorite", label=_("Favorite") method="filter_is_favorite", label=_("Favorite")
) )
@@ -109,30 +106,3 @@ class ListDocumentFilter(DocumentFilter):
return queryset return queryset
return queryset.filter(is_favorite=bool(value)) return queryset.filter(is_favorite=bool(value))
# pylint: disable=unused-argument
def filter_is_masked(self, queryset, name, value):
"""
Filter documents based on whether they are masked by the current user.
Example:
- /api/v1.0/documents/?is_masked=true
→ Filters documents marked as masked by the logged-in user
- /api/v1.0/documents/?is_masked=false
→ Filters documents not marked as masked by the logged-in user
"""
user = self.request.user
if not user.is_authenticated:
return queryset
queryset_method = queryset.filter if bool(value) else queryset.exclude
return queryset_method(link_traces__user=user, link_traces__is_masked=True)
class UserSearchFilter(django_filters.FilterSet):
"""
Custom filter for searching users.
"""
q = django_filters.CharFilter(min_length=5, max_length=254)
+9 -62
View File
@@ -6,7 +6,6 @@ from django.http import Http404
from rest_framework import permissions from rest_framework import permissions
from core import choices
from core.models import DocumentAccess, RoleChoices, get_trashbin_cutoff from core.models import DocumentAccess, RoleChoices, get_trashbin_cutoff
ACTION_FOR_METHOD_TO_PERMISSION = { ACTION_FOR_METHOD_TO_PERMISSION = {
@@ -97,27 +96,26 @@ class CanCreateInvitationPermission(permissions.BasePermission):
).exists() ).exists()
class ResourceWithAccessPermission(permissions.BasePermission): class AccessPermission(permissions.BasePermission):
"""A permission class for templates and invitations.""" """Permission class for access objects."""
def has_permission(self, request, view): def has_permission(self, request, view):
"""check create permission for templates."""
return request.user.is_authenticated or view.action != "create" return request.user.is_authenticated or view.action != "create"
def has_object_permission(self, request, view, obj): def has_object_permission(self, request, view, obj):
"""Check permission for a given object.""" """Check permission for a given object."""
abilities = obj.get_abilities(request.user) abilities = obj.get_abilities(request.user)
action = view.action action = view.action
try:
action = ACTION_FOR_METHOD_TO_PERMISSION[view.action][request.method]
except KeyError:
pass
return abilities.get(action, False) return abilities.get(action, False)
class DocumentPermission(permissions.BasePermission): class DocumentAccessPermission(AccessPermission):
"""Subclass to handle soft deletion specificities.""" """Subclass to handle soft deletion specificities."""
def has_permission(self, request, view):
"""check create permission for documents."""
return request.user.is_authenticated or view.action != "create"
def has_object_permission(self, request, view, obj): def has_object_permission(self, request, view, obj):
""" """
Return a 404 on deleted documents Return a 404 on deleted documents
@@ -129,61 +127,10 @@ class DocumentPermission(permissions.BasePermission):
) and deleted_at < get_trashbin_cutoff(): ) and deleted_at < get_trashbin_cutoff():
raise Http404 raise Http404
abilities = obj.get_abilities(request.user) # Compute permission first to ensure the "user_roles" attribute is set
action = view.action has_permission = super().has_object_permission(request, view, obj)
try:
action = ACTION_FOR_METHOD_TO_PERMISSION[view.action][request.method]
except KeyError:
pass
has_permission = abilities.get(action, False)
if obj.ancestors_deleted_at and not RoleChoices.OWNER in obj.user_roles: if obj.ancestors_deleted_at and not RoleChoices.OWNER in obj.user_roles:
raise Http404 raise Http404
return has_permission return has_permission
class ResourceAccessPermission(IsAuthenticated):
"""Permission class for document access objects."""
def has_permission(self, request, view):
"""check create permission for accesses in documents tree."""
if super().has_permission(request, view) is False:
return False
if view.action == "create":
role = getattr(view, view.resource_field_name).get_role(request.user)
if role not in choices.PRIVILEGED_ROLES:
raise exceptions.PermissionDenied(
"You are not allowed to manage accesses for this resource."
)
return True
def has_object_permission(self, request, view, obj):
"""Check permission for a given object."""
abilities = obj.get_abilities(request.user)
requested_role = request.data.get("role")
if requested_role and requested_role not in abilities.get("set_role_to", []):
return False
action = view.action
return abilities.get(action, False)
class CommentPermission(permissions.BasePermission):
"""Permission class for comments."""
def has_permission(self, request, view):
"""Check permission for a given object."""
if view.action in ["create", "list"]:
document_abilities = view.get_document_or_404().get_abilities(request.user)
return document_abilities["comment"]
return True
def has_object_permission(self, request, view, obj):
"""Check permission for a given object."""
return obj.get_abilities(request.user).get(view.action, False)
+149 -427
View File
@@ -1,5 +1,4 @@
"""Client serializers for the impress core app.""" """Client serializers for the impress core app."""
# pylint: disable=too-many-lines
import binascii import binascii
import mimetypes import mimetypes
@@ -8,13 +7,12 @@ from base64 import b64decode
from django.conf import settings from django.conf import settings
from django.db.models import Q from django.db.models import Q
from django.utils.functional import lazy from django.utils.functional import lazy
from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
import magic import magic
from rest_framework import serializers from rest_framework import exceptions, serializers
from core import choices, enums, models, utils, validators from core import enums, models, utils
from core.services.ai_services import AI_ACTIONS from core.services.ai_services import AI_ACTIONS
from core.services.converter_services import ( from core.services.converter_services import (
ConversionError, ConversionError,
@@ -25,63 +23,143 @@ from core.services.converter_services import (
class UserSerializer(serializers.ModelSerializer): class UserSerializer(serializers.ModelSerializer):
"""Serialize users.""" """Serialize users."""
full_name = serializers.SerializerMethodField(read_only=True)
short_name = serializers.SerializerMethodField(read_only=True)
class Meta: class Meta:
model = models.User model = models.User
fields = ["id", "email", "full_name", "short_name", "language"] fields = ["id", "email", "full_name", "short_name", "language"]
read_only_fields = ["id", "email", "full_name", "short_name"] read_only_fields = ["id", "email", "full_name", "short_name"]
def get_full_name(self, instance):
"""Return the full name of the user."""
if not instance.full_name:
email = instance.email.split("@")[0]
return slugify(email)
return instance.full_name
def get_short_name(self, instance):
"""Return the short name of the user."""
if not instance.short_name:
email = instance.email.split("@")[0]
return slugify(email)
return instance.short_name
class UserLightSerializer(UserSerializer): class UserLightSerializer(UserSerializer):
"""Serialize users with limited fields.""" """Serialize users with limited fields."""
id = serializers.SerializerMethodField(read_only=True)
email = serializers.SerializerMethodField(read_only=True)
def get_id(self, _user):
"""Return always None. Here to have the same fields than in UserSerializer."""
return None
def get_email(self, _user):
"""Return always None. Here to have the same fields than in UserSerializer."""
return None
class Meta: class Meta:
model = models.User model = models.User
fields = ["full_name", "short_name"] fields = ["id", "email", "full_name", "short_name"]
read_only_fields = ["full_name", "short_name"] read_only_fields = ["id", "email", "full_name", "short_name"]
class TemplateAccessSerializer(serializers.ModelSerializer): class BaseAccessSerializer(serializers.ModelSerializer):
"""Serialize template accesses.""" """Serialize template accesses."""
abilities = serializers.SerializerMethodField(read_only=True) abilities = serializers.SerializerMethodField(read_only=True)
def update(self, instance, validated_data):
"""Make "user" field is readonly but only on update."""
validated_data.pop("user", None)
return super().update(instance, validated_data)
def get_abilities(self, access) -> dict:
"""Return abilities of the logged-in user on the instance."""
request = self.context.get("request")
if request:
return access.get_abilities(request.user)
return {}
def validate(self, attrs):
"""
Check access rights specific to writing (create/update)
"""
request = self.context.get("request")
user = getattr(request, "user", None)
role = attrs.get("role")
# Update
if self.instance:
can_set_role_to = self.instance.get_abilities(user)["set_role_to"]
if role and role not in can_set_role_to:
message = (
f"You are only allowed to set role to {', '.join(can_set_role_to)}"
if can_set_role_to
else "You are not allowed to set this role for this template."
)
raise exceptions.PermissionDenied(message)
# Create
else:
try:
resource_id = self.context["resource_id"]
except KeyError as exc:
raise exceptions.ValidationError(
"You must set a resource ID in kwargs to create a new access."
) from exc
if not self.Meta.model.objects.filter( # pylint: disable=no-member
Q(user=user) | Q(team__in=user.teams),
role__in=[models.RoleChoices.OWNER, models.RoleChoices.ADMIN],
**{self.Meta.resource_field_name: resource_id}, # pylint: disable=no-member
).exists():
raise exceptions.PermissionDenied(
"You are not allowed to manage accesses for this resource."
)
if (
role == models.RoleChoices.OWNER
and not self.Meta.model.objects.filter( # pylint: disable=no-member
Q(user=user) | Q(team__in=user.teams),
role=models.RoleChoices.OWNER,
**{self.Meta.resource_field_name: resource_id}, # pylint: disable=no-member
).exists()
):
raise exceptions.PermissionDenied(
"Only owners of a resource can assign other users as owners."
)
# pylint: disable=no-member
attrs[f"{self.Meta.resource_field_name}_id"] = self.context["resource_id"]
return attrs
class DocumentAccessSerializer(BaseAccessSerializer):
"""Serialize document accesses."""
user_id = serializers.PrimaryKeyRelatedField(
queryset=models.User.objects.all(),
write_only=True,
source="user",
required=False,
allow_null=True,
)
user = UserSerializer(read_only=True)
class Meta:
model = models.DocumentAccess
resource_field_name = "document"
fields = ["id", "user", "user_id", "team", "role", "abilities"]
read_only_fields = ["id", "abilities"]
class DocumentAccessLightSerializer(DocumentAccessSerializer):
"""Serialize document accesses with limited fields."""
user = UserLightSerializer(read_only=True)
class Meta:
model = models.DocumentAccess
fields = ["id", "user", "team", "role", "abilities"]
read_only_fields = ["id", "team", "role", "abilities"]
class TemplateAccessSerializer(BaseAccessSerializer):
"""Serialize template accesses."""
class Meta: class Meta:
model = models.TemplateAccess model = models.TemplateAccess
resource_field_name = "template" resource_field_name = "template"
fields = ["id", "user", "team", "role", "abilities"] fields = ["id", "user", "team", "role", "abilities"]
read_only_fields = ["id", "abilities"] read_only_fields = ["id", "abilities"]
def get_abilities(self, instance) -> dict:
"""Return abilities of the logged-in user on the instance."""
request = self.context.get("request")
if request:
return instance.get_abilities(request.user)
return {}
def update(self, instance, validated_data):
"""Make "user" field is readonly but only on update."""
validated_data.pop("user", None)
return super().update(instance, validated_data)
class ListDocumentSerializer(serializers.ModelSerializer): class ListDocumentSerializer(serializers.ModelSerializer):
"""Serialize documents with limited fields for display in lists.""" """Serialize documents with limited fields for display in lists."""
@@ -89,22 +167,16 @@ class ListDocumentSerializer(serializers.ModelSerializer):
is_favorite = serializers.BooleanField(read_only=True) is_favorite = serializers.BooleanField(read_only=True)
nb_accesses_ancestors = serializers.IntegerField(read_only=True) nb_accesses_ancestors = serializers.IntegerField(read_only=True)
nb_accesses_direct = serializers.IntegerField(read_only=True) nb_accesses_direct = serializers.IntegerField(read_only=True)
user_role = serializers.SerializerMethodField(read_only=True) user_roles = serializers.SerializerMethodField(read_only=True)
abilities = serializers.SerializerMethodField(read_only=True) abilities = serializers.SerializerMethodField(read_only=True)
deleted_at = serializers.SerializerMethodField(read_only=True)
class Meta: class Meta:
model = models.Document model = models.Document
fields = [ fields = [
"id", "id",
"abilities", "abilities",
"ancestors_link_reach",
"ancestors_link_role",
"computed_link_reach",
"computed_link_role",
"created_at", "created_at",
"creator", "creator",
"deleted_at",
"depth", "depth",
"excerpt", "excerpt",
"is_favorite", "is_favorite",
@@ -116,18 +188,13 @@ class ListDocumentSerializer(serializers.ModelSerializer):
"path", "path",
"title", "title",
"updated_at", "updated_at",
"user_role", "user_roles",
] ]
read_only_fields = [ read_only_fields = [
"id", "id",
"abilities", "abilities",
"ancestors_link_reach",
"ancestors_link_role",
"computed_link_reach",
"computed_link_role",
"created_at", "created_at",
"creator", "creator",
"deleted_at",
"depth", "depth",
"excerpt", "excerpt",
"is_favorite", "is_favorite",
@@ -138,70 +205,49 @@ class ListDocumentSerializer(serializers.ModelSerializer):
"numchild", "numchild",
"path", "path",
"updated_at", "updated_at",
"user_role", "user_roles",
] ]
def to_representation(self, instance): def get_abilities(self, document) -> dict:
"""Precompute once per instance"""
paths_links_mapping = self.context.get("paths_links_mapping")
if paths_links_mapping is not None:
links = paths_links_mapping.get(instance.path[: -instance.steplen], [])
instance.ancestors_link_definition = choices.get_equivalent_link_definition(
links
)
return super().to_representation(instance)
def get_abilities(self, instance) -> dict:
"""Return abilities of the logged-in user on the instance.""" """Return abilities of the logged-in user on the instance."""
request = self.context.get("request") request = self.context.get("request")
if not request:
return {}
return instance.get_abilities(request.user) if request:
paths_links_mapping = self.context.get("paths_links_mapping", None)
# Retrieve ancestor links from paths_links_mapping (if provided)
ancestors_links = (
paths_links_mapping.get(document.path[: -document.steplen])
if paths_links_mapping
else None
)
return document.get_abilities(request.user, ancestors_links=ancestors_links)
def get_user_role(self, instance): return {}
def get_user_roles(self, document):
""" """
Return roles of the logged-in user for the current document, Return roles of the logged-in user for the current document,
taking into account ancestors. taking into account ancestors.
""" """
request = self.context.get("request") request = self.context.get("request")
return instance.get_role(request.user) if request else None if request:
return document.get_roles(request.user)
def get_deleted_at(self, instance): return []
"""Return the deleted_at of the current document."""
return instance.ancestors_deleted_at
class DocumentLightSerializer(serializers.ModelSerializer):
"""Minial document serializer for nesting in document accesses."""
class Meta:
model = models.Document
fields = ["id", "path", "depth"]
read_only_fields = ["id", "path", "depth"]
class DocumentSerializer(ListDocumentSerializer): class DocumentSerializer(ListDocumentSerializer):
"""Serialize documents with all fields for display in detail views.""" """Serialize documents with all fields for display in detail views."""
content = serializers.CharField(required=False) content = serializers.CharField(required=False)
websocket = serializers.BooleanField(required=False, write_only=True)
class Meta: class Meta:
model = models.Document model = models.Document
fields = [ fields = [
"id", "id",
"abilities", "abilities",
"ancestors_link_reach",
"ancestors_link_role",
"computed_link_reach",
"computed_link_role",
"content", "content",
"created_at", "created_at",
"creator", "creator",
"deleted_at",
"depth", "depth",
"excerpt", "excerpt",
"is_favorite", "is_favorite",
@@ -213,19 +259,13 @@ class DocumentSerializer(ListDocumentSerializer):
"path", "path",
"title", "title",
"updated_at", "updated_at",
"user_role", "user_roles",
"websocket",
] ]
read_only_fields = [ read_only_fields = [
"id", "id",
"abilities", "abilities",
"ancestors_link_reach",
"ancestors_link_role",
"computed_link_reach",
"computed_link_role",
"created_at", "created_at",
"creator", "creator",
"deleted_at",
"depth", "depth",
"is_favorite", "is_favorite",
"link_role", "link_role",
@@ -235,7 +275,7 @@ class DocumentSerializer(ListDocumentSerializer):
"numchild", "numchild",
"path", "path",
"updated_at", "updated_at",
"user_role", "user_roles",
] ]
def get_fields(self): def get_fields(self):
@@ -321,99 +361,6 @@ class DocumentSerializer(ListDocumentSerializer):
return super().save(**kwargs) return super().save(**kwargs)
class DocumentAccessSerializer(serializers.ModelSerializer):
"""Serialize document accesses."""
document = DocumentLightSerializer(read_only=True)
user_id = serializers.PrimaryKeyRelatedField(
queryset=models.User.objects.all(),
write_only=True,
source="user",
required=False,
allow_null=True,
)
user = UserSerializer(read_only=True)
team = serializers.CharField(required=False, allow_blank=True)
abilities = serializers.SerializerMethodField(read_only=True)
max_ancestors_role = serializers.SerializerMethodField(read_only=True)
max_role = serializers.SerializerMethodField(read_only=True)
class Meta:
model = models.DocumentAccess
resource_field_name = "document"
fields = [
"id",
"document",
"user",
"user_id",
"team",
"role",
"abilities",
"max_ancestors_role",
"max_role",
]
read_only_fields = [
"id",
"document",
"abilities",
"max_ancestors_role",
"max_role",
]
def get_abilities(self, instance) -> dict:
"""Return abilities of the logged-in user on the instance."""
request = self.context.get("request")
if request:
return instance.get_abilities(request.user)
return {}
def get_max_ancestors_role(self, instance):
"""Return max_ancestors_role if annotated; else None."""
return getattr(instance, "max_ancestors_role", None)
def get_max_role(self, instance):
"""Return max_ancestors_role if annotated; else None."""
return choices.RoleChoices.max(
getattr(instance, "max_ancestors_role", None),
instance.role,
)
def update(self, instance, validated_data):
"""Make "user" field readonly but only on update."""
validated_data.pop("team", None)
validated_data.pop("user", None)
return super().update(instance, validated_data)
class DocumentAccessLightSerializer(DocumentAccessSerializer):
"""Serialize document accesses with limited fields."""
user = UserLightSerializer(read_only=True)
class Meta:
model = models.DocumentAccess
resource_field_name = "document"
fields = [
"id",
"document",
"user",
"team",
"role",
"abilities",
"max_ancestors_role",
"max_role",
]
read_only_fields = [
"id",
"document",
"team",
"role",
"abilities",
"max_ancestors_role",
"max_role",
]
class ServerCreateDocumentSerializer(serializers.Serializer): class ServerCreateDocumentSerializer(serializers.Serializer):
""" """
Serializer for creating a document from a server-to-server request. Serializer for creating a document from a server-to-server request.
@@ -432,7 +379,7 @@ class ServerCreateDocumentSerializer(serializers.Serializer):
content = serializers.CharField(required=True) content = serializers.CharField(required=True)
# User # User
sub = serializers.CharField( sub = serializers.CharField(
required=True, validators=[validators.sub_validator], max_length=255 required=True, validators=[models.User.sub_validator], max_length=255
) )
email = serializers.EmailField(required=True) email = serializers.EmailField(required=True)
language = serializers.ChoiceField( language = serializers.ChoiceField(
@@ -461,7 +408,9 @@ class ServerCreateDocumentSerializer(serializers.Serializer):
language = user.language or language language = user.language or language
try: try:
document_content = YdocConverter().convert(validated_data["content"]) document_content = YdocConverter().convert_markdown(
validated_data["content"]
)
except ConversionError as err: except ConversionError as err:
raise serializers.ValidationError( raise serializers.ValidationError(
{"content": ["Could not convert content"]} {"content": ["Could not convert content"]}
@@ -516,10 +465,6 @@ class LinkDocumentSerializer(serializers.ModelSerializer):
We expose it separately from document in order to simplify and secure access control. We expose it separately from document in order to simplify and secure access control.
""" """
link_reach = serializers.ChoiceField(
choices=models.LinkReachChoices.choices, required=True
)
class Meta: class Meta:
model = models.Document model = models.Document
fields = [ fields = [
@@ -527,58 +472,6 @@ class LinkDocumentSerializer(serializers.ModelSerializer):
"link_reach", "link_reach",
] ]
def validate(self, attrs):
"""Validate that link_role and link_reach are compatible using get_select_options."""
link_reach = attrs.get("link_reach")
link_role = attrs.get("link_role")
if not link_reach:
raise serializers.ValidationError(
{"link_reach": _("This field is required.")}
)
# Get available options based on ancestors' link definition
available_options = models.LinkReachChoices.get_select_options(
**self.instance.ancestors_link_definition
)
# Validate link_reach is allowed
if link_reach not in available_options:
msg = _(
"Link reach '%(link_reach)s' is not allowed based on parent document configuration."
)
raise serializers.ValidationError(
{"link_reach": msg % {"link_reach": link_reach}}
)
# Validate link_role is compatible with link_reach
allowed_roles = available_options[link_reach]
# Restricted reach: link_role must be None
if link_reach == models.LinkReachChoices.RESTRICTED:
if link_role is not None:
raise serializers.ValidationError(
{
"link_role": (
"Cannot set link_role when link_reach is 'restricted'. "
"Link role must be null for restricted reach."
)
}
)
return attrs
# Non-restricted: link_role must be in allowed roles
if link_role not in allowed_roles:
allowed_roles_str = ", ".join(allowed_roles) if allowed_roles else "none"
raise serializers.ValidationError(
{
"link_role": (
f"Link role '{link_role}' is not allowed for link reach '{link_reach}'. "
f"Allowed roles: {allowed_roles_str}"
)
}
)
return attrs
class DocumentDuplicationSerializer(serializers.Serializer): class DocumentDuplicationSerializer(serializers.Serializer):
""" """
@@ -624,17 +517,16 @@ class FileUploadSerializer(serializers.Serializer):
mime = magic.Magic(mime=True) mime = magic.Magic(mime=True)
magic_mime_type = mime.from_buffer(file.read(1024)) magic_mime_type = mime.from_buffer(file.read(1024))
file.seek(0) # Reset file pointer to the beginning after reading file.seek(0) # Reset file pointer to the beginning after reading
self.context["is_unsafe"] = False
if settings.DOCUMENT_ATTACHMENT_CHECK_UNSAFE_MIME_TYPES_ENABLED:
self.context["is_unsafe"] = (
magic_mime_type in settings.DOCUMENT_UNSAFE_MIME_TYPES
)
extension_mime_type, _ = mimetypes.guess_type(file.name) self.context["is_unsafe"] = (
magic_mime_type in settings.DOCUMENT_UNSAFE_MIME_TYPES
)
# Try guessing a coherent extension from the mimetype extension_mime_type, _ = mimetypes.guess_type(file.name)
if extension_mime_type != magic_mime_type:
self.context["is_unsafe"] = True # Try guessing a coherent extension from the mimetype
if extension_mime_type != magic_mime_type:
self.context["is_unsafe"] = True
guessed_ext = mimetypes.guess_extension(magic_mime_type) guessed_ext = mimetypes.guess_extension(magic_mime_type)
# Missing extensions or extensions longer than 5 characters (it's as long as an extension # Missing extensions or extensions longer than 5 characters (it's as long as an extension
@@ -750,9 +642,6 @@ class InvitationSerializer(serializers.ModelSerializer):
if self.instance is None: if self.instance is None:
attrs["issuer"] = user attrs["issuer"] = user
if attrs.get("email"):
attrs["email"] = attrs["email"].lower()
return attrs return attrs
def validate_role(self, role): def validate_role(self, role):
@@ -775,52 +664,6 @@ class InvitationSerializer(serializers.ModelSerializer):
return role return role
class RoleSerializer(serializers.Serializer):
"""Serializer validating role choices."""
role = serializers.ChoiceField(
choices=models.RoleChoices.choices, required=False, allow_null=True
)
class DocumentAskForAccessCreateSerializer(serializers.Serializer):
"""Serializer for creating a document ask for access."""
role = serializers.ChoiceField(
choices=[
role for role in choices.RoleChoices if role != models.RoleChoices.OWNER
],
required=False,
default=models.RoleChoices.READER,
)
class DocumentAskForAccessSerializer(serializers.ModelSerializer):
"""Serializer for document ask for access model"""
abilities = serializers.SerializerMethodField(read_only=True)
user = UserSerializer(read_only=True)
class Meta:
model = models.DocumentAskForAccess
fields = [
"id",
"document",
"user",
"role",
"created_at",
"abilities",
]
read_only_fields = ["id", "document", "user", "role", "created_at", "abilities"]
def get_abilities(self, instance) -> dict:
"""Return abilities of the logged-in user on the instance."""
request = self.context.get("request")
if request:
return instance.get_abilities(request.user)
return {}
class VersionFilterSerializer(serializers.Serializer): class VersionFilterSerializer(serializers.Serializer):
"""Validate version filters applied to the list endpoint.""" """Validate version filters applied to the list endpoint."""
@@ -892,124 +735,3 @@ class MoveDocumentSerializer(serializers.Serializer):
choices=enums.MoveNodePositionChoices.choices, choices=enums.MoveNodePositionChoices.choices,
default=enums.MoveNodePositionChoices.LAST_CHILD, default=enums.MoveNodePositionChoices.LAST_CHILD,
) )
class ReactionSerializer(serializers.ModelSerializer):
"""Serialize reactions."""
users = UserLightSerializer(many=True, read_only=True)
class Meta:
model = models.Reaction
fields = [
"id",
"emoji",
"created_at",
"users",
]
read_only_fields = ["id", "created_at", "users"]
class CommentSerializer(serializers.ModelSerializer):
"""Serialize comments (nested under a thread) with reactions and abilities."""
user = UserLightSerializer(read_only=True)
abilities = serializers.SerializerMethodField()
reactions = ReactionSerializer(many=True, read_only=True)
class Meta:
model = models.Comment
fields = [
"id",
"user",
"body",
"created_at",
"updated_at",
"reactions",
"abilities",
]
read_only_fields = [
"id",
"user",
"created_at",
"updated_at",
"reactions",
"abilities",
]
def validate(self, attrs):
"""Validate comment data."""
request = self.context.get("request")
user = getattr(request, "user", None)
attrs["thread_id"] = self.context["thread_id"]
attrs["user_id"] = user.id if user else None
return attrs
def get_abilities(self, obj):
"""Return comment's abilities."""
request = self.context.get("request")
if request:
return obj.get_abilities(request.user)
return {}
class ThreadSerializer(serializers.ModelSerializer):
"""Serialize threads in a backward compatible shape for current frontend.
We expose a flatten representation where ``content`` maps to the first
comment's body. Creating a thread requires a ``content`` field which is
stored as the first comment.
"""
creator = UserLightSerializer(read_only=True)
abilities = serializers.SerializerMethodField(read_only=True)
body = serializers.JSONField(write_only=True, required=True)
comments = serializers.SerializerMethodField(read_only=True)
comments = CommentSerializer(many=True, read_only=True)
class Meta:
model = models.Thread
fields = [
"id",
"body",
"created_at",
"updated_at",
"creator",
"abilities",
"comments",
"resolved",
"resolved_at",
"resolved_by",
"metadata",
]
read_only_fields = [
"id",
"created_at",
"updated_at",
"creator",
"abilities",
"comments",
"resolved",
"resolved_at",
"resolved_by",
"metadata",
]
def validate(self, attrs):
"""Validate thread data."""
request = self.context.get("request")
user = getattr(request, "user", None)
attrs["document_id"] = self.context["resource_id"]
attrs["creator_id"] = user.id if user else None
return attrs
def get_abilities(self, thread):
"""Return thread's abilities."""
request = self.context.get("request")
if request:
return thread.get_abilities(request.user)
return {}
-21
View File
@@ -1,21 +0,0 @@
"""Throttling modules for the API."""
from rest_framework.throttling import UserRateThrottle
from sentry_sdk import capture_message
def sentry_monitoring_throttle_failure(message):
"""Log when a failure occurs to detect rate limiting issues."""
capture_message(message, "warning")
class UserListThrottleBurst(UserRateThrottle):
"""Throttle for the user list endpoint."""
scope = "user_list_burst"
class UserListThrottleSustained(UserRateThrottle):
"""Throttle for the user list endpoint."""
scope = "user_list_sustained"
File diff suppressed because it is too large Load Diff
-117
View File
@@ -1,117 +0,0 @@
"""Declare and configure choices for Docs' core application."""
from django.db.models import TextChoices
from django.utils.translation import gettext_lazy as _
class PriorityTextChoices(TextChoices):
"""
This class inherits from Django's TextChoices and provides a method to get the priority
of a given value based on its position in the class.
"""
@classmethod
def get_priority(cls, role):
"""Returns the priority of the given role based on its order in the class."""
members = list(cls.__members__.values())
return members.index(role) + 1 if role in members else 0
@classmethod
def max(cls, *roles):
"""
Return the highest-priority role among the given roles, using get_priority().
If no valid roles are provided, returns None.
"""
valid_roles = [role for role in roles if cls.get_priority(role) is not None]
if not valid_roles:
return None
return max(valid_roles, key=cls.get_priority)
class LinkRoleChoices(PriorityTextChoices):
"""Defines the possible roles a link can offer on a document."""
READER = "reader", _("Reader") # Can read
COMMENTER = "commenter", _("Commenter") # Can read and comment
EDITOR = "editor", _("Editor") # Can read and edit
class RoleChoices(PriorityTextChoices):
"""Defines the possible roles a user can have in a resource."""
READER = "reader", _("Reader") # Can read
COMMENTER = "commenter", _("Commenter") # Can read and comment
EDITOR = "editor", _("Editor") # Can read and edit
ADMIN = "administrator", _("Administrator") # Can read, edit, delete and share
OWNER = "owner", _("Owner")
PRIVILEGED_ROLES = [RoleChoices.ADMIN, RoleChoices.OWNER]
class LinkReachChoices(PriorityTextChoices):
"""Defines types of access for links"""
RESTRICTED = (
"restricted",
_("Restricted"),
) # Only users with a specific access can read/edit the document
AUTHENTICATED = (
"authenticated",
_("Authenticated"),
) # Any authenticated user can access the document
PUBLIC = "public", _("Public") # Even anonymous users can access the document
@classmethod
def get_select_options(cls, link_reach, link_role):
"""
Determines the valid select options for link reach and link role depending on the
ancestors' link reach/role given as arguments.
Returns:
Dictionary mapping possible reach levels to their corresponding possible roles.
"""
return {
reach: [
role
for role in LinkRoleChoices.values
if LinkRoleChoices.get_priority(role)
>= LinkRoleChoices.get_priority(link_role)
]
if reach != cls.RESTRICTED
else None
for reach in cls.values
if LinkReachChoices.get_priority(reach)
>= LinkReachChoices.get_priority(link_reach)
}
def get_equivalent_link_definition(ancestors_links):
"""
Return the (reach, role) pair with:
1. Highest reach
2. Highest role among links having that reach
"""
if not ancestors_links:
return {"link_reach": None, "link_role": None}
# 1) Find the highest reach
max_reach = max(
ancestors_links,
key=lambda link: LinkReachChoices.get_priority(link["link_reach"]),
)["link_reach"]
# 2) Among those, find the highest role (ignore role if RESTRICTED)
if max_reach == LinkReachChoices.RESTRICTED:
max_role = None
else:
max_role = max(
(
link["link_role"]
for link in ancestors_links
if link["link_reach"] == max_reach
),
key=LinkRoleChoices.get_priority,
)
return {"link_reach": max_reach, "link_role": max_role}
+2 -69
View File
@@ -1,3 +1,4 @@
# ruff: noqa: S311
""" """
Core application factories Core application factories
""" """
@@ -34,8 +35,6 @@ class UserFactory(factory.django.DjangoModelFactory):
class Meta: class Meta:
model = models.User model = models.User
# Skip postgeneration save, no save is made in the postgeneration methods.
skip_postgeneration_save = True
sub = factory.Sequence(lambda n: f"user{n!s}") sub = factory.Sequence(lambda n: f"user{n!s}")
email = factory.Faker("email") email = factory.Faker("email")
@@ -150,7 +149,7 @@ class DocumentFactory(factory.django.DjangoModelFactory):
"""Add link traces to document from a given list of users.""" """Add link traces to document from a given list of users."""
if create and extracted: if create and extracted:
for item in extracted: for item in extracted:
models.LinkTrace.objects.update_or_create(document=self, user=item) models.LinkTrace.objects.create(document=self, user=item)
@factory.post_generation @factory.post_generation
def favorited_by(self, create, extracted, **kwargs): def favorited_by(self, create, extracted, **kwargs):
@@ -159,15 +158,6 @@ class DocumentFactory(factory.django.DjangoModelFactory):
for item in extracted: for item in extracted:
models.DocumentFavorite.objects.create(document=self, user=item) models.DocumentFavorite.objects.create(document=self, user=item)
@factory.post_generation
def masked_by(self, create, extracted, **kwargs):
"""Mark document as masked by a list of users."""
if create and extracted:
for item in extracted:
models.LinkTrace.objects.update_or_create(
document=self, user=item, defaults={"is_masked": True}
)
class UserDocumentAccessFactory(factory.django.DjangoModelFactory): class UserDocumentAccessFactory(factory.django.DjangoModelFactory):
"""Create fake document user accesses for testing.""" """Create fake document user accesses for testing."""
@@ -191,17 +181,6 @@ class TeamDocumentAccessFactory(factory.django.DjangoModelFactory):
role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices]) role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices])
class DocumentAskForAccessFactory(factory.django.DjangoModelFactory):
"""Create fake document ask for access for testing."""
class Meta:
model = models.DocumentAskForAccess
document = factory.SubFactory(DocumentFactory)
user = factory.SubFactory(UserFactory)
role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices])
class TemplateFactory(factory.django.DjangoModelFactory): class TemplateFactory(factory.django.DjangoModelFactory):
"""A factory to create templates""" """A factory to create templates"""
@@ -256,49 +235,3 @@ class InvitationFactory(factory.django.DjangoModelFactory):
document = factory.SubFactory(DocumentFactory) document = factory.SubFactory(DocumentFactory)
role = factory.fuzzy.FuzzyChoice([role[0] for role in models.RoleChoices.choices]) role = factory.fuzzy.FuzzyChoice([role[0] for role in models.RoleChoices.choices])
issuer = factory.SubFactory(UserFactory) issuer = factory.SubFactory(UserFactory)
class ThreadFactory(factory.django.DjangoModelFactory):
"""A factory to create threads for a document"""
class Meta:
model = models.Thread
document = factory.SubFactory(DocumentFactory)
creator = factory.SubFactory(UserFactory)
class CommentFactory(factory.django.DjangoModelFactory):
"""A factory to create comments for a thread"""
class Meta:
model = models.Comment
thread = factory.SubFactory(ThreadFactory)
user = factory.SubFactory(UserFactory)
body = factory.Faker("text")
class ReactionFactory(factory.django.DjangoModelFactory):
"""A factory to create reactions for a comment"""
class Meta:
model = models.Reaction
comment = factory.SubFactory(CommentFactory)
emoji = "test"
@factory.post_generation
def users(self, create, extracted, **kwargs):
"""Add users to reaction from a given list of users or create one if not provided."""
if not create:
return
if not extracted:
# the factory is being created, but no users were provided
user = UserFactory()
self.users.add(user)
return
# Add the iterable of groups using bulk addition
self.users.add(*extracted)
-21
View File
@@ -1,21 +0,0 @@
"""Force session creation for all requests."""
class ForceSessionMiddleware:
"""
Force session creation for unauthenticated users.
Must be used after Authentication middleware.
"""
def __init__(self, get_response):
"""Initialize the middleware."""
self.get_response = get_response
def __call__(self, request):
"""Force session creation for unauthenticated users."""
if not request.user.is_authenticated and request.session.session_key is None:
request.session.create()
response = self.get_response(request)
return response
+2 -2
View File
@@ -504,7 +504,7 @@ class Migration(migrations.Migration):
migrations.AddConstraint( migrations.AddConstraint(
model_name="documentaccess", model_name="documentaccess",
constraint=models.CheckConstraint( constraint=models.CheckConstraint(
condition=models.Q( check=models.Q(
models.Q(("team", ""), ("user__isnull", False)), models.Q(("team", ""), ("user__isnull", False)),
models.Q(("team__gt", ""), ("user__isnull", True)), models.Q(("team__gt", ""), ("user__isnull", True)),
_connector="OR", _connector="OR",
@@ -540,7 +540,7 @@ class Migration(migrations.Migration):
migrations.AddConstraint( migrations.AddConstraint(
model_name="templateaccess", model_name="templateaccess",
constraint=models.CheckConstraint( constraint=models.CheckConstraint(
condition=models.Q( check=models.Q(
models.Q(("team", ""), ("user__isnull", False)), models.Q(("team", ""), ("user__isnull", False)),
models.Q(("team__gt", ""), ("user__isnull", True)), models.Q(("team__gt", ""), ("user__isnull", True)),
_connector="OR", _connector="OR",
@@ -1,89 +0,0 @@
# Generated by Django 5.2.3 on 2025-06-18 10:02
import uuid
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0021_activate_unaccent_extension"),
]
operations = [
migrations.CreateModel(
name="DocumentAskForAccess",
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",
),
),
(
"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="ask_for_accesses",
to="core.document",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="ask_for_accesses",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "Document ask for access",
"verbose_name_plural": "Document ask for accesses",
"db_table": "impress_document_ask_for_access",
"constraints": [
models.UniqueConstraint(
fields=("user", "document"),
name="unique_document_ask_for_access_user",
violation_error_message="This user has already asked for access to this document.",
)
],
},
),
]
@@ -1,17 +0,0 @@
# Generated by Django 5.1.7 on 2025-03-14 14:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0022_alter_user_language_documentaskforaccess"),
]
operations = [
migrations.AddField(
model_name="document",
name="has_deleted_children",
field=models.BooleanField(default=False),
),
]
@@ -1,51 +0,0 @@
# Generated by Django 5.2.3 on 2025-07-13 08:22
from django.db import migrations, models
import core.validators
class Migration(migrations.Migration):
dependencies = [
("core", "0023_remove_document_is_public_and_more"),
]
operations = [
migrations.AddField(
model_name="linktrace",
name="is_masked",
field=models.BooleanField(default=False),
),
migrations.AlterField(
model_name="user",
name="language",
field=models.CharField(
blank=True,
choices=[
("en-us", "English"),
("fr-fr", "Français"),
("de-de", "Deutsch"),
("nl-nl", "Nederlands"),
("es-es", "Español"),
],
default=None,
help_text="The language in which the user wants to see the interface.",
max_length=10,
null=True,
verbose_name="language",
),
),
migrations.AlterField(
model_name="user",
name="sub",
field=models.CharField(
blank=True,
help_text="Required. 255 characters or fewer. ASCII characters only.",
max_length=255,
null=True,
unique=True,
validators=[core.validators.sub_validator],
verbose_name="sub",
),
),
]
@@ -1,19 +0,0 @@
# Generated by Django 5.2.7 on 2025-10-22 06:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0024_add_is_masked_field_to_link_trace"),
]
operations = [
migrations.AlterField(
model_name="user",
name="short_name",
field=models.CharField(
blank=True, max_length=100, null=True, verbose_name="short name"
),
),
]
@@ -1,275 +0,0 @@
# Generated by Django 5.2.6 on 2025-09-16 08:59
import uuid
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0025_alter_user_short_name"),
]
operations = [
migrations.AlterField(
model_name="document",
name="link_role",
field=models.CharField(
choices=[
("reader", "Reader"),
("commenter", "Commenter"),
("editor", "Editor"),
],
default="reader",
max_length=20,
),
),
migrations.AlterField(
model_name="documentaccess",
name="role",
field=models.CharField(
choices=[
("reader", "Reader"),
("commenter", "Commenter"),
("editor", "Editor"),
("administrator", "Administrator"),
("owner", "Owner"),
],
default="reader",
max_length=20,
),
),
migrations.AlterField(
model_name="documentaskforaccess",
name="role",
field=models.CharField(
choices=[
("reader", "Reader"),
("commenter", "Commenter"),
("editor", "Editor"),
("administrator", "Administrator"),
("owner", "Owner"),
],
default="reader",
max_length=20,
),
),
migrations.AlterField(
model_name="invitation",
name="role",
field=models.CharField(
choices=[
("reader", "Reader"),
("commenter", "Commenter"),
("editor", "Editor"),
("administrator", "Administrator"),
("owner", "Owner"),
],
default="reader",
max_length=20,
),
),
migrations.AlterField(
model_name="templateaccess",
name="role",
field=models.CharField(
choices=[
("reader", "Reader"),
("commenter", "Commenter"),
("editor", "Editor"),
("administrator", "Administrator"),
("owner", "Owner"),
],
default="reader",
max_length=20,
),
),
migrations.CreateModel(
name="Thread",
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",
),
),
("resolved", models.BooleanField(default=False)),
("resolved_at", models.DateTimeField(blank=True, null=True)),
("metadata", models.JSONField(blank=True, default=dict)),
(
"creator",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="threads",
to=settings.AUTH_USER_MODEL,
),
),
(
"document",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="threads",
to="core.document",
),
),
(
"resolved_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="resolved_threads",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "Thread",
"verbose_name_plural": "Threads",
"db_table": "impress_thread",
"ordering": ("-created_at",),
},
),
migrations.CreateModel(
name="Comment",
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",
),
),
("body", models.JSONField()),
("metadata", models.JSONField(blank=True, default=dict)),
(
"user",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="thread_comment",
to=settings.AUTH_USER_MODEL,
),
),
(
"thread",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="comments",
to="core.thread",
),
),
],
options={
"verbose_name": "Comment",
"verbose_name_plural": "Comments",
"db_table": "impress_comment",
"ordering": ("created_at",),
},
),
migrations.CreateModel(
name="Reaction",
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",
),
),
("emoji", models.CharField(max_length=32)),
(
"comment",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="reactions",
to="core.comment",
),
),
(
"users",
models.ManyToManyField(
related_name="reactions", to=settings.AUTH_USER_MODEL
),
),
],
options={
"verbose_name": "Reaction",
"verbose_name_plural": "Reactions",
"db_table": "impress_comment_reaction",
"constraints": [
models.UniqueConstraint(
fields=("comment", "emoji"),
name="unique_comment_emoji",
violation_error_message="This emoji has already been reacted to this comment.",
)
],
},
),
]
+261 -589
View File
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -9,8 +9,7 @@ from core import enums
AI_ACTIONS = { AI_ACTIONS = {
"prompt": ( "prompt": (
"Answer the prompt using markdown formatting for structure and emphasis. " "Answer the prompt in markdown format. "
"Return the content directly without wrapping it in code blocks or markdown delimiters. "
"Preserve the language and markdown formatting. " "Preserve the language and markdown formatting. "
"Do not provide any other information. " "Do not provide any other information. "
"Preserve the language." "Preserve the language."
@@ -41,35 +41,3 @@ class CollaborationService:
f"Failed to notify WebSocket server. Status code: {response.status_code}, " f"Failed to notify WebSocket server. Status code: {response.status_code}, "
f"Response: {response.text}" f"Response: {response.text}"
) )
def get_document_connection_info(self, room, session_key):
"""
Get the connection info for a document.
"""
endpoint = "get-connections"
querystring = {
"room": room,
"sessionKey": session_key,
}
endpoint_url = f"{settings.COLLABORATION_API_URL}{endpoint}/"
headers = {"Authorization": settings.COLLABORATION_SERVER_SECRET}
try:
response = requests.get(
endpoint_url, headers=headers, params=querystring, timeout=10
)
except requests.RequestException as e:
raise requests.HTTPError("Failed to get document connection info.") from e
if response.status_code == 200:
result = response.json()
return result.get("count", 0), result.get("exists", False)
if response.status_code == 404:
return 0, False
raise requests.HTTPError(
f"Failed to get document connection info. Status code: {response.status_code}, "
f"Response: {response.text}"
)
+40 -34
View File
@@ -1,6 +1,4 @@
"""Y-Provider API services.""" """Converter services."""
from base64 import b64encode
from django.conf import settings from django.conf import settings
@@ -19,6 +17,14 @@ class ServiceUnavailableError(ConversionError):
"""Raised when the conversion service is unavailable.""" """Raised when the conversion service is unavailable."""
class InvalidResponseError(ConversionError):
"""Raised when the conversion service returns an invalid response."""
class MissingContentError(ConversionError):
"""Raised when the response is missing required content."""
class YdocConverter: class YdocConverter:
"""Service class for conversion-related operations.""" """Service class for conversion-related operations."""
@@ -26,47 +32,47 @@ class YdocConverter:
def auth_header(self): def auth_header(self):
"""Build microservice authentication header.""" """Build microservice authentication header."""
# Note: Yprovider microservice accepts only raw token, which is not recommended # Note: Yprovider microservice accepts only raw token, which is not recommended
return f"Bearer {settings.Y_PROVIDER_API_KEY}" return settings.Y_PROVIDER_API_KEY
def _request(self, url, data, content_type, accept): def convert_markdown(self, text):
"""Make a request to the Y-Provider API."""
response = requests.post(
url,
data=data,
headers={
"Authorization": self.auth_header,
"Content-Type": content_type,
"Accept": accept,
},
timeout=settings.CONVERSION_API_TIMEOUT,
verify=settings.CONVERSION_API_SECURE,
)
response.raise_for_status()
return response
def convert(
self, text, content_type="text/markdown", accept="application/vnd.yjs.doc"
):
"""Convert a Markdown text into our internal format using an external microservice.""" """Convert a Markdown text into our internal format using an external microservice."""
if not text: if not text:
raise ValidationError("Input text cannot be empty") raise ValidationError("Input text cannot be empty")
try: try:
response = self._request( response = requests.post(
f"{settings.Y_PROVIDER_API_BASE_URL}{settings.CONVERSION_API_ENDPOINT}/", f"{settings.Y_PROVIDER_API_BASE_URL}{settings.CONVERSION_API_ENDPOINT}/",
text, json={
content_type, "content": text,
accept, },
headers={
"Authorization": self.auth_header,
"Content-Type": "application/json",
},
timeout=settings.CONVERSION_API_TIMEOUT,
verify=settings.CONVERSION_API_SECURE,
) )
if accept == "application/vnd.yjs.doc": response.raise_for_status()
return b64encode(response.content).decode("utf-8") conversion_response = response.json()
if accept in {"text/markdown", "text/html"}:
return response.text
if accept == "application/json":
return response.json()
raise ValidationError("Unsupported format")
except requests.RequestException as err: except requests.RequestException as err:
raise ServiceUnavailableError( raise ServiceUnavailableError(
"Failed to connect to conversion service", "Failed to connect to conversion service",
) from err ) from err
except ValueError as err:
raise InvalidResponseError(
"Could not parse conversion service response"
) from err
try:
document_content = conversion_response[
settings.CONVERSION_API_CONTENT_FIELD
]
except KeyError as err:
raise MissingContentError(
f"Response missing required field: {settings.CONVERSION_API_CONTENT_FIELD}"
) from err
return document_content
View File
-24
View File
@@ -1,24 +0,0 @@
"""Send mail using celery task."""
from django.conf import settings
from core import models
from impress.celery_app import app
@app.task
def send_ask_for_access_mail(ask_for_access_id):
"""Send mail using celery task."""
# Send email to document owners/admins
ask_for_access = models.DocumentAskForAccess.objects.get(id=ask_for_access_id)
owner_admin_accesses = models.DocumentAccess.objects.filter(
document=ask_for_access.document, role__in=models.PRIVILEGED_ROLES
).select_related("user")
for access in owner_admin_accesses:
if access.user and access.user.email:
ask_for_access.send_ask_for_access_email(
access.user.email,
access.user.language or settings.LANGUAGE_CODE,
)
@@ -57,7 +57,7 @@ def test_authentication_getter_existing_user_via_email(
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked) monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with django_assert_num_queries(4): # user by sub, user by mail, update sub with django_assert_num_queries(3): # user by sub, user by mail, update sub
user = klass.get_or_create_user( user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None access_token="test-token", id_token=None, payload=None
) )
@@ -214,7 +214,7 @@ def test_authentication_getter_existing_user_change_fields_sub(
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked) monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
# One and only one additional update query when a field has changed # One and only one additional update query when a field has changed
with django_assert_num_queries(3): with django_assert_num_queries(2):
authenticated_user = klass.get_or_create_user( authenticated_user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None access_token="test-token", id_token=None, payload=None
) )
@@ -256,7 +256,7 @@ def test_authentication_getter_existing_user_change_fields_email(
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked) monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
# One and only one additional update query when a field has changed # One and only one additional update query when a field has changed
with django_assert_num_queries(4): with django_assert_num_queries(3):
authenticated_user = klass.get_or_create_user( authenticated_user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None access_token="test-token", id_token=None, payload=None
) )
@@ -1,16 +1,14 @@
""" """
Test document accesses API endpoints for users in impress's core app. Test document accesses API endpoints for users in impress's core app.
""" """
# pylint: disable=too-many-lines
import random import random
from unittest import mock
from uuid import uuid4 from uuid import uuid4
import pytest import pytest
from rest_framework.test import APIClient from rest_framework.test import APIClient
from core import choices, factories, models from core import factories, models
from core.api import serializers from core.api import serializers
from core.tests.conftest import TEAM, USER, VIA from core.tests.conftest import TEAM, USER, VIA
from core.tests.test_services_collaboration_services import ( # pylint: disable=unused-import from core.tests.test_services_collaboration_services import ( # pylint: disable=unused-import
@@ -53,7 +51,12 @@ def test_api_document_accesses_list_authenticated_unrelated():
f"/api/v1.0/documents/{document.id!s}/accesses/", f"/api/v1.0/documents/{document.id!s}/accesses/",
) )
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == [] assert response.json() == {
"count": 0,
"next": None,
"previous": None,
"results": [],
}
def test_api_document_accesses_list_unexisting_document(): def test_api_document_accesses_list_unexisting_document():
@@ -66,46 +69,39 @@ def test_api_document_accesses_list_unexisting_document():
client.force_login(user) client.force_login(user)
response = client.get(f"/api/v1.0/documents/{uuid4()!s}/accesses/") response = client.get(f"/api/v1.0/documents/{uuid4()!s}/accesses/")
assert response.status_code == 404 assert response.status_code == 200
assert response.json() == {"detail": "Not found."} assert response.json() == {
"count": 0,
"next": None,
"previous": None,
"results": [],
}
@pytest.mark.parametrize("via", VIA) @pytest.mark.parametrize("via", VIA)
@pytest.mark.parametrize( @pytest.mark.parametrize(
"role", "role", [role for role in models.RoleChoices if role not in models.PRIVILEGED_ROLES]
[role for role in choices.RoleChoices if role not in choices.PRIVILEGED_ROLES],
) )
def test_api_document_accesses_list_authenticated_related_non_privileged( def test_api_document_accesses_list_authenticated_related_non_privileged(
via, role, mock_user_teams, django_assert_num_queries via, role, mock_user_teams
): ):
""" """
Authenticated users with no privileged role should only be able to list document Authenticated users should be able to list document accesses for a document
accesses associated with privileged roles for a document, including from ancestors. to which they are directly related, whatever their role in the document.
""" """
user = factories.UserFactory() user = factories.UserFactory()
client = APIClient() client = APIClient()
client.force_login(user) client.force_login(user)
# Create documents structured as a tree owner = factories.UserFactory()
unreadable_ancestor = factories.DocumentFactory(link_reach="restricted") accesses = []
# make all documents below the grand parent readable without a specific access for the user
grand_parent = factories.DocumentFactory(
parent=unreadable_ancestor, link_reach="authenticated"
)
parent = factories.DocumentFactory(parent=grand_parent)
document = factories.DocumentFactory(parent=parent)
child = factories.DocumentFactory(parent=document)
# Create accesses related to each document document_access = factories.UserDocumentAccessFactory(
accesses = ( user=owner, role=models.RoleChoices.OWNER
factories.UserDocumentAccessFactory(document=unreadable_ancestor),
factories.UserDocumentAccessFactory(document=grand_parent),
factories.UserDocumentAccessFactory(document=parent),
factories.UserDocumentAccessFactory(document=document),
factories.TeamDocumentAccessFactory(document=document),
) )
factories.UserDocumentAccessFactory(document=child) accesses.append(document_access)
document = document_access.document
if via == USER: if via == USER:
models.DocumentAccess.objects.create( models.DocumentAccess.objects.create(
document=document, document=document,
@@ -120,407 +116,140 @@ def test_api_document_accesses_list_authenticated_related_non_privileged(
role=role, role=role,
) )
# Accesses for other documents to which the user is related should not be listed either access1 = factories.TeamDocumentAccessFactory(document=document)
other_access = factories.UserDocumentAccessFactory(user=user) access2 = factories.UserDocumentAccessFactory(document=document)
factories.UserDocumentAccessFactory(document=other_access.document) accesses.append(access1)
accesses.append(access2)
with django_assert_num_queries(3):
response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/")
assert response.status_code == 200
content = response.json()
# Make sure only privileged roles are returned
privileged_accesses = [
acc for acc in accesses if acc.role in choices.PRIVILEGED_ROLES
]
assert len(content) == len(privileged_accesses)
assert sorted(content, key=lambda x: x["id"]) == sorted(
[
{
"id": str(access.id),
"document": {
"id": str(access.document_id),
"path": access.document.path,
"depth": access.document.depth,
},
"user": {
"full_name": access.user.full_name,
"short_name": access.user.short_name,
}
if access.user
else None,
"team": access.team,
"role": access.role,
"max_ancestors_role": None,
"max_role": access.role,
"abilities": {
"destroy": False,
"partial_update": False,
"retrieve": False,
"set_role_to": [],
"update": False,
},
}
for access in privileged_accesses
],
key=lambda x: x["id"],
)
@pytest.mark.parametrize("via", VIA)
@pytest.mark.parametrize(
"role", [role for role in choices.RoleChoices if role in choices.PRIVILEGED_ROLES]
)
def test_api_document_accesses_list_authenticated_related_privileged(
via, role, mock_user_teams, django_assert_num_queries
):
"""
Authenticated users with a privileged role should be able to list all
document accesses whatever the role, including from ancestors.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Create documents structured as a tree
unreadable_ancestor = factories.DocumentFactory(link_reach="restricted")
# make all documents below the grand parent readable without a specific access for the user
grand_parent = factories.DocumentFactory(
parent=unreadable_ancestor, link_reach="authenticated"
)
parent = factories.DocumentFactory(parent=grand_parent)
document = factories.DocumentFactory(parent=parent)
child = factories.DocumentFactory(parent=document)
if via == USER:
user_access = models.DocumentAccess.objects.create(
document=document,
user=user,
role=role,
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
user_access = models.DocumentAccess.objects.create(
document=document,
team="lasuite",
role=role,
)
else:
raise RuntimeError()
# Create accesses related to each document
ancestors_accesses = [
# Access on unreadable ancestor should still be listed
# as the related user gains access to our document
factories.UserDocumentAccessFactory(document=unreadable_ancestor),
factories.UserDocumentAccessFactory(document=grand_parent),
factories.UserDocumentAccessFactory(document=parent),
]
document_accesses = [
factories.UserDocumentAccessFactory(document=document),
factories.TeamDocumentAccessFactory(document=document),
factories.UserDocumentAccessFactory(document=document),
user_access,
]
factories.UserDocumentAccessFactory(document=child)
# Accesses for other documents to which the user is related should not be listed either # Accesses for other documents to which the user is related should not be listed either
other_access = factories.UserDocumentAccessFactory(user=user) other_access = factories.UserDocumentAccessFactory(user=user)
factories.UserDocumentAccessFactory(document=other_access.document) factories.UserDocumentAccessFactory(document=other_access.document)
with django_assert_num_queries(3): response = client.get(
response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/") f"/api/v1.0/documents/{document.id!s}/accesses/",
)
# Return only owners
owners_accesses = [
access for access in accesses if access.role in models.PRIVILEGED_ROLES
]
assert response.status_code == 200 assert response.status_code == 200
content = response.json() content = response.json()
assert len(content) == 7 assert content["count"] == len(owners_accesses)
assert sorted(content, key=lambda x: x["id"]) == sorted( assert sorted(content["results"], key=lambda x: x["id"]) == sorted(
[ [
{ {
"id": str(access.id), "id": str(access.id),
"document": {
"id": str(access.document_id),
"path": access.document.path,
"depth": access.document.depth,
},
"user": { "user": {
"id": str(access.user.id), "id": None,
"email": access.user.email, "email": None,
"language": access.user.language,
"full_name": access.user.full_name, "full_name": access.user.full_name,
"short_name": access.user.short_name, "short_name": access.user.short_name,
} }
if access.user if access.user
else None, else None,
"max_ancestors_role": None,
"max_role": access.role,
"team": access.team, "team": access.team,
"role": access.role, "role": access.role,
"abilities": access.get_abilities(user), "abilities": access.get_abilities(user),
} }
for access in ancestors_accesses + document_accesses for access in owners_accesses
], ],
key=lambda x: x["id"], key=lambda x: x["id"],
) )
for access in content["results"]:
def test_api_document_accesses_retrieve_set_role_to_child(): assert access["role"] in models.PRIVILEGED_ROLES
"""Check set_role_to for an access with no access on the ancestor."""
user, other_user = factories.UserFactory.create_batch(2)
client = APIClient()
client.force_login(user)
parent = factories.DocumentFactory()
parent_access = factories.UserDocumentAccessFactory(
document=parent, user=user, role="owner"
)
document = factories.DocumentFactory(parent=parent)
document_access_other_user = factories.UserDocumentAccessFactory(
document=document, user=other_user, role="editor"
)
response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/")
assert response.status_code == 200
content = response.json()
assert len(content) == 2
result_dict = {
result["id"]: result["abilities"]["set_role_to"] for result in content
}
assert result_dict[str(document_access_other_user.id)] == [
"reader",
"commenter",
"editor",
"administrator",
"owner",
]
assert result_dict[str(parent_access.id)] == []
# Add an access for the other user on the parent
parent_access_other_user = factories.UserDocumentAccessFactory(
document=parent, user=other_user, role="commenter"
)
response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/")
assert response.status_code == 200
content = response.json()
assert len(content) == 3
result_dict = {
result["id"]: result["abilities"]["set_role_to"] for result in content
}
assert result_dict[str(document_access_other_user.id)] == [
"commenter",
"editor",
"administrator",
"owner",
]
assert result_dict[str(parent_access.id)] == []
assert result_dict[str(parent_access_other_user.id)] == [
"reader",
"commenter",
"editor",
"administrator",
"owner",
]
@pytest.mark.parametrize( @pytest.mark.parametrize("via", VIA)
"roles,results", @pytest.mark.parametrize("role", models.PRIVILEGED_ROLES)
[ def test_api_document_accesses_list_authenticated_related_privileged_roles(
[ via, role, mock_user_teams
["administrator", "reader", "reader", "reader"],
[
["reader", "commenter", "editor", "administrator"],
[],
[],
["reader", "commenter", "editor", "administrator"],
],
],
[
["owner", "reader", "reader", "reader"],
[
["reader", "commenter", "editor", "administrator", "owner"],
[],
[],
["reader", "commenter", "editor", "administrator", "owner"],
],
],
[
["owner", "reader", "reader", "owner"],
[
["reader", "commenter", "editor", "administrator", "owner"],
[],
[],
["reader", "commenter", "editor", "administrator", "owner"],
],
],
],
)
def test_api_document_accesses_list_authenticated_related_same_user(roles, results):
"""
The maximum role across ancestor documents and set_role_to optionsfor
a given user should be filled as expected.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Create documents structured as a tree
grand_parent = factories.DocumentFactory(link_reach="authenticated")
parent = factories.DocumentFactory(parent=grand_parent)
document = factories.DocumentFactory(parent=parent)
# Create accesses for another user
other_user = factories.UserFactory()
accesses = [
factories.UserDocumentAccessFactory(
document=document, user=user, role=roles[0]
),
factories.UserDocumentAccessFactory(
document=grand_parent, user=other_user, role=roles[1]
),
factories.UserDocumentAccessFactory(
document=parent, user=other_user, role=roles[2]
),
factories.UserDocumentAccessFactory(
document=document, user=other_user, role=roles[3]
),
]
response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/")
assert response.status_code == 200
content = response.json()
assert len(content) == 4
for result in content:
assert (
result["max_ancestors_role"] is None
if result["user"]["id"] == str(user.id)
else choices.RoleChoices.max(roles[1], roles[2])
)
result_dict = {
result["id"]: result["abilities"]["set_role_to"] for result in content
}
assert [result_dict[str(access.id)] for access in accesses] == results
@pytest.mark.parametrize(
"roles,results",
[
[
["administrator", "reader", "reader", "reader"],
[
["reader", "commenter", "editor", "administrator"],
[],
[],
["reader", "commenter", "editor", "administrator"],
],
],
[
["owner", "reader", "reader", "reader"],
[
["reader", "commenter", "editor", "administrator", "owner"],
[],
[],
["reader", "commenter", "editor", "administrator", "owner"],
],
],
[
["owner", "reader", "reader", "owner"],
[
["reader", "commenter", "editor", "administrator", "owner"],
[],
[],
["reader", "commenter", "editor", "administrator", "owner"],
],
],
[
["reader", "reader", "reader", "owner"],
[
["reader", "commenter", "editor", "administrator", "owner"],
[],
[],
["reader", "commenter", "editor", "administrator", "owner"],
],
],
[
["reader", "administrator", "reader", "editor"],
[
["reader", "commenter", "editor", "administrator"],
["reader", "commenter", "editor", "administrator"],
[],
[],
],
],
[
["editor", "editor", "administrator", "editor"],
[
["reader", "commenter", "editor", "administrator"],
[],
["editor", "administrator"],
[],
],
],
],
)
def test_api_document_accesses_list_authenticated_related_same_team(
roles, results, mock_user_teams
): ):
""" """
The maximum role across ancestor documents and set_role_to optionsfor Authenticated users should be able to list document accesses for a document
a given team should be filled as expected. to which they are directly related, whatever their role in the document.
""" """
user = factories.UserFactory() user = factories.UserFactory()
client = APIClient() client = APIClient()
client.force_login(user) client.force_login(user)
# Create documents structured as a tree owner = factories.UserFactory()
grand_parent = factories.DocumentFactory(link_reach="authenticated") accesses = []
parent = factories.DocumentFactory(parent=grand_parent)
document = factories.DocumentFactory(parent=parent)
mock_user_teams.return_value = ["lasuite", "unknown"] document_access = factories.UserDocumentAccessFactory(
accesses = [ user=owner, role=models.RoleChoices.OWNER
factories.UserDocumentAccessFactory( )
document=document, user=user, role=roles[0] accesses.append(document_access)
), document = document_access.document
# Create accesses for a team user_access = None
factories.TeamDocumentAccessFactory( if via == USER:
document=grand_parent, team="lasuite", role=roles[1] user_access = models.DocumentAccess.objects.create(
), document=document,
factories.TeamDocumentAccessFactory( user=user,
document=parent, team="lasuite", role=roles[2] role=role,
), )
factories.TeamDocumentAccessFactory( elif via == TEAM:
document=document, team="lasuite", role=roles[3] mock_user_teams.return_value = ["lasuite", "unknown"]
), user_access = models.DocumentAccess.objects.create(
] document=document,
team="lasuite",
role=role,
)
response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/") access1 = factories.TeamDocumentAccessFactory(document=document)
access2 = factories.UserDocumentAccessFactory(document=document)
accesses.append(access1)
accesses.append(access2)
# Accesses for other documents to which the user is related should not be listed either
other_access = factories.UserDocumentAccessFactory(user=user)
factories.UserDocumentAccessFactory(document=other_access.document)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/accesses/",
)
access2_user = serializers.UserSerializer(instance=access2.user).data
base_user = serializers.UserSerializer(instance=user).data
assert response.status_code == 200 assert response.status_code == 200
content = response.json() content = response.json()
assert len(content) == 4 assert len(content["results"]) == 4
assert sorted(content["results"], key=lambda x: x["id"]) == sorted(
for result in content: [
assert ( {
result["max_ancestors_role"] is None "id": str(user_access.id),
if result["user"] and result["user"]["id"] == str(user.id) "user": base_user if via == "user" else None,
else choices.RoleChoices.max(roles[1], roles[2]) "team": "lasuite" if via == "team" else "",
) "role": user_access.role,
"abilities": user_access.get_abilities(user),
result_dict = { },
result["id"]: result["abilities"]["set_role_to"] for result in content {
} "id": str(access1.id),
assert [result_dict[str(access.id)] for access in accesses] == results "user": None,
"team": access1.team,
"role": access1.role,
"abilities": access1.get_abilities(user),
},
{
"id": str(access2.id),
"user": access2_user,
"team": "",
"role": access2.role,
"abilities": access2.get_abilities(user),
},
{
"id": str(document_access.id),
"user": serializers.UserSerializer(instance=owner).data,
"team": "",
"role": models.RoleChoices.OWNER,
"abilities": document_access.get_abilities(user),
},
],
key=lambda x: x["id"],
)
def test_api_document_accesses_retrieve_anonymous(): def test_api_document_accesses_retrieve_anonymous():
@@ -578,9 +307,7 @@ def test_api_document_accesses_retrieve_authenticated_unrelated():
@pytest.mark.parametrize("via", VIA) @pytest.mark.parametrize("via", VIA)
@pytest.mark.parametrize("role", models.RoleChoices) @pytest.mark.parametrize("role", models.RoleChoices)
def test_api_document_accesses_retrieve_authenticated_related( def test_api_document_accesses_retrieve_authenticated_related(
via, via, role, mock_user_teams
role,
mock_user_teams,
): ):
""" """
A user who is related to a document should be allowed to retrieve the A user who is related to a document should be allowed to retrieve the
@@ -606,7 +333,7 @@ def test_api_document_accesses_retrieve_authenticated_related(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
) )
if not role in choices.PRIVILEGED_ROLES: if not role in models.PRIVILEGED_ROLES:
assert response.status_code == 403 assert response.status_code == 403
else: else:
access_user = serializers.UserSerializer(instance=access.user).data access_user = serializers.UserSerializer(instance=access.user).data
@@ -614,16 +341,9 @@ def test_api_document_accesses_retrieve_authenticated_related(
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == { assert response.json() == {
"id": str(access.id), "id": str(access.id),
"document": {
"id": str(access.document_id),
"path": access.document.path,
"depth": access.document.depth,
},
"user": access_user, "user": access_user,
"team": "", "team": "",
"role": access.role, "role": access.role,
"max_ancestors_role": None,
"max_role": access.role,
"abilities": access.get_abilities(user), "abilities": access.get_abilities(user),
} }
@@ -728,9 +448,7 @@ def test_api_document_accesses_update_authenticated_reader_or_editor(
@pytest.mark.parametrize("via", VIA) @pytest.mark.parametrize("via", VIA)
@pytest.mark.parametrize("create_for", VIA)
def test_api_document_accesses_update_administrator_except_owner( def test_api_document_accesses_update_administrator_except_owner(
create_for,
via, via,
mock_user_teams, mock_user_teams,
mock_reset_connections, # pylint: disable=redefined-outer-name mock_reset_connections, # pylint: disable=redefined-outer-name
@@ -763,31 +481,32 @@ def test_api_document_accesses_update_administrator_except_owner(
new_values = { new_values = {
"id": uuid4(), "id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(["administrator", "editor", "reader"]), "role": random.choice(["administrator", "editor", "reader"]),
} }
if create_for == USER:
new_values["user_id"] = factories.UserFactory().id
elif create_for == TEAM:
new_values["team"] = "new-team"
for field, value in new_values.items(): for field, value in new_values.items():
new_data = {**old_values, field: value} new_data = {**old_values, field: value}
with mock_reset_connections(document.id, str(access.user_id)): if new_data["role"] == old_values["role"]:
response = client.put( response = client.put(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
data=new_data, data=new_data,
format="json", format="json",
) )
assert response.status_code == 200 assert response.status_code == 403
else:
with mock_reset_connections(document.id, str(access.user_id)):
response = client.put(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
)
assert response.status_code == 200
access.refresh_from_db() access.refresh_from_db()
updated_values = serializers.DocumentAccessSerializer(instance=access).data updated_values = serializers.DocumentAccessSerializer(instance=access).data
if field in ["role", "max_role"]: if field == "role":
assert updated_values == { assert updated_values == {**old_values, "role": new_values["role"]}
**old_values,
"role": new_values["role"],
"max_role": new_values["role"],
}
else: else:
assert updated_values == old_values assert updated_values == old_values
@@ -882,7 +601,7 @@ def test_api_document_accesses_update_administrator_to_owner(
for field, value in new_values.items(): for field, value in new_values.items():
new_data = {**old_values, field: value} new_data = {**old_values, field: value}
# We are not allowed or not really updating the role # We are not allowed or not really updating the role
if field == "role": if field == "role" or new_data["role"] == old_values["role"]:
response = client.put( response = client.put(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
data=new_data, data=new_data,
@@ -905,9 +624,7 @@ def test_api_document_accesses_update_administrator_to_owner(
@pytest.mark.parametrize("via", VIA) @pytest.mark.parametrize("via", VIA)
@pytest.mark.parametrize("create_for", VIA)
def test_api_document_accesses_update_owner( def test_api_document_accesses_update_owner(
create_for,
via, via,
mock_user_teams, mock_user_teams,
mock_reset_connections, # pylint: disable=redefined-outer-name mock_reset_connections, # pylint: disable=redefined-outer-name
@@ -938,39 +655,42 @@ def test_api_document_accesses_update_owner(
new_values = { new_values = {
"id": uuid4(), "id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.values), "role": random.choice(models.RoleChoices.values),
} }
if create_for == USER:
new_values["user_id"] = factories.UserFactory().id
elif create_for == TEAM:
new_values["team"] = "new-team"
for field, value in new_values.items(): for field, value in new_values.items():
new_data = {**old_values, field: value} new_data = {**old_values, field: value}
with mock_reset_connections(document.id, str(access.user_id)): if (
new_data["role"] == old_values["role"]
): # we are not really updating the role
response = client.put( response = client.put(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
data=new_data, data=new_data,
format="json", format="json",
) )
assert response.status_code == 403
else:
with mock_reset_connections(document.id, str(access.user_id)):
response = client.put(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
)
assert response.status_code == 200 assert response.status_code == 200
access.refresh_from_db() access.refresh_from_db()
updated_values = serializers.DocumentAccessSerializer(instance=access).data updated_values = serializers.DocumentAccessSerializer(instance=access).data
if field in ["role", "max_role"]: if field == "role":
assert updated_values == { assert updated_values == {**old_values, "role": new_values["role"]}
**old_values,
"role": new_values["role"],
"max_role": new_values["role"],
}
else: else:
assert updated_values == old_values assert updated_values == old_values
@pytest.mark.parametrize("via", VIA) @pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_update_owner_self_root( def test_api_document_accesses_update_owner_self(
via, via,
mock_user_teams, mock_user_teams,
mock_reset_connections, # pylint: disable=redefined-outer-name mock_reset_connections, # pylint: disable=redefined-outer-name
@@ -1031,51 +751,6 @@ def test_api_document_accesses_update_owner_self_root(
assert access.role == new_role assert access.role == new_role
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_update_owner_self_child(
via,
mock_user_teams,
mock_reset_connections, # pylint: disable=redefined-outer-name
):
"""
A user who is owner of a document should be allowed to update
their own user access even if they are the only owner in the document,
provided the document is not a root.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
parent = factories.DocumentFactory()
document = factories.DocumentFactory(parent=parent)
access = None
if via == USER:
access = factories.UserDocumentAccessFactory(
document=document, user=user, role="owner"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
access = factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="owner"
)
old_values = serializers.DocumentAccessSerializer(instance=access).data
new_role = random.choice(["administrator", "editor", "reader"])
user_id = str(access.user_id) if via == USER else None
with mock_reset_connections(document.id, user_id):
response = client.put(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
data={**old_values, "role": new_role},
format="json",
)
assert response.status_code == 200
access.refresh_from_db()
assert access.role == new_role
# Delete # Delete
@@ -1256,16 +931,17 @@ def test_api_document_accesses_delete_owners(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
) )
assert response.status_code == 204 assert response.status_code == 204
assert models.DocumentAccess.objects.count() == 1 assert models.DocumentAccess.objects.count() == 1
@pytest.mark.parametrize("via", VIA) @pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_delete_owners_last_owner_root(via, mock_user_teams): def test_api_document_accesses_delete_owners_last_owner(via, mock_user_teams):
""" """
It should not be possible to delete the last owner access from a root document It should not be possible to delete the last owner access from a document
""" """
user = factories.UserFactory(with_owned_document=True) user = factories.UserFactory(with_owned_document=True)
client = APIClient() client = APIClient()
client.force_login(user) client.force_login(user)
@@ -1288,84 +964,3 @@ def test_api_document_accesses_delete_owners_last_owner_root(via, mock_user_team
assert response.status_code == 403 assert response.status_code == 403
assert models.DocumentAccess.objects.count() == 2 assert models.DocumentAccess.objects.count() == 2
def test_api_document_accesses_delete_owners_last_owner_child_user(
mock_reset_connections, # pylint: disable=redefined-outer-name
):
"""
It should be possible to delete the last owner access from a document that is not a root.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
parent = factories.DocumentFactory()
document = factories.DocumentFactory(parent=parent)
access = None
access = factories.UserDocumentAccessFactory(
document=document, user=user, role="owner"
)
assert models.DocumentAccess.objects.count() == 2
with mock_reset_connections(document.id, str(access.user_id)):
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 204
assert models.DocumentAccess.objects.count() == 1
@pytest.mark.skip(
reason="Pending fix on https://github.com/suitenumerique/docs/issues/969"
)
def test_api_document_accesses_delete_owners_last_owner_child_team(
mock_user_teams,
mock_reset_connections, # pylint: disable=redefined-outer-name
):
"""
It should be possible to delete the last owner access from a document that
is not a root.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
parent = factories.DocumentFactory()
document = factories.DocumentFactory(parent=parent)
access = None
mock_user_teams.return_value = ["lasuite", "unknown"]
access = factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="owner"
)
assert models.DocumentAccess.objects.count() == 2
with mock_reset_connections(document.id, str(access.user_id)):
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 204
assert models.DocumentAccess.objects.count() == 1
def test_api_document_accesses_throttling(settings):
"""Test api document accesses throttling."""
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["document_access"] = "2/minute"
user = factories.UserFactory()
document = factories.DocumentFactory()
factories.UserDocumentAccessFactory(
document=document, user=user, role="administrator"
)
client = APIClient()
client.force_login(user)
for _i in range(2):
response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/")
assert response.status_code == 200
with mock.patch("core.api.throttling.capture_message") as mock_capture_message:
response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/")
assert response.status_code == 429
mock_capture_message.assert_called_once_with(
"Rate limit exceeded for scope document_access", "warning"
)
@@ -103,37 +103,32 @@ def test_api_document_accesses_create_authenticated_reader_or_editor(
assert not models.DocumentAccess.objects.filter(user=other_user).exists() assert not models.DocumentAccess.objects.filter(user=other_user).exists()
@pytest.mark.parametrize("depth", [1, 2, 3])
@pytest.mark.parametrize("via", VIA) @pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_create_authenticated_administrator_share_to_user( def test_api_document_accesses_create_authenticated_administrator(via, mock_user_teams):
via, depth, mock_user_teams
):
""" """
Administrators of a document (direct or by heritage) should be able to create Administrators of a document should be able to create document accesses
document accesses except for the "owner" role. except for the "owner" role.
An email should be sent to the accesses to notify them of the adding. An email should be sent to the accesses to notify them of the adding.
""" """
user = factories.UserFactory(with_owned_document=True) user = factories.UserFactory(with_owned_document=True)
client = APIClient() client = APIClient()
client.force_login(user) client.force_login(user)
documents = [] document = factories.DocumentFactory()
for i in range(depth):
parent = documents[i - 1] if i > 0 else None
documents.append(factories.DocumentFactory(parent=parent))
if via == USER: if via == USER:
factories.UserDocumentAccessFactory( factories.UserDocumentAccessFactory(
document=documents[0], user=user, role="administrator" document=document, user=user, role="administrator"
) )
elif via == TEAM: elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"] mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory( factories.TeamDocumentAccessFactory(
document=documents[0], team="lasuite", role="administrator" document=document, team="lasuite", role="administrator"
) )
other_user = factories.UserFactory(language="en-us") other_user = factories.UserFactory(language="en-us")
document = documents[-1]
# It should not be allowed to create an owner access
response = client.post( response = client.post(
f"/api/v1.0/documents/{document.id!s}/accesses/", f"/api/v1.0/documents/{document.id!s}/accesses/",
{ {
@@ -145,7 +140,7 @@ def test_api_document_accesses_create_authenticated_administrator_share_to_user(
assert response.status_code == 403 assert response.status_code == 403
assert response.json() == { assert response.json() == {
"detail": "Only owners of a document can assign other users as owners." "detail": "Only owners of a resource can assign other users as owners."
} }
# It should be allowed to create a lower access # It should be allowed to create a lower access
@@ -170,16 +165,9 @@ def test_api_document_accesses_create_authenticated_administrator_share_to_user(
other_user = serializers.UserSerializer(instance=other_user).data other_user = serializers.UserSerializer(instance=other_user).data
assert response.json() == { assert response.json() == {
"abilities": new_document_access.get_abilities(user), "abilities": new_document_access.get_abilities(user),
"document": {
"id": str(new_document_access.document_id),
"depth": new_document_access.document.depth,
"path": new_document_access.document.path,
},
"id": str(new_document_access.id), "id": str(new_document_access.id),
"max_ancestors_role": None,
"max_role": role,
"role": role,
"team": "", "team": "",
"role": role,
"user": other_user, "user": other_user,
} }
assert len(mail.outbox) == 1 assert len(mail.outbox) == 1
@@ -194,119 +182,28 @@ def test_api_document_accesses_create_authenticated_administrator_share_to_user(
assert "docs/" + str(document.id) + "/" in email_content assert "docs/" + str(document.id) + "/" in email_content
@pytest.mark.parametrize("depth", [1, 2, 3])
@pytest.mark.parametrize("via", VIA) @pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_create_authenticated_administrator_share_to_team( def test_api_document_accesses_create_authenticated_owner(via, mock_user_teams):
via, depth, mock_user_teams
):
""" """
Administrators of a document (direct or by heritage) should be able to create Owners of a document should be able to create document accesses whatever the role.
document accesses except for the "owner" role.
An email should be sent to the accesses to notify them of the adding. An email should be sent to the accesses to notify them of the adding.
""" """
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
documents = []
for i in range(depth):
parent = documents[i - 1] if i > 0 else None
documents.append(factories.DocumentFactory(parent=parent))
if via == USER:
factories.UserDocumentAccessFactory(
document=documents[0], user=user, role="administrator"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=documents[0], team="lasuite", role="administrator"
)
other_user = factories.UserFactory(language="en-us")
document = documents[-1]
response = client.post(
f"/api/v1.0/documents/{document.id!s}/accesses/",
{
"team": "new-team",
"role": "owner",
},
format="json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "Only owners of a document can assign other users as owners."
}
# It should be allowed to create a lower access
role = random.choice(
[role[0] for role in models.RoleChoices.choices if role[0] != "owner"]
)
assert len(mail.outbox) == 0
response = client.post(
f"/api/v1.0/documents/{document.id!s}/accesses/",
{
"team": "new-team",
"role": role,
},
format="json",
)
assert response.status_code == 201
assert models.DocumentAccess.objects.filter(team="new-team").count() == 1
new_document_access = models.DocumentAccess.objects.filter(team="new-team").get()
other_user = serializers.UserSerializer(instance=other_user).data
assert response.json() == {
"abilities": new_document_access.get_abilities(user),
"document": {
"id": str(new_document_access.document_id),
"depth": new_document_access.document.depth,
"path": new_document_access.document.path,
},
"id": str(new_document_access.id),
"max_ancestors_role": None,
"max_role": role,
"role": role,
"team": "new-team",
"user": None,
}
assert len(mail.outbox) == 0
@pytest.mark.parametrize("depth", [1, 2, 3])
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_create_authenticated_owner_share_to_user(
via, depth, mock_user_teams
):
"""
Owners of a document (direct or by heritage) should be able to create document accesses
whatever the role. An email should be sent to the accesses to notify them of the adding.
"""
user = factories.UserFactory() user = factories.UserFactory()
client = APIClient() client = APIClient()
client.force_login(user) client.force_login(user)
documents = [] document = factories.DocumentFactory()
for i in range(depth):
parent = documents[i - 1] if i > 0 else None
documents.append(factories.DocumentFactory(parent=parent))
if via == USER: if via == USER:
factories.UserDocumentAccessFactory( factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
document=documents[0], user=user, role="owner"
)
elif via == TEAM: elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"] mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory( factories.TeamDocumentAccessFactory(
document=documents[0], team="lasuite", role="owner" document=document, team="lasuite", role="owner"
) )
other_user = factories.UserFactory(language="en-us") other_user = factories.UserFactory(language="en-us")
document = documents[-1]
role = random.choice([role[0] for role in models.RoleChoices.choices]) role = random.choice([role[0] for role in models.RoleChoices.choices])
assert len(mail.outbox) == 0 assert len(mail.outbox) == 0
@@ -325,18 +222,11 @@ def test_api_document_accesses_create_authenticated_owner_share_to_user(
new_document_access = models.DocumentAccess.objects.filter(user=other_user).get() new_document_access = models.DocumentAccess.objects.filter(user=other_user).get()
other_user = serializers.UserSerializer(instance=other_user).data other_user = serializers.UserSerializer(instance=other_user).data
assert response.json() == { assert response.json() == {
"abilities": new_document_access.get_abilities(user),
"document": {
"id": str(new_document_access.document_id),
"depth": new_document_access.document.depth,
"path": new_document_access.document.path,
},
"id": str(new_document_access.id), "id": str(new_document_access.id),
"max_ancestors_role": None,
"max_role": role,
"role": role,
"team": "",
"user": other_user, "user": other_user,
"team": "",
"role": role,
"abilities": new_document_access.get_abilities(user),
} }
assert len(mail.outbox) == 1 assert len(mail.outbox) == 1
email = mail.outbox[0] email = mail.outbox[0]
@@ -350,71 +240,6 @@ def test_api_document_accesses_create_authenticated_owner_share_to_user(
assert "docs/" + str(document.id) + "/" in email_content assert "docs/" + str(document.id) + "/" in email_content
@pytest.mark.parametrize("depth", [1, 2, 3])
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_create_authenticated_owner_share_to_team(
via, depth, mock_user_teams
):
"""
Owners of a document (direct or by heritage) should be able to create document accesses
whatever the role. An email should be sent to the accesses to notify them of the adding.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
documents = []
for i in range(depth):
parent = documents[i - 1] if i > 0 else None
documents.append(factories.DocumentFactory(parent=parent))
if via == USER:
factories.UserDocumentAccessFactory(
document=documents[0], user=user, role="owner"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=documents[0], team="lasuite", role="owner"
)
other_user = factories.UserFactory(language="en-us")
document = documents[-1]
role = random.choice([role[0] for role in models.RoleChoices.choices])
assert len(mail.outbox) == 0
response = client.post(
f"/api/v1.0/documents/{document.id!s}/accesses/",
{
"team": "new-team",
"role": role,
},
format="json",
)
assert response.status_code == 201
assert models.DocumentAccess.objects.filter(team="new-team").count() == 1
new_document_access = models.DocumentAccess.objects.filter(team="new-team").get()
other_user = serializers.UserSerializer(instance=other_user).data
assert response.json() == {
"abilities": new_document_access.get_abilities(user),
"document": {
"id": str(new_document_access.document_id),
"path": new_document_access.document.path,
"depth": new_document_access.document.depth,
},
"id": str(new_document_access.id),
"max_ancestors_role": None,
"max_role": role,
"role": role,
"team": "new-team",
"user": None,
}
assert len(mail.outbox) == 0
@pytest.mark.parametrize("via", VIA) @pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_create_email_in_receivers_language(via, mock_user_teams): def test_api_document_accesses_create_email_in_receivers_language(via, mock_user_teams):
""" """
@@ -461,18 +286,11 @@ def test_api_document_accesses_create_email_in_receivers_language(via, mock_user
).get() ).get()
other_user_data = serializers.UserSerializer(instance=other_user).data other_user_data = serializers.UserSerializer(instance=other_user).data
assert response.json() == { assert response.json() == {
"abilities": new_document_access.get_abilities(user),
"document": {
"id": str(new_document_access.document_id),
"path": new_document_access.document.path,
"depth": new_document_access.document.depth,
},
"id": str(new_document_access.id), "id": str(new_document_access.id),
"max_ancestors_role": None,
"max_role": role,
"role": role,
"team": "",
"user": other_user_data, "user": other_user_data,
"team": "",
"role": role,
"abilities": new_document_access.get_abilities(user),
} }
assert len(mail.outbox) == index + 1 assert len(mail.outbox) == index + 1
email = mail.outbox[index] email = mail.outbox[index]
@@ -596,32 +596,6 @@ def test_api_document_invitations_create_cannot_invite_existing_users():
} }
def test_api_document_invitations_create_lower_email():
"""
No matter the case, the email should be converted to lowercase.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(users=[(user, "owner")])
# Build an invitation to the email of an existing identity in the db
invitation_values = {
"email": "GuEst@example.com",
"role": random.choice(models.RoleChoices.values),
}
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/invitations/",
invitation_values,
format="json",
)
assert response.status_code == 201
assert response.json()["email"] == "guest@example.com"
# Update # Update
@@ -769,37 +743,6 @@ def test_api_document_invitations_update_authenticated_unprivileged(
assert value == old_invitation_values[key] assert value == old_invitation_values[key]
@pytest.mark.parametrize("via", VIA)
@pytest.mark.parametrize("role", ["administrator", "owner"])
def test_api_document_invitations_patch(via, role, mock_user_teams):
"""Partially updating an invitation should be allowed."""
user = factories.UserFactory()
invitation = factories.InvitationFactory(role="editor")
if via == USER:
factories.UserDocumentAccessFactory(
document=invitation.document, user=user, role=role
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=invitation.document, team="lasuite", role=role
)
client = APIClient()
client.force_login(user)
response = client.patch(
f"/api/v1.0/documents/{invitation.document.id!s}/invitations/{invitation.id!s}/",
{"role": "reader"},
format="json",
)
assert response.status_code == 200
invitation.refresh_from_db()
assert invitation.role == "reader"
# Delete # Delete
@@ -881,29 +824,3 @@ def test_api_document_invitations_delete_readers_or_editors(via, role, mock_user
response.json()["detail"] response.json()["detail"]
== "You do not have permission to perform this action." == "You do not have permission to perform this action."
) )
def test_api_document_invitations_throttling(settings):
"""Test api document ask for access throttling."""
current_rate = settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["invitation"]
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["invitation"] = "2/minute"
user = factories.UserFactory()
document = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
factories.InvitationFactory(document=document, issuer=user)
client = APIClient()
client.force_login(user)
for _i in range(2):
response = client.get(f"/api/v1.0/documents/{document.id}/invitations/")
assert response.status_code == 200
with mock.patch("core.api.throttling.capture_message") as mock_capture_message:
response = client.get(f"/api/v1.0/documents/{document.id}/invitations/")
assert response.status_code == 429
mock_capture_message.assert_called_once_with(
"Rate limit exceeded for scope invitation", "warning"
)
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["invitation"] = current_rate
@@ -175,11 +175,8 @@ def test_api_documents_ai_transform_authenticated_success(mock_create, reach, ro
{ {
"role": "system", "role": "system",
"content": ( "content": (
"Answer the prompt using markdown formatting for structure and emphasis. " "Answer the prompt in markdown format. Preserve the language and markdown "
"Return the content directly without wrapping it in code blocks or markdown delimiters. " "formatting. Do not provide any other information. Preserve the language."
"Preserve the language and markdown formatting. "
"Do not provide any other information. "
"Preserve the language."
), ),
}, },
{"role": "user", "content": "Hello"}, {"role": "user", "content": "Hello"},
@@ -252,11 +249,8 @@ def test_api_documents_ai_transform_success(mock_create, via, role, mock_user_te
{ {
"role": "system", "role": "system",
"content": ( "content": (
"Answer the prompt using markdown formatting for structure and emphasis. " "Answer the prompt in markdown format. Preserve the language and markdown "
"Return the content directly without wrapping it in code blocks or markdown delimiters. " "formatting. Do not provide any other information. Preserve the language."
"Preserve the language and markdown formatting. "
"Do not provide any other information. "
"Preserve the language."
), ),
}, },
{"role": "user", "content": "Hello"}, {"role": "user", "content": "Hello"},
@@ -1,892 +0,0 @@
"""Test API for document ask for access."""
import uuid
from unittest import mock
from django.core import mail
import pytest
from rest_framework.test import APIClient
from core.api.serializers import UserSerializer
from core.factories import (
DocumentAskForAccessFactory,
DocumentFactory,
UserDocumentAccessFactory,
UserFactory,
)
from core.models import DocumentAccess, DocumentAskForAccess, RoleChoices
pytestmark = pytest.mark.django_db
## Create
def test_api_documents_ask_for_access_create_anonymous():
"""Anonymous users should not be able to create a document ask for access."""
document = DocumentFactory()
client = APIClient()
response = client.post(f"/api/v1.0/documents/{document.id}/ask-for-access/")
assert response.status_code == 401
def test_api_documents_ask_for_access_create_invalid_document_id():
"""Invalid document ID should return a 404 error."""
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(f"/api/v1.0/documents/{uuid.uuid4()}/ask-for-access/")
assert response.status_code == 404
def test_api_documents_ask_for_access_create_authenticated():
"""
Authenticated users should be able to create a document ask for access.
An email should be sent to document owners and admins to notify them.
"""
owner_user = UserFactory(language="en-us")
admin_user = UserFactory(language="en-us")
document = DocumentFactory(
users=[
(owner_user, RoleChoices.OWNER),
(admin_user, RoleChoices.ADMIN),
]
)
user = UserFactory()
client = APIClient()
client.force_login(user)
assert len(mail.outbox) == 0
response = client.post(f"/api/v1.0/documents/{document.id}/ask-for-access/")
assert response.status_code == 201
assert DocumentAskForAccess.objects.filter(
document=document,
user=user,
role=RoleChoices.READER,
).exists()
# Verify emails were sent to both owner and admin
assert len(mail.outbox) == 2
# Check that emails were sent to the right recipients
email_recipients = [email.to[0] for email in mail.outbox]
assert owner_user.email in email_recipients
assert admin_user.email in email_recipients
# Check email content for both users
for email in mail.outbox:
email_content = " ".join(email.body.split())
email_subject = " ".join(email.subject.split())
# Check that the requesting user's name is in the email
user_name = user.full_name or user.email
assert user_name.lower() in email_content.lower()
# Check that the subject mentions access request
assert "access" in email_subject.lower()
# Check that the document title is mentioned if it exists
if document.title:
assert document.title.lower() in email_subject.lower()
def test_api_documents_ask_for_access_create_authenticated_non_root_document():
"""
Authenticated users should not be able to create a document ask for access on a non-root
document.
"""
parent = DocumentFactory()
child = DocumentFactory(parent=parent)
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(f"/api/v1.0/documents/{child.id}/ask-for-access/")
assert response.status_code == 404
@pytest.mark.parametrize(
"role", [role for role in RoleChoices if role != RoleChoices.OWNER]
)
def test_api_documents_ask_for_access_create_authenticated_specific_role(role):
"""
Authenticated users should be able to create a document ask for access with a specific role.
"""
document = DocumentFactory()
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id}/ask-for-access/",
data={"role": role},
)
assert response.status_code == 201
assert DocumentAskForAccess.objects.filter(
document=document,
user=user,
role=role,
).exists()
def test_api_documents_ask_for_access_create_authenticated_owner_role():
"""
Authenticated users should not be able to create a document ask for access with the owner role.
"""
document = DocumentFactory()
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id}/ask-for-access/",
data={"role": RoleChoices.OWNER},
)
assert response.status_code == 400
assert response.json() == {"role": ['"owner" is not a valid choice.']}
def test_api_documents_ask_for_access_create_authenticated_already_has_access():
"""Authenticated users with existing access can ask for access with a different role."""
user = UserFactory()
document = DocumentFactory(users=[(user, RoleChoices.READER)])
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id}/ask-for-access/",
data={"role": RoleChoices.EDITOR},
)
assert response.status_code == 201
assert DocumentAskForAccess.objects.filter(
document=document,
user=user,
role=RoleChoices.EDITOR,
).exists()
def test_api_documents_ask_for_access_create_authenticated_already_has_ask_for_access():
"""
Authenticated users with existing ask for access can not ask for a new access on this document.
"""
user = UserFactory()
document = DocumentFactory(users=[(user, RoleChoices.READER)])
DocumentAskForAccessFactory(document=document, user=user, role=RoleChoices.READER)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id}/ask-for-access/",
data={"role": RoleChoices.EDITOR},
)
assert response.status_code == 400
assert response.json() == {"detail": "You already ask to access to this document."}
## List
def test_api_documents_ask_for_access_list_anonymous():
"""Anonymous users should not be able to list document ask for access."""
document = DocumentFactory()
DocumentAskForAccessFactory.create_batch(
3, document=document, role=RoleChoices.READER
)
client = APIClient()
response = client.get(f"/api/v1.0/documents/{document.id}/ask-for-access/")
assert response.status_code == 401
def test_api_documents_ask_for_access_list_authenticated():
"""Authenticated users should be able to list document ask for access."""
document = DocumentFactory()
DocumentAskForAccessFactory.create_batch(
3, document=document, role=RoleChoices.READER
)
client = APIClient()
client.force_login(UserFactory())
response = client.get(f"/api/v1.0/documents/{document.id}/ask-for-access/")
assert response.status_code == 200
assert response.json() == {
"count": 0,
"next": None,
"previous": None,
"results": [],
}
def test_api_documents_ask_for_access_list_authenticated_non_root_document():
"""
Authenticated users should not be able to list document ask for access on a non-root document.
"""
parent = DocumentFactory()
child = DocumentFactory(parent=parent)
client = APIClient()
client.force_login(UserFactory())
response = client.get(f"/api/v1.0/documents/{child.id}/ask-for-access/")
assert response.status_code == 404
def test_api_documents_ask_for_access_list_authenticated_own_request():
"""Authenticated users should be able to list their own document ask for access."""
document = DocumentFactory()
DocumentAskForAccessFactory.create_batch(
3, document=document, role=RoleChoices.READER
)
user = UserFactory()
user_data = UserSerializer(instance=user).data
document_ask_for_access = DocumentAskForAccessFactory(
document=document, user=user, role=RoleChoices.READER
)
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/documents/{document.id}/ask-for-access/")
assert response.status_code == 200
assert response.json() == {
"count": 1,
"next": None,
"previous": None,
"results": [
{
"id": str(document_ask_for_access.id),
"document": str(document.id),
"user": user_data,
"role": RoleChoices.READER,
"created_at": document_ask_for_access.created_at.isoformat().replace(
"+00:00", "Z"
),
"abilities": {
"accept": False,
"destroy": False,
"update": False,
"partial_update": False,
"retrieve": False,
"set_role_to": [],
},
}
],
}
def test_api_documents_ask_for_access_list_authenticated_other_document():
"""Authenticated users should not be able to list document ask for access of other documents."""
document = DocumentFactory()
DocumentAskForAccessFactory.create_batch(
3, document=document, role=RoleChoices.READER
)
client = APIClient()
client.force_login(UserFactory())
other_document = DocumentFactory()
DocumentAskForAccessFactory.create_batch(
3, document=other_document, role=RoleChoices.READER
)
response = client.get(f"/api/v1.0/documents/{other_document.id}/ask-for-access/")
assert response.status_code == 200
assert response.json() == {
"count": 0,
"next": None,
"previous": None,
"results": [],
}
@pytest.mark.parametrize("role", [RoleChoices.READER, RoleChoices.EDITOR])
def test_api_documents_ask_for_access_list_non_owner_or_admin(role):
"""Non owner or admin users should not be able to list document ask for access."""
user = UserFactory()
document = DocumentFactory(users=[(user, role)])
DocumentAskForAccessFactory.create_batch(
3, document=document, role=RoleChoices.READER
)
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/documents/{document.id}/ask-for-access/")
assert response.status_code == 200
assert response.json() == {
"count": 0,
"next": None,
"previous": None,
"results": [],
}
@pytest.mark.parametrize("role", [RoleChoices.OWNER, RoleChoices.ADMIN])
def test_api_documents_ask_for_access_list_owner_or_admin(role):
"""Owner or admin users should be able to list document ask for access."""
user = UserFactory()
document = DocumentFactory(users=[(user, role)])
document_ask_for_accesses = DocumentAskForAccessFactory.create_batch(
3, document=document, role=RoleChoices.READER
)
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/documents/{document.id}/ask-for-access/")
assert response.status_code == 200
expected_set_role_to = [
RoleChoices.READER,
RoleChoices.COMMENTER,
RoleChoices.EDITOR,
RoleChoices.ADMIN,
]
if role == RoleChoices.OWNER:
expected_set_role_to.append(RoleChoices.OWNER)
assert response.json() == {
"count": 3,
"next": None,
"previous": None,
"results": [
{
"id": str(document_ask_for_access.id),
"document": str(document.id),
"user": UserSerializer(instance=document_ask_for_access.user).data,
"role": RoleChoices.READER,
"created_at": document_ask_for_access.created_at.isoformat().replace(
"+00:00", "Z"
),
"abilities": {
"accept": True,
"destroy": True,
"update": True,
"partial_update": True,
"retrieve": True,
"set_role_to": expected_set_role_to,
},
}
for document_ask_for_access in document_ask_for_accesses
],
}
@pytest.mark.parametrize("role", [RoleChoices.OWNER, RoleChoices.ADMIN])
def test_api_documents_ask_for_access_list_admin_non_root_document(role):
"""
Authenticated users should not be able to list document ask for access on a non-root document.
"""
user = UserFactory()
parent = DocumentFactory(users=[(user, role)])
child = DocumentFactory(parent=parent, users=[(user, role)])
DocumentAskForAccessFactory.create_batch(3, document=child, role=RoleChoices.READER)
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/documents/{child.id}/ask-for-access/")
assert response.status_code == 404
## Retrieve
def test_api_documents_ask_for_access_retrieve_anonymous():
"""Anonymous users should not be able to retrieve document ask for access."""
document = DocumentFactory()
document_ask_for_access = DocumentAskForAccessFactory(
document=document, role=RoleChoices.READER
)
client = APIClient()
response = client.get(
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/"
)
assert response.status_code == 401
def test_api_documents_ask_for_access_retrieve_authenticated():
"""Authenticated users should not be able to retrieve document ask for access."""
document = DocumentFactory()
document_ask_for_access = DocumentAskForAccessFactory(
document=document, role=RoleChoices.READER
)
client = APIClient()
client.force_login(UserFactory())
response = client.get(
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/"
)
assert response.status_code == 404
@pytest.mark.parametrize("role", [RoleChoices.READER, RoleChoices.EDITOR])
def test_api_documents_ask_for_access_retrieve_authenticated_non_owner_or_admin(role):
"""Non owner or admin users should not be able to retrieve document ask for access."""
user = UserFactory()
document = DocumentFactory(users=[(user, role)])
document_ask_for_access = DocumentAskForAccessFactory(
document=document, role=RoleChoices.READER
)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/"
)
assert response.status_code == 404
@pytest.mark.parametrize("role", [RoleChoices.OWNER, RoleChoices.ADMIN])
def test_api_documents_ask_for_access_retrieve_owner_or_admin(role):
"""Owner or admin users should be able to retrieve document ask for access."""
user = UserFactory()
document = DocumentFactory(users=[(user, role)])
document_ask_for_access = DocumentAskForAccessFactory(
document=document, role=RoleChoices.READER
)
user_data = UserSerializer(instance=document_ask_for_access.user).data
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/"
)
assert response.status_code == 200
expected_set_role_to = [
RoleChoices.READER,
RoleChoices.COMMENTER,
RoleChoices.EDITOR,
RoleChoices.ADMIN,
]
if role == RoleChoices.OWNER:
expected_set_role_to.append(RoleChoices.OWNER)
assert response.json() == {
"id": str(document_ask_for_access.id),
"document": str(document.id),
"user": user_data,
"role": RoleChoices.READER,
"created_at": document_ask_for_access.created_at.isoformat().replace(
"+00:00", "Z"
),
"abilities": {
"accept": True,
"destroy": True,
"update": True,
"partial_update": True,
"retrieve": True,
"set_role_to": expected_set_role_to,
},
}
@pytest.mark.parametrize("role", [RoleChoices.OWNER, RoleChoices.ADMIN])
def test_api_documents_ask_for_access_retrieve_authenticated_non_root_document(role):
"""
Authenticated users should not be able to retrieve document ask for access on a non-root
document.
"""
user = UserFactory()
parent = DocumentFactory(users=[(user, role)])
child = DocumentFactory(parent=parent, users=[(user, role)])
document_ask_for_access = DocumentAskForAccessFactory(
document=child, role=RoleChoices.READER
)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/documents/{child.id}/ask-for-access/{document_ask_for_access.id}/"
)
assert response.status_code == 404
## Delete
def test_api_documents_ask_for_access_delete_anonymous():
"""Anonymous users should not be able to delete document ask for access."""
document = DocumentFactory()
document_ask_for_access = DocumentAskForAccessFactory(
document=document, role=RoleChoices.READER
)
client = APIClient()
response = client.delete(
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/"
)
assert response.status_code == 401
def test_api_documents_ask_for_access_delete_authenticated():
"""Authenticated users should not be able to delete document ask for access."""
document = DocumentFactory()
document_ask_for_access = DocumentAskForAccessFactory(
document=document, role=RoleChoices.READER
)
client = APIClient()
client.force_login(UserFactory())
response = client.delete(
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/"
)
assert response.status_code == 404
@pytest.mark.parametrize("role", [RoleChoices.READER, RoleChoices.EDITOR])
def test_api_documents_ask_for_access_delete_authenticated_non_owner_or_admin(role):
"""Non owner or admin users should not be able to delete document ask for access."""
user = UserFactory()
document = DocumentFactory(users=[(user, role)])
document_ask_for_access = DocumentAskForAccessFactory(
document=document, role=RoleChoices.READER
)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/"
)
assert response.status_code == 404
@pytest.mark.parametrize("role", [RoleChoices.OWNER, RoleChoices.ADMIN])
def test_api_documents_ask_for_access_delete_owner_or_admin(role):
"""Owner or admin users should be able to delete document ask for access."""
user = UserFactory()
document = DocumentFactory(users=[(user, role)])
document_ask_for_access = DocumentAskForAccessFactory(
document=document, role=RoleChoices.READER
)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/"
)
assert response.status_code == 204
assert not DocumentAskForAccess.objects.filter(
id=document_ask_for_access.id
).exists()
@pytest.mark.parametrize("role", [RoleChoices.OWNER, RoleChoices.ADMIN])
def test_api_documents_ask_for_access_delete_authenticated_non_root_document(role):
"""
Authenticated users should not be able to delete document ask for access on a non-root
document.
"""
user = UserFactory()
parent = DocumentFactory(users=[(user, role)])
child = DocumentFactory(parent=parent, users=[(user, role)])
document_ask_for_access = DocumentAskForAccessFactory(
document=child, role=RoleChoices.READER
)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/documents/{child.id}/ask-for-access/{document_ask_for_access.id}/"
)
assert response.status_code == 404
## Accept
def test_api_documents_ask_for_access_accept_anonymous():
"""Anonymous users should not be able to accept document ask for access."""
document = DocumentFactory()
document_ask_for_access = DocumentAskForAccessFactory(
document=document, role=RoleChoices.READER
)
client = APIClient()
response = client.post(
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/accept/"
)
assert response.status_code == 401
def test_api_documents_ask_for_access_accept_authenticated():
"""Authenticated users should not be able to accept document ask for access."""
document = DocumentFactory()
document_ask_for_access = DocumentAskForAccessFactory(
document=document, role=RoleChoices.READER
)
client = APIClient()
client.force_login(UserFactory())
response = client.post(
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/accept/"
)
assert response.status_code == 404
@pytest.mark.parametrize("role", [RoleChoices.READER, RoleChoices.EDITOR])
def test_api_documents_ask_for_access_accept_authenticated_non_owner_or_admin(role):
"""Non owner or admin users should not be able to accept document ask for access."""
user = UserFactory()
document = DocumentFactory(users=[(user, role)])
document_ask_for_access = DocumentAskForAccessFactory(
document=document, role=RoleChoices.READER
)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/accept/"
)
assert response.status_code == 404
@pytest.mark.parametrize("role", [RoleChoices.OWNER, RoleChoices.ADMIN])
def test_api_documents_ask_for_access_accept_owner_or_admin(role):
"""Owner or admin users should be able to accept document ask for access."""
user = UserFactory()
document = DocumentFactory(users=[(user, role)])
document_ask_for_access = DocumentAskForAccessFactory(
document=document, role=RoleChoices.READER
)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/accept/"
)
assert response.status_code == 204
assert not DocumentAskForAccess.objects.filter(
id=document_ask_for_access.id
).exists()
assert DocumentAccess.objects.filter(
document=document, user=document_ask_for_access.user, role=RoleChoices.READER
).exists()
@pytest.mark.parametrize("role", [RoleChoices.OWNER, RoleChoices.ADMIN])
def test_api_documents_ask_for_access_accept_authenticated_specific_role(role):
"""
Owner or admin users should be able to accept document ask for access with a specific role.
"""
user = UserFactory()
document = DocumentFactory(users=[(user, role)])
document_ask_for_access = DocumentAskForAccessFactory(
document=document, role=RoleChoices.READER
)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/accept/",
data={"role": RoleChoices.EDITOR},
)
assert response.status_code == 204
assert not DocumentAskForAccess.objects.filter(
id=document_ask_for_access.id
).exists()
assert DocumentAccess.objects.filter(
document=document, user=document_ask_for_access.user, role=RoleChoices.EDITOR
).exists()
@pytest.mark.parametrize("role", [RoleChoices.OWNER, RoleChoices.ADMIN])
def test_api_documents_ask_for_access_accept_authenticated_owner_or_admin_update_access(
role,
):
"""
Owner or admin users should be able to accept document ask for access and update the access.
"""
user = UserFactory()
document = DocumentFactory(users=[(user, role)])
document_access = UserDocumentAccessFactory(
document=document, role=RoleChoices.READER
)
document_ask_for_access = DocumentAskForAccessFactory(
document=document, user=document_access.user, role=RoleChoices.EDITOR
)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/accept/",
data={"role": RoleChoices.EDITOR},
)
assert response.status_code == 204
assert not DocumentAskForAccess.objects.filter(
id=document_ask_for_access.id
).exists()
document_access.refresh_from_db()
assert document_access.role == RoleChoices.EDITOR
@pytest.mark.parametrize("role", [RoleChoices.OWNER, RoleChoices.ADMIN])
# pylint: disable=line-too-long
def test_api_documents_ask_for_access_accept_authenticated_owner_or_admin_update_access_with_specific_role(
role,
):
"""
Owner or admin users should be able to accept document ask for access and update the access
with a specific role.
"""
user = UserFactory()
document = DocumentFactory(users=[(user, role)])
document_access = UserDocumentAccessFactory(
document=document, role=RoleChoices.READER
)
document_ask_for_access = DocumentAskForAccessFactory(
document=document, user=document_access.user, role=RoleChoices.EDITOR
)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/accept/",
data={"role": RoleChoices.ADMIN},
)
assert response.status_code == 204
assert not DocumentAskForAccess.objects.filter(
id=document_ask_for_access.id
).exists()
document_access.refresh_from_db()
assert document_access.role == RoleChoices.ADMIN
def test_api_documents_ask_for_access_accept_admin_cannot_accept_owner_role():
"""
Admin users should not be able to accept document ask for access with the owner role.
"""
user = UserFactory()
document = DocumentFactory(users=[(user, RoleChoices.ADMIN)])
document_ask_for_access = DocumentAskForAccessFactory(
document=document, role=RoleChoices.READER
)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/accept/",
data={"role": RoleChoices.OWNER},
)
assert response.status_code == 400
assert response.json() == {
"detail": "You cannot accept a role higher than your own."
}
def test_api_documents_ask_for_access_accept_owner_can_accept_owner_role():
"""
Owner users should be able to accept document ask for access with the owner role.
"""
user = UserFactory()
document = DocumentFactory(users=[(user, RoleChoices.OWNER)])
document_ask_for_access = DocumentAskForAccessFactory(
document=document, role=RoleChoices.READER
)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/accept/",
data={"role": RoleChoices.OWNER},
)
assert response.status_code == 204
assert not DocumentAskForAccess.objects.filter(
id=document_ask_for_access.id
).exists()
@pytest.mark.parametrize("role", [RoleChoices.OWNER, RoleChoices.ADMIN])
def test_api_documents_ask_for_access_accept_authenticated_non_root_document(role):
"""
Authenticated users should not be able to accept document ask for access on a non-root
document.
"""
user = UserFactory()
parent = DocumentFactory(users=[(user, role)])
child = DocumentFactory(parent=parent, users=[(user, role)])
document_ask_for_access = DocumentAskForAccessFactory(
document=child, role=RoleChoices.READER
)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{child.id}/ask-for-access/{document_ask_for_access.id}/accept/"
)
assert response.status_code == 404
def test_api_document_ask_for_access_throttling(settings):
"""Test api document ask for access throttling."""
current_rate = settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"][
"document_ask_for_access"
]
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["document_ask_for_access"] = (
"2/minute"
)
document = DocumentFactory()
DocumentAskForAccessFactory.create_batch(
3, document=document, role=RoleChoices.READER
)
user = UserFactory()
client = APIClient()
client.force_login(user)
for _i in range(2):
response = client.get(f"/api/v1.0/documents/{document.id}/ask-for-access/")
assert response.status_code == 200
with mock.patch("core.api.throttling.capture_message") as mock_capture_message:
response = client.get(f"/api/v1.0/documents/{document.id}/ask-for-access/")
assert response.status_code == 429
mock_capture_message.assert_called_once_with(
"Rate limit exceeded for scope document_ask_for_access", "warning"
)
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["document_ask_for_access"] = (
current_rate
)
@@ -439,56 +439,3 @@ def test_api_documents_attachment_upload_unsafe():
"application/octet-stream", "application/octet-stream",
] ]
assert file_head["ContentDisposition"] == 'attachment; filename="script.exe"' assert file_head["ContentDisposition"] == 'attachment; filename="script.exe"'
def test_api_documents_attachment_upload_unsafe_mime_types_disabled(settings):
"""A file with an unsafe mime type but checking disabled should not be tagged as unsafe."""
settings.DOCUMENT_ATTACHMENT_CHECK_UNSAFE_MIME_TYPES_ENABLED = False
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[(user, "owner")])
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
file = SimpleUploadedFile(
name="script.exe", content=b"\x4d\x5a\x90\x00\x03\x00\x00\x00"
)
with mock.patch.object(malware_detection, "analyse_file") as mock_analyse_file:
response = client.post(url, {"file": file}, format="multipart")
assert response.status_code == 201
pattern = re.compile(rf"^{document.id!s}/attachments/(.*)\.exe")
url_parsed = urlparse(response.json()["file"])
assert url_parsed.path == f"/api/v1.0/documents/{document.id!s}/media-check/"
query = parse_qs(url_parsed.query)
assert query["key"][0] is not None
file_path = query["key"][0]
match = pattern.search(file_path)
file_id = match.group(1)
document.refresh_from_db()
assert document.attachments == [f"{document.id!s}/attachments/{file_id!s}.exe"]
assert "-unsafe" not in file_id
# Validate that file_id is a valid UUID
uuid.UUID(file_id)
key = file_path.replace("/media/", "")
mock_analyse_file.assert_called_once_with(key, document_id=document.id)
# Now, check the metadata of the uploaded file
file_head = default_storage.connection.meta.client.head_object(
Bucket=default_storage.bucket_name, Key=key
)
assert file_head["Metadata"] == {
"owner": str(user.id),
"status": "processing",
}
# Depending the libmagic version, the content type may change.
assert file_head["ContentType"] in [
"application/x-dosexec",
"application/octet-stream",
]
assert file_head["ContentDisposition"] == 'attachment; filename="script.exe"'
@@ -1,318 +0,0 @@
"""Test the can_edit endpoint in the viewset DocumentViewSet."""
from django.core.cache import cache
import pytest
import responses
from rest_framework.test import APIClient
from core import factories
pytestmark = pytest.mark.django_db
@responses.activate
@pytest.mark.parametrize("ws_not_connected_ready_only", [True, False])
@pytest.mark.parametrize("role", ["editor", "reader"])
def test_api_documents_can_edit_anonymous(settings, ws_not_connected_ready_only, role):
"""Anonymous users can edit documents when link_role is editor."""
document = factories.DocumentFactory(link_reach="public", link_role=role)
client = APIClient()
session_key = client.session.session_key
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = ws_not_connected_ready_only
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, json={"count": 0, "exists": False})
response = client.get(f"/api/v1.0/documents/{document.id!s}/can-edit/")
if role == "reader":
assert response.status_code == 401
else:
assert response.status_code == 200
assert response.json() == {"can_edit": True}
assert ws_resp.call_count == (1 if ws_not_connected_ready_only else 0)
@responses.activate
@pytest.mark.parametrize("ws_not_connected_ready_only", [True, False])
def test_api_documents_can_edit_authenticated_no_websocket(
settings, ws_not_connected_ready_only
):
"""
A user not connected to the websocket and no other user have already updated the document,
the document can be updated.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = ws_not_connected_ready_only
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, json={"count": 0, "exists": False})
assert cache.get(f"docs:no-websocket:{document.id}") is None
response = client.get(
f"/api/v1.0/documents/{document.id!s}/can-edit/",
)
assert response.status_code == 200
assert response.json() == {"can_edit": True}
assert ws_resp.call_count == (1 if ws_not_connected_ready_only else 0)
@responses.activate
def test_api_documents_can_edit_authenticated_no_websocket_user_already_editing(
settings,
):
"""
A user not connected to the websocket and another user have already updated the document,
the document can not be updated.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, json={"count": 0, "exists": False})
cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
response = client.get(
f"/api/v1.0/documents/{document.id!s}/can-edit/",
)
assert response.status_code == 200
assert response.json() == {"can_edit": False}
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_can_edit_no_websocket_other_user_connected_to_websocket(
settings,
):
"""
A user not connected to the websocket and another user is connected to the websocket,
the document can not be updated.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, json={"count": 3, "exists": False})
assert cache.get(f"docs:no-websocket:{document.id}") is None
response = client.get(
f"/api/v1.0/documents/{document.id!s}/can-edit/",
)
assert response.status_code == 200
assert response.json() == {"can_edit": False}
assert cache.get(f"docs:no-websocket:{document.id}") is None
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_can_edit_user_connected_to_websocket(settings):
"""
A user connected to the websocket, the document can be updated.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, json={"count": 3, "exists": True})
assert cache.get(f"docs:no-websocket:{document.id}") is None
response = client.get(
f"/api/v1.0/documents/{document.id!s}/can-edit/",
)
assert response.status_code == 200
assert response.json() == {"can_edit": True}
assert cache.get(f"docs:no-websocket:{document.id}") is None
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_can_edit_websocket_server_unreachable_fallback_to_no_websocket(
settings,
):
"""
When the websocket server is unreachable, the document can be updated like if the user was
not connected to the websocket.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, status=500)
assert cache.get(f"docs:no-websocket:{document.id}") is None
response = client.get(
f"/api/v1.0/documents/{document.id!s}/can-edit/",
)
assert response.status_code == 200
assert response.json() == {"can_edit": True}
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_can_edit_websocket_server_unreachable_fallback_to_no_websocket_other_users(
settings,
):
"""
When the websocket server is unreachable, the behavior fallback to the no websocket one.
If an other user is already editing, the document can not be updated.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, status=500)
cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
response = client.get(
f"/api/v1.0/documents/{document.id!s}/can-edit/",
)
assert response.status_code == 200
assert response.json() == {"can_edit": False}
assert cache.get(f"docs:no-websocket:{document.id}") == "other_session_key"
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_can_edit_websocket_server_room_not_found(
settings,
):
"""
When the websocket server returns a 404, the document can be updated like if the user was
not connected to the websocket.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, status=404)
assert cache.get(f"docs:no-websocket:{document.id}") is None
response = client.get(
f"/api/v1.0/documents/{document.id!s}/can-edit/",
)
assert response.status_code == 200
assert response.json() == {"can_edit": True}
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_can_edit_websocket_server_room_not_found_other_already_editing(
settings,
):
"""
When the websocket server returns a 404 and another user is editing the document,
the response should be can-edit=False.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, status=404)
cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
response = client.get(
f"/api/v1.0/documents/{document.id!s}/can-edit/",
)
assert response.status_code == 200
assert response.json() == {"can_edit": False}
assert ws_resp.call_count == 1
@@ -98,9 +98,7 @@ def test_api_documents_children_create_authenticated_success(reach, role, depth)
if i == 0: if i == 0:
document = factories.DocumentFactory(link_reach=reach, link_role=role) document = factories.DocumentFactory(link_reach=reach, link_role=role)
else: else:
document = factories.DocumentFactory( document = factories.DocumentFactory(parent=document, link_role="reader")
parent=document, link_reach="restricted"
)
response = client.post( response = client.post(
f"/api/v1.0/documents/{document.id!s}/children/", f"/api/v1.0/documents/{document.id!s}/children/",
@@ -114,8 +112,7 @@ def test_api_documents_children_create_authenticated_success(reach, role, depth)
child = Document.objects.get(id=response.json()["id"]) child = Document.objects.get(id=response.json()["id"])
assert child.title == "my child" assert child.title == "my child"
assert child.link_reach == "restricted" assert child.link_reach == "restricted"
# Access objects on the child are not necessary assert child.accesses.filter(role="owner", user=user).exists()
assert child.accesses.exists() is False
@pytest.mark.parametrize("depth", [1, 2, 3]) @pytest.mark.parametrize("depth", [1, 2, 3])
@@ -183,8 +180,7 @@ def test_api_documents_children_create_related_success(role, depth):
child = Document.objects.get(id=response.json()["id"]) child = Document.objects.get(id=response.json()["id"])
assert child.title == "my child" assert child.title == "my child"
assert child.link_reach == "restricted" assert child.link_reach == "restricted"
# Access objects on the child are not necessary assert child.accesses.filter(role="owner", user=user).exists()
assert child.accesses.exists() is False
def test_api_documents_children_create_authenticated_title_null(): def test_api_documents_children_create_authenticated_title_null():
@@ -14,18 +14,13 @@ from core import factories
pytestmark = pytest.mark.django_db pytestmark = pytest.mark.django_db
def test_api_documents_children_list_anonymous_public_standalone( def test_api_documents_children_list_anonymous_public_standalone():
django_assert_num_queries,
):
"""Anonymous users should be allowed to retrieve the children of a public document.""" """Anonymous users should be allowed to retrieve the children of a public document."""
document = factories.DocumentFactory(link_reach="public") document = factories.DocumentFactory(link_reach="public")
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document) child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
factories.UserDocumentAccessFactory(document=child1) factories.UserDocumentAccessFactory(document=child1)
with django_assert_num_queries(8): response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/children/")
APIClient().get(f"/api/v1.0/documents/{document.id!s}/children/")
with django_assert_num_queries(4):
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/children/")
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == { assert response.json() == {
@@ -35,13 +30,8 @@ def test_api_documents_children_list_anonymous_public_standalone(
"results": [ "results": [
{ {
"abilities": child1.get_abilities(AnonymousUser()), "abilities": child1.get_abilities(AnonymousUser()),
"ancestors_link_reach": "public",
"ancestors_link_role": document.link_role,
"computed_link_reach": "public",
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"), "created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id), "creator": str(child1.creator.id),
"deleted_at": None,
"depth": 2, "depth": 2,
"excerpt": child1.excerpt, "excerpt": child1.excerpt,
"id": str(child1.id), "id": str(child1.id),
@@ -54,17 +44,12 @@ def test_api_documents_children_list_anonymous_public_standalone(
"path": child1.path, "path": child1.path,
"title": child1.title, "title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": None, "user_roles": [],
}, },
{ {
"abilities": child2.get_abilities(AnonymousUser()), "abilities": child2.get_abilities(AnonymousUser()),
"ancestors_link_reach": "public",
"ancestors_link_role": document.link_role,
"computed_link_reach": "public",
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"), "created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id), "creator": str(child2.creator.id),
"deleted_at": None,
"depth": 2, "depth": 2,
"excerpt": child2.excerpt, "excerpt": child2.excerpt,
"id": str(child2.id), "id": str(child2.id),
@@ -77,13 +62,13 @@ def test_api_documents_children_list_anonymous_public_standalone(
"path": child2.path, "path": child2.path,
"title": child2.title, "title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": None, "user_roles": [],
}, },
], ],
} }
def test_api_documents_children_list_anonymous_public_parent(django_assert_num_queries): def test_api_documents_children_list_anonymous_public_parent():
""" """
Anonymous users should be allowed to retrieve the children of a document who Anonymous users should be allowed to retrieve the children of a document who
has a public ancestor. has a public ancestor.
@@ -98,10 +83,7 @@ def test_api_documents_children_list_anonymous_public_parent(django_assert_num_q
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document) child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
factories.UserDocumentAccessFactory(document=child1) factories.UserDocumentAccessFactory(document=child1)
with django_assert_num_queries(9): response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/children/")
APIClient().get(f"/api/v1.0/documents/{document.id!s}/children/")
with django_assert_num_queries(5):
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/children/")
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == { assert response.json() == {
@@ -111,13 +93,8 @@ def test_api_documents_children_list_anonymous_public_parent(django_assert_num_q
"results": [ "results": [
{ {
"abilities": child1.get_abilities(AnonymousUser()), "abilities": child1.get_abilities(AnonymousUser()),
"ancestors_link_reach": child1.ancestors_link_reach,
"ancestors_link_role": child1.ancestors_link_role,
"computed_link_reach": child1.computed_link_reach,
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"), "created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id), "creator": str(child1.creator.id),
"deleted_at": None,
"depth": 4, "depth": 4,
"excerpt": child1.excerpt, "excerpt": child1.excerpt,
"id": str(child1.id), "id": str(child1.id),
@@ -130,17 +107,12 @@ def test_api_documents_children_list_anonymous_public_parent(django_assert_num_q
"path": child1.path, "path": child1.path,
"title": child1.title, "title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": None, "user_roles": [],
}, },
{ {
"abilities": child2.get_abilities(AnonymousUser()), "abilities": child2.get_abilities(AnonymousUser()),
"ancestors_link_reach": child2.ancestors_link_reach,
"ancestors_link_role": child2.ancestors_link_role,
"computed_link_reach": child2.computed_link_reach,
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"), "created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id), "creator": str(child2.creator.id),
"deleted_at": None,
"depth": 4, "depth": 4,
"excerpt": child2.excerpt, "excerpt": child2.excerpt,
"id": str(child2.id), "id": str(child2.id),
@@ -153,7 +125,7 @@ def test_api_documents_children_list_anonymous_public_parent(django_assert_num_q
"path": child2.path, "path": child2.path,
"title": child2.title, "title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": None, "user_roles": [],
}, },
], ],
} }
@@ -177,7 +149,7 @@ def test_api_documents_children_list_anonymous_restricted_or_authenticated(reach
@pytest.mark.parametrize("reach", ["public", "authenticated"]) @pytest.mark.parametrize("reach", ["public", "authenticated"])
def test_api_documents_children_list_authenticated_unrelated_public_or_authenticated( def test_api_documents_children_list_authenticated_unrelated_public_or_authenticated(
reach, django_assert_num_queries reach,
): ):
""" """
Authenticated users should be able to retrieve the children of a public/authenticated Authenticated users should be able to retrieve the children of a public/authenticated
@@ -191,13 +163,9 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document) child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
factories.UserDocumentAccessFactory(document=child1) factories.UserDocumentAccessFactory(document=child1)
with django_assert_num_queries(9): response = client.get(
client.get(f"/api/v1.0/documents/{document.id!s}/children/") f"/api/v1.0/documents/{document.id!s}/children/",
with django_assert_num_queries(5): )
response = client.get(
f"/api/v1.0/documents/{document.id!s}/children/",
)
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == { assert response.json() == {
"count": 2, "count": 2,
@@ -206,13 +174,8 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic
"results": [ "results": [
{ {
"abilities": child1.get_abilities(user), "abilities": child1.get_abilities(user),
"ancestors_link_reach": reach,
"ancestors_link_role": document.link_role,
"computed_link_reach": child1.computed_link_reach,
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"), "created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id), "creator": str(child1.creator.id),
"deleted_at": None,
"depth": 2, "depth": 2,
"excerpt": child1.excerpt, "excerpt": child1.excerpt,
"id": str(child1.id), "id": str(child1.id),
@@ -225,17 +188,12 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic
"path": child1.path, "path": child1.path,
"title": child1.title, "title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": None, "user_roles": [],
}, },
{ {
"abilities": child2.get_abilities(user), "abilities": child2.get_abilities(user),
"ancestors_link_reach": reach,
"ancestors_link_role": document.link_role,
"computed_link_reach": child2.computed_link_reach,
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"), "created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id), "creator": str(child2.creator.id),
"deleted_at": None,
"depth": 2, "depth": 2,
"excerpt": child2.excerpt, "excerpt": child2.excerpt,
"id": str(child2.id), "id": str(child2.id),
@@ -248,7 +206,7 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic
"path": child2.path, "path": child2.path,
"title": child2.title, "title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": None, "user_roles": [],
}, },
], ],
} }
@@ -256,7 +214,7 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic
@pytest.mark.parametrize("reach", ["public", "authenticated"]) @pytest.mark.parametrize("reach", ["public", "authenticated"])
def test_api_documents_children_list_authenticated_public_or_authenticated_parent( def test_api_documents_children_list_authenticated_public_or_authenticated_parent(
reach, django_assert_num_queries reach,
): ):
""" """
Authenticated users should be allowed to retrieve the children of a document who Authenticated users should be allowed to retrieve the children of a document who
@@ -273,11 +231,7 @@ def test_api_documents_children_list_authenticated_public_or_authenticated_paren
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document) child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
factories.UserDocumentAccessFactory(document=child1) factories.UserDocumentAccessFactory(document=child1)
with django_assert_num_queries(10): response = client.get(f"/api/v1.0/documents/{document.id!s}/children/")
client.get(f"/api/v1.0/documents/{document.id!s}/children/")
with django_assert_num_queries(6):
response = client.get(f"/api/v1.0/documents/{document.id!s}/children/")
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == { assert response.json() == {
@@ -287,13 +241,8 @@ def test_api_documents_children_list_authenticated_public_or_authenticated_paren
"results": [ "results": [
{ {
"abilities": child1.get_abilities(user), "abilities": child1.get_abilities(user),
"ancestors_link_reach": child1.ancestors_link_reach,
"ancestors_link_role": child1.ancestors_link_role,
"computed_link_reach": child1.computed_link_reach,
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"), "created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id), "creator": str(child1.creator.id),
"deleted_at": None,
"depth": 4, "depth": 4,
"excerpt": child1.excerpt, "excerpt": child1.excerpt,
"id": str(child1.id), "id": str(child1.id),
@@ -306,17 +255,12 @@ def test_api_documents_children_list_authenticated_public_or_authenticated_paren
"path": child1.path, "path": child1.path,
"title": child1.title, "title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": None, "user_roles": [],
}, },
{ {
"abilities": child2.get_abilities(user), "abilities": child2.get_abilities(user),
"ancestors_link_reach": child2.ancestors_link_reach,
"ancestors_link_role": child2.ancestors_link_role,
"computed_link_reach": child2.computed_link_reach,
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"), "created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id), "creator": str(child2.creator.id),
"deleted_at": None,
"depth": 4, "depth": 4,
"excerpt": child2.excerpt, "excerpt": child2.excerpt,
"id": str(child2.id), "id": str(child2.id),
@@ -329,15 +273,13 @@ def test_api_documents_children_list_authenticated_public_or_authenticated_paren
"path": child2.path, "path": child2.path,
"title": child2.title, "title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": None, "user_roles": [],
}, },
], ],
} }
def test_api_documents_children_list_authenticated_unrelated_restricted( def test_api_documents_children_list_authenticated_unrelated_restricted():
django_assert_num_queries,
):
""" """
Authenticated users should not be allowed to retrieve the children of a document that is Authenticated users should not be allowed to retrieve the children of a document that is
restricted and to which they are not related. restricted and to which they are not related.
@@ -351,20 +293,16 @@ def test_api_documents_children_list_authenticated_unrelated_restricted(
child1, _child2 = factories.DocumentFactory.create_batch(2, parent=document) child1, _child2 = factories.DocumentFactory.create_batch(2, parent=document)
factories.UserDocumentAccessFactory(document=child1) factories.UserDocumentAccessFactory(document=child1)
with django_assert_num_queries(2): response = client.get(
response = client.get( f"/api/v1.0/documents/{document.id!s}/children/",
f"/api/v1.0/documents/{document.id!s}/children/", )
)
assert response.status_code == 403 assert response.status_code == 403
assert response.json() == { assert response.json() == {
"detail": "You do not have permission to perform this action." "detail": "You do not have permission to perform this action."
} }
def test_api_documents_children_list_authenticated_related_direct( def test_api_documents_children_list_authenticated_related_direct():
django_assert_num_queries,
):
""" """
Authenticated users should be allowed to retrieve the children of a document Authenticated users should be allowed to retrieve the children of a document
to which they are directly related whatever the role. to which they are directly related whatever the role.
@@ -381,13 +319,10 @@ def test_api_documents_children_list_authenticated_related_direct(
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document) child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
factories.UserDocumentAccessFactory(document=child1) factories.UserDocumentAccessFactory(document=child1)
with django_assert_num_queries(9): response = client.get(
response = client.get( f"/api/v1.0/documents/{document.id!s}/children/",
f"/api/v1.0/documents/{document.id!s}/children/", )
)
assert response.status_code == 200 assert response.status_code == 200
link_role = None if document.link_reach == "restricted" else document.link_role
assert response.json() == { assert response.json() == {
"count": 2, "count": 2,
"next": None, "next": None,
@@ -395,13 +330,8 @@ def test_api_documents_children_list_authenticated_related_direct(
"results": [ "results": [
{ {
"abilities": child1.get_abilities(user), "abilities": child1.get_abilities(user),
"ancestors_link_reach": document.link_reach,
"ancestors_link_role": link_role,
"computed_link_reach": child1.computed_link_reach,
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"), "created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id), "creator": str(child1.creator.id),
"deleted_at": None,
"depth": 2, "depth": 2,
"excerpt": child1.excerpt, "excerpt": child1.excerpt,
"id": str(child1.id), "id": str(child1.id),
@@ -414,17 +344,12 @@ def test_api_documents_children_list_authenticated_related_direct(
"path": child1.path, "path": child1.path,
"title": child1.title, "title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": access.role, "user_roles": [access.role],
}, },
{ {
"abilities": child2.get_abilities(user), "abilities": child2.get_abilities(user),
"ancestors_link_reach": document.link_reach,
"ancestors_link_role": link_role,
"computed_link_reach": child2.computed_link_reach,
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"), "created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id), "creator": str(child2.creator.id),
"deleted_at": None,
"depth": 2, "depth": 2,
"excerpt": child2.excerpt, "excerpt": child2.excerpt,
"id": str(child2.id), "id": str(child2.id),
@@ -437,15 +362,13 @@ def test_api_documents_children_list_authenticated_related_direct(
"path": child2.path, "path": child2.path,
"title": child2.title, "title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": access.role, "user_roles": [access.role],
}, },
], ],
} }
def test_api_documents_children_list_authenticated_related_parent( def test_api_documents_children_list_authenticated_related_parent():
django_assert_num_queries,
):
""" """
Authenticated users should be allowed to retrieve the children of a document if they Authenticated users should be allowed to retrieve the children of a document if they
are related to one of its ancestors whatever the role. are related to one of its ancestors whatever the role.
@@ -466,11 +389,9 @@ def test_api_documents_children_list_authenticated_related_parent(
document=grand_parent, user=user document=grand_parent, user=user
) )
with django_assert_num_queries(10): response = client.get(
response = client.get( f"/api/v1.0/documents/{document.id!s}/children/",
f"/api/v1.0/documents/{document.id!s}/children/", )
)
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == { assert response.json() == {
"count": 2, "count": 2,
@@ -479,13 +400,8 @@ def test_api_documents_children_list_authenticated_related_parent(
"results": [ "results": [
{ {
"abilities": child1.get_abilities(user), "abilities": child1.get_abilities(user),
"ancestors_link_reach": "restricted",
"ancestors_link_role": None,
"computed_link_reach": child1.computed_link_reach,
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"), "created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id), "creator": str(child1.creator.id),
"deleted_at": None,
"depth": 4, "depth": 4,
"excerpt": child1.excerpt, "excerpt": child1.excerpt,
"id": str(child1.id), "id": str(child1.id),
@@ -498,17 +414,12 @@ def test_api_documents_children_list_authenticated_related_parent(
"path": child1.path, "path": child1.path,
"title": child1.title, "title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": grand_parent_access.role, "user_roles": [grand_parent_access.role],
}, },
{ {
"abilities": child2.get_abilities(user), "abilities": child2.get_abilities(user),
"ancestors_link_reach": "restricted",
"ancestors_link_role": None,
"computed_link_reach": child2.computed_link_reach,
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"), "created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id), "creator": str(child2.creator.id),
"deleted_at": None,
"depth": 4, "depth": 4,
"excerpt": child2.excerpt, "excerpt": child2.excerpt,
"id": str(child2.id), "id": str(child2.id),
@@ -521,15 +432,13 @@ def test_api_documents_children_list_authenticated_related_parent(
"path": child2.path, "path": child2.path,
"title": child2.title, "title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": grand_parent_access.role, "user_roles": [grand_parent_access.role],
}, },
], ],
} }
def test_api_documents_children_list_authenticated_related_child( def test_api_documents_children_list_authenticated_related_child():
django_assert_num_queries,
):
""" """
Authenticated users should not be allowed to retrieve all the children of a document 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. as a result of being related to one of its children.
@@ -545,20 +454,16 @@ def test_api_documents_children_list_authenticated_related_child(
factories.UserDocumentAccessFactory(document=child1, user=user) factories.UserDocumentAccessFactory(document=child1, user=user)
factories.UserDocumentAccessFactory(document=document) factories.UserDocumentAccessFactory(document=document)
with django_assert_num_queries(2): response = client.get(
response = client.get( f"/api/v1.0/documents/{document.id!s}/children/",
f"/api/v1.0/documents/{document.id!s}/children/", )
)
assert response.status_code == 403 assert response.status_code == 403
assert response.json() == { assert response.json() == {
"detail": "You do not have permission to perform this action." "detail": "You do not have permission to perform this action."
} }
def test_api_documents_children_list_authenticated_related_team_none( def test_api_documents_children_list_authenticated_related_team_none(mock_user_teams):
mock_user_teams, django_assert_num_queries
):
""" """
Authenticated users should not be able to retrieve the children of a restricted document Authenticated users should not be able to retrieve the children of a restricted document
related to teams in which the user is not. related to teams in which the user is not.
@@ -575,9 +480,7 @@ def test_api_documents_children_list_authenticated_related_team_none(
factories.TeamDocumentAccessFactory(document=document, team="myteam") factories.TeamDocumentAccessFactory(document=document, team="myteam")
with django_assert_num_queries(2): response = client.get(f"/api/v1.0/documents/{document.id!s}/children/")
response = client.get(f"/api/v1.0/documents/{document.id!s}/children/")
assert response.status_code == 403 assert response.status_code == 403
assert response.json() == { assert response.json() == {
"detail": "You do not have permission to perform this action." "detail": "You do not have permission to perform this action."
@@ -585,7 +488,7 @@ def test_api_documents_children_list_authenticated_related_team_none(
def test_api_documents_children_list_authenticated_related_team_members( def test_api_documents_children_list_authenticated_related_team_members(
mock_user_teams, django_assert_num_queries mock_user_teams,
): ):
""" """
Authenticated users should be allowed to retrieve the children of a document to which they Authenticated users should be allowed to retrieve the children of a document to which they
@@ -603,8 +506,7 @@ def test_api_documents_children_list_authenticated_related_team_members(
access = factories.TeamDocumentAccessFactory(document=document, team="myteam") access = factories.TeamDocumentAccessFactory(document=document, team="myteam")
with django_assert_num_queries(9): response = client.get(f"/api/v1.0/documents/{document.id!s}/children/")
response = client.get(f"/api/v1.0/documents/{document.id!s}/children/")
# pylint: disable=R0801 # pylint: disable=R0801
assert response.status_code == 200 assert response.status_code == 200
@@ -615,13 +517,8 @@ def test_api_documents_children_list_authenticated_related_team_members(
"results": [ "results": [
{ {
"abilities": child1.get_abilities(user), "abilities": child1.get_abilities(user),
"ancestors_link_reach": "restricted",
"ancestors_link_role": None,
"computed_link_reach": child1.computed_link_reach,
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"), "created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id), "creator": str(child1.creator.id),
"deleted_at": None,
"depth": 2, "depth": 2,
"excerpt": child1.excerpt, "excerpt": child1.excerpt,
"id": str(child1.id), "id": str(child1.id),
@@ -634,17 +531,12 @@ def test_api_documents_children_list_authenticated_related_team_members(
"path": child1.path, "path": child1.path,
"title": child1.title, "title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": access.role, "user_roles": [access.role],
}, },
{ {
"abilities": child2.get_abilities(user), "abilities": child2.get_abilities(user),
"ancestors_link_reach": "restricted",
"ancestors_link_role": None,
"computed_link_reach": child2.computed_link_reach,
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"), "created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id), "creator": str(child2.creator.id),
"deleted_at": None,
"depth": 2, "depth": 2,
"excerpt": child2.excerpt, "excerpt": child2.excerpt,
"id": str(child2.id), "id": str(child2.id),
@@ -657,7 +549,7 @@ def test_api_documents_children_list_authenticated_related_team_members(
"path": child2.path, "path": child2.path,
"title": child2.title, "title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": access.role, "user_roles": [access.role],
}, },
], ],
} }
@@ -1,878 +0,0 @@
"""Test API for comments on documents."""
import random
from django.contrib.auth.models import AnonymousUser
import pytest
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
# List comments
def test_list_comments_anonymous_user_public_document():
"""Anonymous users should be allowed to list comments on a public document."""
document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.COMMENTER
)
thread = factories.ThreadFactory(document=document)
comment1, comment2 = factories.CommentFactory.create_batch(2, thread=thread)
# other comments not linked to the document
factories.CommentFactory.create_batch(2)
response = APIClient().get(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/"
)
assert response.status_code == 200
assert response.json() == {
"count": 2,
"next": None,
"previous": None,
"results": [
{
"id": str(comment1.id),
"body": comment1.body,
"created_at": comment1.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": comment1.updated_at.isoformat().replace("+00:00", "Z"),
"user": {
"full_name": comment1.user.full_name,
"short_name": comment1.user.short_name,
},
"abilities": comment1.get_abilities(AnonymousUser()),
"reactions": [],
},
{
"id": str(comment2.id),
"body": comment2.body,
"created_at": comment2.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": comment2.updated_at.isoformat().replace("+00:00", "Z"),
"user": {
"full_name": comment2.user.full_name,
"short_name": comment2.user.short_name,
},
"abilities": comment2.get_abilities(AnonymousUser()),
"reactions": [],
},
],
}
@pytest.mark.parametrize("link_reach", ["restricted", "authenticated"])
def test_list_comments_anonymous_user_non_public_document(link_reach):
"""Anonymous users should not be allowed to list comments on a non-public document."""
document = factories.DocumentFactory(
link_reach=link_reach, link_role=models.LinkRoleChoices.COMMENTER
)
thread = factories.ThreadFactory(document=document)
factories.CommentFactory(thread=thread)
# other comments not linked to the document
factories.CommentFactory.create_batch(2)
response = APIClient().get(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/"
)
assert response.status_code == 401
def test_list_comments_authenticated_user_accessible_document():
"""Authenticated users should be allowed to list comments on an accessible document."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.COMMENTER)]
)
thread = factories.ThreadFactory(document=document)
comment1 = factories.CommentFactory(thread=thread)
comment2 = factories.CommentFactory(thread=thread, user=user)
# other comments not linked to the document
factories.CommentFactory.create_batch(2)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/"
)
assert response.status_code == 200
assert response.json() == {
"count": 2,
"next": None,
"previous": None,
"results": [
{
"id": str(comment1.id),
"body": comment1.body,
"created_at": comment1.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": comment1.updated_at.isoformat().replace("+00:00", "Z"),
"user": {
"full_name": comment1.user.full_name,
"short_name": comment1.user.short_name,
},
"abilities": comment1.get_abilities(user),
"reactions": [],
},
{
"id": str(comment2.id),
"body": comment2.body,
"created_at": comment2.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": comment2.updated_at.isoformat().replace("+00:00", "Z"),
"user": {
"full_name": comment2.user.full_name,
"short_name": comment2.user.short_name,
},
"abilities": comment2.get_abilities(user),
"reactions": [],
},
],
}
def test_list_comments_authenticated_user_non_accessible_document():
"""Authenticated users should not be allowed to list comments on a non-accessible document."""
user = factories.UserFactory()
document = factories.DocumentFactory(link_reach="restricted")
thread = factories.ThreadFactory(document=document)
factories.CommentFactory(thread=thread)
# other comments not linked to the document
factories.CommentFactory.create_batch(2)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/"
)
assert response.status_code == 403
def test_list_comments_authenticated_user_not_enough_access():
"""
Authenticated users should not be allowed to list comments on a document they don't have
comment access to.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.READER)]
)
thread = factories.ThreadFactory(document=document)
factories.CommentFactory(thread=thread)
# other comments not linked to the document
factories.CommentFactory.create_batch(2)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/"
)
assert response.status_code == 403
# Create comment
def test_create_comment_anonymous_user_public_document():
"""
Anonymous users should be allowed to create comments on a public document
with commenter link_role.
"""
document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.COMMENTER
)
thread = factories.ThreadFactory(document=document)
client = APIClient()
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/",
{"body": "test"},
)
assert response.status_code == 201
assert response.json() == {
"id": str(response.json()["id"]),
"body": "test",
"created_at": response.json()["created_at"],
"updated_at": response.json()["updated_at"],
"user": None,
"abilities": {
"destroy": False,
"update": False,
"partial_update": False,
"reactions": False,
"retrieve": True,
},
"reactions": [],
}
def test_create_comment_anonymous_user_non_accessible_document():
"""Anonymous users should not be allowed to create comments on a non-accessible document."""
document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.READER
)
thread = factories.ThreadFactory(document=document)
client = APIClient()
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/",
{"body": "test"},
)
assert response.status_code == 401
def test_create_comment_authenticated_user_accessible_document():
"""Authenticated users should be allowed to create comments on an accessible document."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.COMMENTER)]
)
thread = factories.ThreadFactory(document=document)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/",
{"body": "test"},
)
assert response.status_code == 201
assert response.json() == {
"id": str(response.json()["id"]),
"body": "test",
"created_at": response.json()["created_at"],
"updated_at": response.json()["updated_at"],
"user": {
"full_name": user.full_name,
"short_name": user.short_name,
},
"abilities": {
"destroy": True,
"update": True,
"partial_update": True,
"reactions": True,
"retrieve": True,
},
"reactions": [],
}
def test_create_comment_authenticated_user_not_enough_access():
"""
Authenticated users should not be allowed to create comments on a document they don't have
comment access to.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.READER)]
)
thread = factories.ThreadFactory(document=document)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/",
{"body": "test"},
)
assert response.status_code == 403
# Retrieve comment
def test_retrieve_comment_anonymous_user_public_document():
"""Anonymous users should be allowed to retrieve comments on a public document."""
document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.COMMENTER
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
response = client.get(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/"
)
assert response.status_code == 200
assert response.json() == {
"id": str(comment.id),
"body": comment.body,
"created_at": comment.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": comment.updated_at.isoformat().replace("+00:00", "Z"),
"user": {
"full_name": comment.user.full_name,
"short_name": comment.user.short_name,
},
"reactions": [],
"abilities": comment.get_abilities(AnonymousUser()),
}
def test_retrieve_comment_anonymous_user_non_accessible_document():
"""Anonymous users should not be allowed to retrieve comments on a non-accessible document."""
document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.READER
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
response = client.get(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/"
)
assert response.status_code == 401
def test_retrieve_comment_authenticated_user_accessible_document():
"""Authenticated users should be allowed to retrieve comments on an accessible document."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.COMMENTER)]
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/"
)
assert response.status_code == 200
def test_retrieve_comment_authenticated_user_not_enough_access():
"""
Authenticated users should not be allowed to retrieve comments on a document they don't have
comment access to.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.READER)]
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/"
)
assert response.status_code == 403
# Update comment
def test_update_comment_anonymous_user_public_document():
"""Anonymous users should not be allowed to update comments on a public document."""
document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.COMMENTER
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread, body="test")
client = APIClient()
response = client.put(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/",
{"body": "other content"},
)
assert response.status_code == 401
def test_update_comment_anonymous_user_non_accessible_document():
"""Anonymous users should not be allowed to update comments on a non-accessible document."""
document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.READER
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread, body="test")
client = APIClient()
response = client.put(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/",
{"body": "other content"},
)
assert response.status_code == 401
def test_update_comment_authenticated_user_accessible_document():
"""Authenticated users should not be able to update comments not their own."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted",
users=[
(
user,
random.choice(
[models.LinkRoleChoices.COMMENTER, models.LinkRoleChoices.EDITOR]
),
)
],
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread, body="test")
client = APIClient()
client.force_login(user)
response = client.put(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/",
{"body": "other content"},
)
assert response.status_code == 403
def test_update_comment_authenticated_user_own_comment():
"""Authenticated users should be able to update comments not their own."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted",
users=[
(
user,
random.choice(
[models.LinkRoleChoices.COMMENTER, models.LinkRoleChoices.EDITOR]
),
)
],
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread, body="test", user=user)
client = APIClient()
client.force_login(user)
response = client.put(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/",
{"body": "other content"},
)
assert response.status_code == 200
comment.refresh_from_db()
assert comment.body == "other content"
def test_update_comment_authenticated_user_not_enough_access():
"""
Authenticated users should not be allowed to update comments on a document they don't
have comment access to.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.READER)]
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread, body="test")
client = APIClient()
client.force_login(user)
response = client.put(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/",
{"body": "other content"},
)
assert response.status_code == 403
def test_update_comment_authenticated_no_access():
"""
Authenticated users should not be allowed to update comments on a document they don't
have access to.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(link_reach="restricted")
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread, body="test")
client = APIClient()
client.force_login(user)
response = client.put(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/",
{"body": "other content"},
)
assert response.status_code == 403
@pytest.mark.parametrize("role", [models.RoleChoices.ADMIN, models.RoleChoices.OWNER])
def test_update_comment_authenticated_admin_or_owner_can_update_any_comment(role):
"""
Authenticated users should be able to update comments on a document they don't have access to.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(users=[(user, role)])
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread, body="test")
client = APIClient()
client.force_login(user)
response = client.put(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/",
{"body": "other content"},
)
assert response.status_code == 200
comment.refresh_from_db()
assert comment.body == "other content"
@pytest.mark.parametrize("role", [models.RoleChoices.ADMIN, models.RoleChoices.OWNER])
def test_update_comment_authenticated_admin_or_owner_can_update_own_comment(role):
"""
Authenticated users should be able to update comments on a document they don't have access to.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(users=[(user, role)])
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread, body="test", user=user)
client = APIClient()
client.force_login(user)
response = client.put(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/",
{"body": "other content"},
)
assert response.status_code == 200
comment.refresh_from_db()
assert comment.body == "other content"
# Delete comment
def test_delete_comment_anonymous_user_public_document():
"""Anonymous users should not be allowed to delete comments on a public document."""
document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.COMMENTER
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/"
)
assert response.status_code == 401
def test_delete_comment_anonymous_user_non_accessible_document():
"""Anonymous users should not be allowed to delete comments on a non-accessible document."""
document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.READER
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/"
)
assert response.status_code == 401
def test_delete_comment_authenticated_user_accessible_document_own_comment():
"""Authenticated users should be able to delete comments on an accessible document."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.COMMENTER)]
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread, user=user)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/"
)
assert response.status_code == 204
def test_delete_comment_authenticated_user_accessible_document_not_own_comment():
"""Authenticated users should not be able to delete comments on an accessible document."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.COMMENTER)]
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/"
)
assert response.status_code == 403
@pytest.mark.parametrize("role", [models.RoleChoices.ADMIN, models.RoleChoices.OWNER])
def test_delete_comment_authenticated_user_admin_or_owner_can_delete_any_comment(role):
"""Authenticated users should be able to delete comments on a document they have access to."""
user = factories.UserFactory()
document = factories.DocumentFactory(users=[(user, role)])
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/"
)
assert response.status_code == 204
@pytest.mark.parametrize("role", [models.RoleChoices.ADMIN, models.RoleChoices.OWNER])
def test_delete_comment_authenticated_user_admin_or_owner_can_delete_own_comment(role):
"""Authenticated users should be able to delete comments on a document they have access to."""
user = factories.UserFactory()
document = factories.DocumentFactory(users=[(user, role)])
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread, user=user)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/"
)
assert response.status_code == 204
def test_delete_comment_authenticated_user_not_enough_access():
"""
Authenticated users should not be able to delete comments on a document they don't
have access to.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.READER)]
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/"
)
assert response.status_code == 403
# Create reaction
@pytest.mark.parametrize("link_role", models.LinkRoleChoices.values)
def test_create_reaction_anonymous_user_public_document(link_role):
"""No matter the link_role, an anonymous user can not react to a comment."""
document = factories.DocumentFactory(link_reach="public", link_role=link_role)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
f"comments/{comment.id!s}/reactions/",
{"emoji": "test"},
)
assert response.status_code == 401
def test_create_reaction_authenticated_user_public_document():
"""
Authenticated users should not be able to reaction to a comment on a public document with
link_role reader.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.READER
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
f"comments/{comment.id!s}/reactions/",
{"emoji": "test"},
)
assert response.status_code == 403
def test_create_reaction_authenticated_user_accessible_public_document():
"""
Authenticated users should be able to react to a comment on a public document.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.COMMENTER
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
f"comments/{comment.id!s}/reactions/",
{"emoji": "test"},
)
assert response.status_code == 201
assert models.Reaction.objects.filter(
comment=comment, emoji="test", users__in=[user]
).exists()
def test_create_reaction_authenticated_user_connected_document_link_role_reader():
"""
Authenticated users should not be able to react to a comment on a connected document
with link_role reader.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="authenticated", link_role=models.LinkRoleChoices.READER
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
f"comments/{comment.id!s}/reactions/",
{"emoji": "test"},
)
assert response.status_code == 403
@pytest.mark.parametrize(
"link_role",
[
role
for role in models.LinkRoleChoices.values
if role != models.LinkRoleChoices.READER
],
)
def test_create_reaction_authenticated_user_connected_document(link_role):
"""
Authenticated users should be able to react to a comment on a connected document.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="authenticated", link_role=link_role
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
f"comments/{comment.id!s}/reactions/",
{"emoji": "test"},
)
assert response.status_code == 201
assert models.Reaction.objects.filter(
comment=comment, emoji="test", users__in=[user]
).exists()
def test_create_reaction_authenticated_user_restricted_accessible_document():
"""
Authenticated users should not be able to react to a comment on a restricted accessible document
they don't have access to.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(link_reach="restricted")
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
f"comments/{comment.id!s}/reactions/",
{"emoji": "test"},
)
assert response.status_code == 403
def test_create_reaction_authenticated_user_restricted_accessible_document_role_reader():
"""
Authenticated users should not be able to react to a comment on a restricted accessible
document with role reader.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", link_role=models.LinkRoleChoices.READER
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
f"comments/{comment.id!s}/reactions/",
{"emoji": "test"},
)
assert response.status_code == 403
@pytest.mark.parametrize(
"role",
[role for role in models.RoleChoices.values if role != models.RoleChoices.READER],
)
def test_create_reaction_authenticated_user_restricted_accessible_document_role_commenter(
role,
):
"""
Authenticated users should be able to react to a comment on a restricted accessible document
with role commenter.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(link_reach="restricted", users=[(user, role)])
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
f"comments/{comment.id!s}/reactions/",
{"emoji": "test"},
)
assert response.status_code == 201
assert models.Reaction.objects.filter(
comment=comment, emoji="test", users__in=[user]
).exists()
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
f"comments/{comment.id!s}/reactions/",
{"emoji": "test"},
)
assert response.status_code == 400
assert response.json() == {"user_already_reacted": True}
# Delete reaction
def test_delete_reaction_not_owned_by_the_current_user():
"""
Users should not be able to delete reactions not owned by the current user.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.RoleChoices.ADMIN)]
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
reaction = factories.ReactionFactory(comment=comment)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
f"comments/{comment.id!s}/reactions/",
{"emoji": reaction.emoji},
)
assert response.status_code == 404
def test_delete_reaction_owned_by_the_current_user():
"""
Users should not be able to delete reactions not owned by the current user.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.RoleChoices.ADMIN)]
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
reaction = factories.ReactionFactory(comment=comment)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
f"comments/{comment.id!s}/reactions/",
{"emoji": reaction.emoji},
)
assert response.status_code == 404
reaction.refresh_from_db()
assert reaction.users.exists()
@@ -1,176 +0,0 @@
"""
Tests for Documents API endpoint in impress's core app: content
"""
import base64
from unittest.mock import patch
import pytest
import requests
from rest_framework import status
from rest_framework.test import APIClient
from core import factories
pytestmark = pytest.mark.django_db
@pytest.mark.parametrize(
"reach, role",
[
("public", "reader"),
("public", "editor"),
],
)
@patch("core.services.converter_services.YdocConverter.convert")
def test_api_documents_content_public(mock_content, reach, role):
"""Anonymous users should be allowed to access content of public documents."""
document = factories.DocumentFactory(link_reach=reach, link_role=role)
mock_content.return_value = {"some": "data"}
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/content/")
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["id"] == str(document.id)
assert data["title"] == document.title
assert data["content"] == {"some": "data"}
mock_content.assert_called_once_with(
base64.b64decode(document.content),
"application/vnd.yjs.doc",
"application/json",
)
@pytest.mark.parametrize(
"reach, doc_role, user_role",
[
("restricted", "reader", "reader"),
("restricted", "reader", "editor"),
("restricted", "reader", "administrator"),
("restricted", "reader", "owner"),
("restricted", "editor", "reader"),
("restricted", "editor", "editor"),
("restricted", "editor", "administrator"),
("restricted", "editor", "owner"),
("authenticated", "reader", None),
("authenticated", "editor", None),
],
)
@patch("core.services.converter_services.YdocConverter.convert")
def test_api_documents_content_not_public(mock_content, reach, doc_role, user_role):
"""Authenticated users need access to get non-public document content."""
user = factories.UserFactory()
document = factories.DocumentFactory(link_reach=reach, link_role=doc_role)
mock_content.return_value = {"some": "data"}
# First anonymous request should fail
client = APIClient()
response = client.get(f"/api/v1.0/documents/{document.id!s}/content/")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
mock_content.assert_not_called()
# Login and try again
client.force_login(user)
response = client.get(f"/api/v1.0/documents/{document.id!s}/content/")
# If restricted, we still should not have access
if user_role is not None:
assert response.status_code == status.HTTP_403_FORBIDDEN
mock_content.assert_not_called()
# Create an access as a reader. This should unlock the access.
factories.UserDocumentAccessFactory(
document=document, user=user, role=user_role
)
response = client.get(f"/api/v1.0/documents/{document.id!s}/content/")
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["id"] == str(document.id)
assert data["title"] == document.title
assert data["content"] == {"some": "data"}
mock_content.assert_called_once_with(
base64.b64decode(document.content),
"application/vnd.yjs.doc",
"application/json",
)
@pytest.mark.parametrize(
"content_format, accept",
[
("markdown", "text/markdown"),
("html", "text/html"),
("json", "application/json"),
],
)
@patch("core.services.converter_services.YdocConverter.convert")
def test_api_documents_content_format(mock_content, content_format, accept):
"""Test that the content endpoint returns a specific format."""
document = factories.DocumentFactory(link_reach="public")
mock_content.return_value = {"some": "data"}
response = APIClient().get(
f"/api/v1.0/documents/{document.id!s}/content/?content_format={content_format}"
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["id"] == str(document.id)
assert data["title"] == document.title
assert data["content"] == {"some": "data"}
mock_content.assert_called_once_with(
base64.b64decode(document.content), "application/vnd.yjs.doc", accept
)
@patch("core.services.converter_services.YdocConverter._request")
def test_api_documents_content_invalid_format(mock_request):
"""Test that the content endpoint rejects invalid formats."""
document = factories.DocumentFactory(link_reach="public")
response = APIClient().get(
f"/api/v1.0/documents/{document.id!s}/content/?content_format=invalid"
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
mock_request.assert_not_called()
@patch("core.services.converter_services.YdocConverter._request")
def test_api_documents_content_yservice_error(mock_request):
"""Test that service errors are handled properly."""
document = factories.DocumentFactory(link_reach="public")
mock_request.side_effect = requests.RequestException()
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/content/")
mock_request.assert_called_once()
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
@patch("core.services.converter_services.YdocConverter._request")
def test_api_documents_content_nonexistent_document(mock_request):
"""Test that accessing a nonexistent document returns 404."""
client = APIClient()
response = client.get(
"/api/v1.0/documents/00000000-0000-0000-0000-000000000000/content/"
)
assert response.status_code == status.HTTP_404_NOT_FOUND
mock_request.assert_not_called()
@patch("core.services.converter_services.YdocConverter._request")
def test_api_documents_content_empty_document(mock_request):
"""Test that accessing an empty document returns empty content."""
document = factories.DocumentFactory(link_reach="public", content="")
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/content/")
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["id"] == str(document.id)
assert data["title"] == document.title
assert data["content"] is None
mock_request.assert_not_called()
@@ -2,7 +2,6 @@
import pytest import pytest
import responses import responses
from requests.exceptions import RequestException
from rest_framework.test import APIClient from rest_framework.test import APIClient
from core import factories from core import factories
@@ -24,25 +23,10 @@ def test_api_docs_cors_proxy_valid_url():
assert response.status_code == 200 assert response.status_code == 200
assert response.headers["Content-Type"] == "image/png" assert response.headers["Content-Type"] == "image/png"
assert response.headers["Content-Disposition"] == "attachment;" assert response.headers["Content-Disposition"] == "attachment;"
policy_list = sorted(response.headers["Content-Security-Policy"].split("; ")) assert (
assert policy_list == [ response.headers["Content-Security-Policy"]
"base-uri 'none'", == "default-src 'none'; img-src 'none' data:;"
"child-src 'none'", )
"connect-src 'none'",
"default-src 'none'",
"font-src 'none'",
"form-action 'none'",
"frame-ancestors 'none'",
"frame-src 'none'",
"img-src 'none' data:",
"manifest-src 'none'",
"media-src 'none'",
"object-src 'none'",
"prefetch-src 'none'",
"script-src 'none'",
"style-src 'none'",
"worker-src 'none'",
]
assert response.streaming_content assert response.streaming_content
@@ -93,25 +77,10 @@ def test_api_docs_cors_proxy_authenticated_user_accessing_protected_doc():
assert response.status_code == 200 assert response.status_code == 200
assert response.headers["Content-Type"] == "image/png" assert response.headers["Content-Type"] == "image/png"
assert response.headers["Content-Disposition"] == "attachment;" assert response.headers["Content-Disposition"] == "attachment;"
policy_list = sorted(response.headers["Content-Security-Policy"].split("; ")) assert (
assert policy_list == [ response.headers["Content-Security-Policy"]
"base-uri 'none'", == "default-src 'none'; img-src 'none' data:;"
"child-src 'none'", )
"connect-src 'none'",
"default-src 'none'",
"font-src 'none'",
"form-action 'none'",
"frame-ancestors 'none'",
"frame-src 'none'",
"img-src 'none' data:",
"manifest-src 'none'",
"media-src 'none'",
"object-src 'none'",
"prefetch-src 'none'",
"script-src 'none'",
"style-src 'none'",
"worker-src 'none'",
]
assert response.streaming_content assert response.streaming_content
@@ -150,41 +119,3 @@ def test_api_docs_cors_proxy_unsupported_media_type():
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}" f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
) )
assert response.status_code == 415 assert response.status_code == 415
@pytest.mark.parametrize(
"url_to_fetch",
[
"ftp://external-url.com/assets/index.html",
"ftps://external-url.com/assets/index.html",
"invalid-url.com",
"ssh://external-url.com/assets/index.html",
],
)
def test_api_docs_cors_proxy_invalid_url(url_to_fetch):
"""Test the CORS proxy API for documents with an invalid URL."""
document = factories.DocumentFactory(link_reach="public")
client = APIClient()
response = client.get(
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
)
assert response.status_code == 400
assert response.json() == ["Enter a valid URL."]
@responses.activate
def test_api_docs_cors_proxy_request_failed():
"""Test the CORS proxy API for documents with a request failed."""
document = factories.DocumentFactory(link_reach="public")
client = APIClient()
url_to_fetch = "https://external-url.com/assets/index.html"
responses.get(url_to_fetch, body=RequestException("Connection refused"))
response = client.get(
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
)
assert response.status_code == 400
assert response.json() == {
"error": "Failed to fetch resource from https://external-url.com/assets/index.html"
}
@@ -23,10 +23,10 @@ pytestmark = pytest.mark.django_db
@pytest.fixture @pytest.fixture
def mock_convert_md(): def mock_convert_md():
"""Mock YdocConverter.convert to return a converted content.""" """Mock YdocConverter.convert_markdown to return a converted content."""
with patch.object( with patch.object(
YdocConverter, YdocConverter,
"convert", "convert_markdown",
return_value="Converted document content", return_value="Converted document content",
) as mock: ) as mock:
yield mock yield mock
@@ -148,7 +148,7 @@ def test_api_documents_create_for_owner_invalid_sub():
data = { data = {
"title": "My Document", "title": "My Document",
"content": "Document content", "content": "Document content",
"sub": "invalid süb", "sub": "123!!",
"email": "john.doe@example.com", "email": "john.doe@example.com",
} }
@@ -163,7 +163,10 @@ def test_api_documents_create_for_owner_invalid_sub():
assert not Document.objects.exists() assert not Document.objects.exists()
assert response.json() == { assert response.json() == {
"sub": ["Enter a valid sub. This value should be ASCII only."] "sub": [
"Enter a valid sub. This value may contain only letters, "
"numbers, and @/./+/-/_/: characters."
]
} }
@@ -32,13 +32,8 @@ def test_api_documents_descendants_list_anonymous_public_standalone():
"results": [ "results": [
{ {
"abilities": child1.get_abilities(AnonymousUser()), "abilities": child1.get_abilities(AnonymousUser()),
"ancestors_link_reach": "public",
"ancestors_link_role": document.link_role,
"computed_link_reach": child1.computed_link_reach,
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"), "created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id), "creator": str(child1.creator.id),
"deleted_at": None,
"depth": 2, "depth": 2,
"excerpt": child1.excerpt, "excerpt": child1.excerpt,
"id": str(child1.id), "id": str(child1.id),
@@ -51,19 +46,12 @@ def test_api_documents_descendants_list_anonymous_public_standalone():
"path": child1.path, "path": child1.path,
"title": child1.title, "title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": None, "user_roles": [],
}, },
{ {
"abilities": grand_child.get_abilities(AnonymousUser()), "abilities": grand_child.get_abilities(AnonymousUser()),
"ancestors_link_reach": "public",
"ancestors_link_role": "editor"
if (child1.link_reach == "public" and child1.link_role == "editor")
else document.link_role,
"computed_link_reach": "public",
"computed_link_role": grand_child.computed_link_role,
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"), "created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(grand_child.creator.id), "creator": str(grand_child.creator.id),
"deleted_at": None,
"depth": 3, "depth": 3,
"excerpt": grand_child.excerpt, "excerpt": grand_child.excerpt,
"id": str(grand_child.id), "id": str(grand_child.id),
@@ -76,17 +64,12 @@ def test_api_documents_descendants_list_anonymous_public_standalone():
"path": grand_child.path, "path": grand_child.path,
"title": grand_child.title, "title": grand_child.title,
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": None, "user_roles": [],
}, },
{ {
"abilities": child2.get_abilities(AnonymousUser()), "abilities": child2.get_abilities(AnonymousUser()),
"ancestors_link_reach": "public",
"ancestors_link_role": document.link_role,
"computed_link_reach": "public",
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"), "created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id), "creator": str(child2.creator.id),
"deleted_at": None,
"depth": 2, "depth": 2,
"excerpt": child2.excerpt, "excerpt": child2.excerpt,
"id": str(child2.id), "id": str(child2.id),
@@ -99,7 +82,7 @@ def test_api_documents_descendants_list_anonymous_public_standalone():
"path": child2.path, "path": child2.path,
"title": child2.title, "title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": None, "user_roles": [],
}, },
], ],
} }
@@ -132,13 +115,8 @@ def test_api_documents_descendants_list_anonymous_public_parent():
"results": [ "results": [
{ {
"abilities": child1.get_abilities(AnonymousUser()), "abilities": child1.get_abilities(AnonymousUser()),
"ancestors_link_reach": "public",
"ancestors_link_role": grand_parent.link_role,
"computed_link_reach": child1.computed_link_reach,
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"), "created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id), "creator": str(child1.creator.id),
"deleted_at": None,
"depth": 4, "depth": 4,
"excerpt": child1.excerpt, "excerpt": child1.excerpt,
"id": str(child1.id), "id": str(child1.id),
@@ -151,17 +129,12 @@ def test_api_documents_descendants_list_anonymous_public_parent():
"path": child1.path, "path": child1.path,
"title": child1.title, "title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": None, "user_roles": [],
}, },
{ {
"abilities": grand_child.get_abilities(AnonymousUser()), "abilities": grand_child.get_abilities(AnonymousUser()),
"ancestors_link_reach": "public",
"ancestors_link_role": grand_child.ancestors_link_role,
"computed_link_reach": "public",
"computed_link_role": grand_child.computed_link_role,
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"), "created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(grand_child.creator.id), "creator": str(grand_child.creator.id),
"deleted_at": None,
"depth": 5, "depth": 5,
"excerpt": grand_child.excerpt, "excerpt": grand_child.excerpt,
"id": str(grand_child.id), "id": str(grand_child.id),
@@ -174,17 +147,12 @@ def test_api_documents_descendants_list_anonymous_public_parent():
"path": grand_child.path, "path": grand_child.path,
"title": grand_child.title, "title": grand_child.title,
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": None, "user_roles": [],
}, },
{ {
"abilities": child2.get_abilities(AnonymousUser()), "abilities": child2.get_abilities(AnonymousUser()),
"ancestors_link_reach": "public",
"ancestors_link_role": grand_parent.link_role,
"computed_link_reach": "public",
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"), "created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id), "creator": str(child2.creator.id),
"deleted_at": None,
"depth": 4, "depth": 4,
"excerpt": child2.excerpt, "excerpt": child2.excerpt,
"id": str(child2.id), "id": str(child2.id),
@@ -197,7 +165,7 @@ def test_api_documents_descendants_list_anonymous_public_parent():
"path": child2.path, "path": child2.path,
"title": child2.title, "title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": None, "user_roles": [],
}, },
], ],
} }
@@ -233,9 +201,7 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen
client.force_login(user) client.force_login(user)
document = factories.DocumentFactory(link_reach=reach) document = factories.DocumentFactory(link_reach=reach)
child1, child2 = factories.DocumentFactory.create_batch( child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
2, parent=document, link_reach="restricted"
)
grand_child = factories.DocumentFactory(parent=child1) grand_child = factories.DocumentFactory(parent=child1)
factories.UserDocumentAccessFactory(document=child1) factories.UserDocumentAccessFactory(document=child1)
@@ -251,13 +217,8 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen
"results": [ "results": [
{ {
"abilities": child1.get_abilities(user), "abilities": child1.get_abilities(user),
"ancestors_link_reach": reach,
"ancestors_link_role": document.link_role,
"computed_link_reach": child1.computed_link_reach,
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"), "created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id), "creator": str(child1.creator.id),
"deleted_at": None,
"depth": 2, "depth": 2,
"excerpt": child1.excerpt, "excerpt": child1.excerpt,
"id": str(child1.id), "id": str(child1.id),
@@ -270,17 +231,12 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen
"path": child1.path, "path": child1.path,
"title": child1.title, "title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": None, "user_roles": [],
}, },
{ {
"abilities": grand_child.get_abilities(user), "abilities": grand_child.get_abilities(user),
"ancestors_link_reach": reach,
"ancestors_link_role": document.link_role,
"computed_link_reach": grand_child.computed_link_reach,
"computed_link_role": grand_child.computed_link_role,
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"), "created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(grand_child.creator.id), "creator": str(grand_child.creator.id),
"deleted_at": None,
"depth": 3, "depth": 3,
"excerpt": grand_child.excerpt, "excerpt": grand_child.excerpt,
"id": str(grand_child.id), "id": str(grand_child.id),
@@ -293,17 +249,12 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen
"path": grand_child.path, "path": grand_child.path,
"title": grand_child.title, "title": grand_child.title,
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": None, "user_roles": [],
}, },
{ {
"abilities": child2.get_abilities(user), "abilities": child2.get_abilities(user),
"ancestors_link_reach": reach,
"ancestors_link_role": document.link_role,
"computed_link_reach": child2.computed_link_reach,
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"), "created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id), "creator": str(child2.creator.id),
"deleted_at": None,
"depth": 2, "depth": 2,
"excerpt": child2.excerpt, "excerpt": child2.excerpt,
"id": str(child2.id), "id": str(child2.id),
@@ -316,7 +267,7 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen
"path": child2.path, "path": child2.path,
"title": child2.title, "title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": None, "user_roles": [],
}, },
], ],
} }
@@ -338,9 +289,7 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa
grand_parent = factories.DocumentFactory(link_reach=reach) grand_parent = factories.DocumentFactory(link_reach=reach)
parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted") parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted")
document = factories.DocumentFactory(link_reach="restricted", parent=parent) document = factories.DocumentFactory(link_reach="restricted", parent=parent)
child1, child2 = factories.DocumentFactory.create_batch( child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
2, parent=document, link_reach="restricted"
)
grand_child = factories.DocumentFactory(parent=child1) grand_child = factories.DocumentFactory(parent=child1)
factories.UserDocumentAccessFactory(document=child1) factories.UserDocumentAccessFactory(document=child1)
@@ -355,13 +304,8 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa
"results": [ "results": [
{ {
"abilities": child1.get_abilities(user), "abilities": child1.get_abilities(user),
"ancestors_link_reach": reach,
"ancestors_link_role": grand_parent.link_role,
"computed_link_reach": child1.computed_link_reach,
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"), "created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id), "creator": str(child1.creator.id),
"deleted_at": None,
"depth": 4, "depth": 4,
"excerpt": child1.excerpt, "excerpt": child1.excerpt,
"id": str(child1.id), "id": str(child1.id),
@@ -374,17 +318,12 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa
"path": child1.path, "path": child1.path,
"title": child1.title, "title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": None, "user_roles": [],
}, },
{ {
"abilities": grand_child.get_abilities(user), "abilities": grand_child.get_abilities(user),
"ancestors_link_reach": reach,
"ancestors_link_role": grand_parent.link_role,
"computed_link_reach": grand_child.computed_link_reach,
"computed_link_role": grand_child.computed_link_role,
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"), "created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(grand_child.creator.id), "creator": str(grand_child.creator.id),
"deleted_at": None,
"depth": 5, "depth": 5,
"excerpt": grand_child.excerpt, "excerpt": grand_child.excerpt,
"id": str(grand_child.id), "id": str(grand_child.id),
@@ -397,17 +336,12 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa
"path": grand_child.path, "path": grand_child.path,
"title": grand_child.title, "title": grand_child.title,
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": None, "user_roles": [],
}, },
{ {
"abilities": child2.get_abilities(user), "abilities": child2.get_abilities(user),
"ancestors_link_reach": reach,
"ancestors_link_role": grand_parent.link_role,
"computed_link_reach": child2.computed_link_reach,
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"), "created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id), "creator": str(child2.creator.id),
"deleted_at": None,
"depth": 4, "depth": 4,
"excerpt": child2.excerpt, "excerpt": child2.excerpt,
"id": str(child2.id), "id": str(child2.id),
@@ -420,7 +354,7 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa
"path": child2.path, "path": child2.path,
"title": child2.title, "title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": None, "user_roles": [],
}, },
], ],
} }
@@ -480,13 +414,8 @@ def test_api_documents_descendants_list_authenticated_related_direct():
"results": [ "results": [
{ {
"abilities": child1.get_abilities(user), "abilities": child1.get_abilities(user),
"ancestors_link_reach": child1.ancestors_link_reach,
"ancestors_link_role": child1.ancestors_link_role,
"computed_link_reach": child1.computed_link_reach,
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"), "created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id), "creator": str(child1.creator.id),
"deleted_at": None,
"depth": 2, "depth": 2,
"excerpt": child1.excerpt, "excerpt": child1.excerpt,
"id": str(child1.id), "id": str(child1.id),
@@ -499,17 +428,12 @@ def test_api_documents_descendants_list_authenticated_related_direct():
"path": child1.path, "path": child1.path,
"title": child1.title, "title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": access.role, "user_roles": [access.role],
}, },
{ {
"abilities": grand_child.get_abilities(user), "abilities": grand_child.get_abilities(user),
"ancestors_link_reach": grand_child.ancestors_link_reach,
"ancestors_link_role": grand_child.ancestors_link_role,
"computed_link_reach": grand_child.computed_link_reach,
"computed_link_role": grand_child.computed_link_role,
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"), "created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(grand_child.creator.id), "creator": str(grand_child.creator.id),
"deleted_at": None,
"depth": 3, "depth": 3,
"excerpt": grand_child.excerpt, "excerpt": grand_child.excerpt,
"id": str(grand_child.id), "id": str(grand_child.id),
@@ -522,17 +446,12 @@ def test_api_documents_descendants_list_authenticated_related_direct():
"path": grand_child.path, "path": grand_child.path,
"title": grand_child.title, "title": grand_child.title,
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": access.role, "user_roles": [access.role],
}, },
{ {
"abilities": child2.get_abilities(user), "abilities": child2.get_abilities(user),
"ancestors_link_reach": child2.ancestors_link_reach,
"ancestors_link_role": child2.ancestors_link_role,
"computed_link_reach": child2.computed_link_reach,
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"), "created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id), "creator": str(child2.creator.id),
"deleted_at": None,
"depth": 2, "depth": 2,
"excerpt": child2.excerpt, "excerpt": child2.excerpt,
"id": str(child2.id), "id": str(child2.id),
@@ -545,7 +464,7 @@ def test_api_documents_descendants_list_authenticated_related_direct():
"path": child2.path, "path": child2.path,
"title": child2.title, "title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": access.role, "user_roles": [access.role],
}, },
], ],
} }
@@ -585,13 +504,8 @@ def test_api_documents_descendants_list_authenticated_related_parent():
"results": [ "results": [
{ {
"abilities": child1.get_abilities(user), "abilities": child1.get_abilities(user),
"ancestors_link_reach": child1.ancestors_link_reach,
"ancestors_link_role": child1.ancestors_link_role,
"computed_link_reach": child1.computed_link_reach,
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"), "created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id), "creator": str(child1.creator.id),
"deleted_at": None,
"depth": 4, "depth": 4,
"excerpt": child1.excerpt, "excerpt": child1.excerpt,
"id": str(child1.id), "id": str(child1.id),
@@ -604,17 +518,12 @@ def test_api_documents_descendants_list_authenticated_related_parent():
"path": child1.path, "path": child1.path,
"title": child1.title, "title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": grand_parent_access.role, "user_roles": [grand_parent_access.role],
}, },
{ {
"abilities": grand_child.get_abilities(user), "abilities": grand_child.get_abilities(user),
"ancestors_link_reach": grand_child.ancestors_link_reach,
"ancestors_link_role": grand_child.ancestors_link_role,
"computed_link_reach": grand_child.computed_link_reach,
"computed_link_role": grand_child.computed_link_role,
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"), "created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(grand_child.creator.id), "creator": str(grand_child.creator.id),
"deleted_at": None,
"depth": 5, "depth": 5,
"excerpt": grand_child.excerpt, "excerpt": grand_child.excerpt,
"id": str(grand_child.id), "id": str(grand_child.id),
@@ -627,17 +536,12 @@ def test_api_documents_descendants_list_authenticated_related_parent():
"path": grand_child.path, "path": grand_child.path,
"title": grand_child.title, "title": grand_child.title,
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": grand_parent_access.role, "user_roles": [grand_parent_access.role],
}, },
{ {
"abilities": child2.get_abilities(user), "abilities": child2.get_abilities(user),
"ancestors_link_reach": child2.ancestors_link_reach,
"ancestors_link_role": child2.ancestors_link_role,
"computed_link_reach": child2.computed_link_reach,
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"), "created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id), "creator": str(child2.creator.id),
"deleted_at": None,
"depth": 4, "depth": 4,
"excerpt": child2.excerpt, "excerpt": child2.excerpt,
"id": str(child2.id), "id": str(child2.id),
@@ -650,7 +554,7 @@ def test_api_documents_descendants_list_authenticated_related_parent():
"path": child2.path, "path": child2.path,
"title": child2.title, "title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": grand_parent_access.role, "user_roles": [grand_parent_access.role],
}, },
], ],
} }
@@ -736,13 +640,8 @@ def test_api_documents_descendants_list_authenticated_related_team_members(
"results": [ "results": [
{ {
"abilities": child1.get_abilities(user), "abilities": child1.get_abilities(user),
"ancestors_link_reach": child1.ancestors_link_reach,
"ancestors_link_role": child1.ancestors_link_role,
"computed_link_reach": child1.computed_link_reach,
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"), "created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id), "creator": str(child1.creator.id),
"deleted_at": None,
"depth": 2, "depth": 2,
"excerpt": child1.excerpt, "excerpt": child1.excerpt,
"id": str(child1.id), "id": str(child1.id),
@@ -755,17 +654,12 @@ def test_api_documents_descendants_list_authenticated_related_team_members(
"path": child1.path, "path": child1.path,
"title": child1.title, "title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": access.role, "user_roles": [access.role],
}, },
{ {
"abilities": grand_child.get_abilities(user), "abilities": grand_child.get_abilities(user),
"ancestors_link_reach": grand_child.ancestors_link_reach,
"ancestors_link_role": grand_child.ancestors_link_role,
"computed_link_reach": grand_child.computed_link_reach,
"computed_link_role": grand_child.computed_link_role,
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"), "created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(grand_child.creator.id), "creator": str(grand_child.creator.id),
"deleted_at": None,
"depth": 3, "depth": 3,
"excerpt": grand_child.excerpt, "excerpt": grand_child.excerpt,
"id": str(grand_child.id), "id": str(grand_child.id),
@@ -778,17 +672,12 @@ def test_api_documents_descendants_list_authenticated_related_team_members(
"path": grand_child.path, "path": grand_child.path,
"title": grand_child.title, "title": grand_child.title,
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": access.role, "user_roles": [access.role],
}, },
{ {
"abilities": child2.get_abilities(user), "abilities": child2.get_abilities(user),
"ancestors_link_reach": child2.ancestors_link_reach,
"ancestors_link_role": child2.ancestors_link_role,
"computed_link_reach": child2.computed_link_reach,
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"), "created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id), "creator": str(child2.creator.id),
"deleted_at": None,
"depth": 2, "depth": 2,
"excerpt": child2.excerpt, "excerpt": child2.excerpt,
"id": str(child2.id), "id": str(child2.id),
@@ -801,7 +690,7 @@ def test_api_documents_descendants_list_authenticated_related_team_members(
"path": child2.path, "path": child2.path,
"title": child2.title, "title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": access.role, "user_roles": [access.role],
}, },
], ],
} }
@@ -14,7 +14,6 @@ from django.utils import timezone
import pycrdt import pycrdt
import pytest import pytest
import requests import requests
from freezegun import freeze_time
from rest_framework.test import APIClient from rest_framework.test import APIClient
from core import factories, models from core import factories, models
@@ -61,7 +60,7 @@ def test_api_documents_duplicate_forbidden():
def test_api_documents_duplicate_anonymous(): def test_api_documents_duplicate_anonymous():
"""Anonymous users should not be able to duplicate documents even with read access.""" """Anonymous users should not be able to duplicate documents even with read access."""
document = factories.DocumentFactory(link_reach="public", link_role="reader") document = factories.DocumentFactory(link_reach="public")
response = APIClient().post(f"/api/v1.0/documents/{document.id!s}/duplicate/") response = APIClient().post(f"/api/v1.0/documents/{document.id!s}/duplicate/")
@@ -134,21 +133,19 @@ def test_api_documents_duplicate_success(index):
# Ensure access persists after the owner loses access to the original document # Ensure access persists after the owner loses access to the original document
models.DocumentAccess.objects.filter(document=document).delete() models.DocumentAccess.objects.filter(document=document).delete()
response = client.get(
now = timezone.now() "/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=image_refs[0][1]
with freeze_time(now): )
response = client.get(
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=image_refs[0][1]
)
assert response.status_code == 200 assert response.status_code == 200
assert response["X-Amz-Date"] == now.strftime("%Y%m%dT%H%M%SZ")
authorization = response["Authorization"] authorization = response["Authorization"]
assert "AWS4-HMAC-SHA256 Credential=" in authorization assert "AWS4-HMAC-SHA256 Credential=" in authorization
assert ( assert (
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=" "SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
in authorization in authorization
) )
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL) s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
response = requests.get( response = requests.get(
@@ -171,17 +168,14 @@ def test_api_documents_duplicate_success(index):
assert response.status_code == 403 assert response.status_code == 403
@pytest.mark.parametrize("role", ["owner", "administrator"]) def test_api_documents_duplicate_with_accesses():
def test_api_documents_duplicate_with_accesses_admin(role): """Accesses should be duplicated if the user requests it specifically."""
"""
Accesses should be duplicated if the user requests it specifically and is owner or admin.
"""
user = factories.UserFactory() user = factories.UserFactory()
client = APIClient() client = APIClient()
client.force_login(user) client.force_login(user)
document = factories.DocumentFactory( document = factories.DocumentFactory(
users=[(user, role)], users=[user],
title="document with accesses", title="document with accesses",
) )
user_access = factories.UserDocumentAccessFactory(document=document) user_access = factories.UserDocumentAccessFactory(document=document)
@@ -211,110 +205,3 @@ def test_api_documents_duplicate_with_accesses_admin(role):
assert duplicated_accesses.get(user=user).role == "owner" assert duplicated_accesses.get(user=user).role == "owner"
assert duplicated_accesses.get(user=user_access.user).role == user_access.role assert duplicated_accesses.get(user=user_access.user).role == user_access.role
assert duplicated_accesses.get(team=team_access.team).role == team_access.role assert duplicated_accesses.get(team=team_access.team).role == team_access.role
@pytest.mark.parametrize("role", ["editor", "reader"])
def test_api_documents_duplicate_with_accesses_non_admin(role):
"""
Accesses should not be duplicated if the user requests it specifically and is not owner
or admin.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(
users=[(user, role)],
title="document with accesses",
)
factories.UserDocumentAccessFactory(document=document)
factories.TeamDocumentAccessFactory(document=document)
# Duplicate the document via the API endpoint requesting to duplicate accesses
response = client.post(
f"/api/v1.0/documents/{document.id!s}/duplicate/",
{"with_accesses": True},
format="json",
)
assert response.status_code == 201
duplicated_document = models.Document.objects.get(id=response.json()["id"])
assert duplicated_document.title == "Copy of document with accesses"
assert duplicated_document.content == document.content
assert duplicated_document.link_reach == document.link_reach
assert duplicated_document.link_role == document.link_role
assert duplicated_document.creator == user
assert duplicated_document.duplicated_from == document
assert duplicated_document.attachments == []
# Check that accesses were duplicated and the user who did the duplicate is forced as owner
duplicated_accesses = duplicated_document.accesses
assert duplicated_accesses.count() == 1
assert duplicated_accesses.get(user=user).role == "owner"
@pytest.mark.parametrize("role", ["editor", "reader"])
def test_api_documents_duplicate_non_root_document(role):
"""
Non-root documents can be duplicated but without accesses.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[(user, "owner")])
child = factories.DocumentFactory(
parent=document, users=[(user, role)], title="document with accesses"
)
assert child.accesses.count() == 1
# Duplicate the document via the API endpoint requesting to duplicate accesses
response = client.post(
f"/api/v1.0/documents/{child.id!s}/duplicate/",
{"with_accesses": True},
format="json",
)
assert response.status_code == 201
duplicated_document = models.Document.objects.get(id=response.json()["id"])
assert duplicated_document.title == "Copy of document with accesses"
assert duplicated_document.content == child.content
assert duplicated_document.link_reach == child.link_reach
assert duplicated_document.link_role == child.link_role
assert duplicated_document.creator == user
assert duplicated_document.duplicated_from == child
assert duplicated_document.attachments == []
# No access should be created for non root documents
duplicated_accesses = duplicated_document.accesses
assert duplicated_accesses.count() == 0
assert duplicated_document.is_sibling_of(child)
assert duplicated_document.is_child_of(document)
def test_api_documents_duplicate_reader_non_root_document():
"""
Reader users should be able to duplicate non-root documents but will be
created as a root document.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[(user, "reader")])
child = factories.DocumentFactory(parent=document)
assert child.get_role(user) == "reader"
response = client.post(
f"/api/v1.0/documents/{child.id!s}/duplicate/", format="json"
)
assert response.status_code == 201
duplicated_document = models.Document.objects.get(id=response.json()["id"])
assert duplicated_document.is_root()
assert duplicated_document.accesses.count() == 1
assert duplicated_document.accesses.get(user=user).role == "owner"
@@ -45,10 +45,7 @@ def test_api_document_favorite_anonymous_user(method, reach):
], ],
) )
def test_api_document_favorite_authenticated_post_allowed(reach, has_role): def test_api_document_favorite_authenticated_post_allowed(reach, has_role):
""" """Authenticated users should be able to mark a document as favorite using POST."""
Authenticated users should be able to mark a document to which they have access
as favorite using POST.
"""
user = factories.UserFactory() user = factories.UserFactory()
document = factories.DocumentFactory(link_reach=reach) document = factories.DocumentFactory(link_reach=reach)
client = APIClient() client = APIClient()
@@ -72,10 +69,7 @@ def test_api_document_favorite_authenticated_post_allowed(reach, has_role):
def test_api_document_favorite_authenticated_post_forbidden(): def test_api_document_favorite_authenticated_post_forbidden():
""" """Authenticated users should be able to mark a document as favorite using POST."""
Authenticated users should not be allowed to mark a document to which they don't
have access as favorite using POST.
"""
user = factories.UserFactory() user = factories.UserFactory()
document = factories.DocumentFactory(link_reach="restricted") document = factories.DocumentFactory(link_reach="restricted")
client = APIClient() client = APIClient()
@@ -41,8 +41,8 @@ def test_api_document_favorite_list_authenticated_with_favorite():
client = APIClient() client = APIClient()
client.force_login(user) client.force_login(user)
# If the user doesn't have access to this document (e.g the user had access # User don't have access to this document, let say it had access and this access has been
# and this access was removed), it should not be in the favorite list anymore. # removed. It should not be in the favorite list anymore.
factories.DocumentFactory(favorited_by=[user]) factories.DocumentFactory(favorited_by=[user])
document = factories.UserDocumentAccessFactory( document = factories.UserDocumentAccessFactory(
@@ -59,13 +59,8 @@ def test_api_document_favorite_list_authenticated_with_favorite():
"results": [ "results": [
{ {
"abilities": document.get_abilities(user), "abilities": document.get_abilities(user),
"ancestors_link_reach": None,
"ancestors_link_role": None,
"computed_link_reach": document.computed_link_reach,
"computed_link_role": document.computed_link_role,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"), "created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id), "creator": str(document.creator.id),
"deleted_at": None,
"content": document.content, "content": document.content,
"depth": document.depth, "depth": document.depth,
"excerpt": document.excerpt, "excerpt": document.excerpt,
@@ -79,7 +74,7 @@ def test_api_document_favorite_list_authenticated_with_favorite():
"path": document.path, "path": document.path,
"title": document.title, "title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": "reader", "user_roles": ["reader"],
} }
], ],
} }
@@ -133,10 +133,7 @@ def test_api_documents_link_configuration_update_authenticated_related_success(
client = APIClient() client = APIClient()
client.force_login(user) client.force_login(user)
document = factories.DocumentFactory( document = factories.DocumentFactory()
link_reach=models.LinkReachChoices.AUTHENTICATED,
link_role=models.LinkRoleChoices.READER,
)
if via == USER: if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role) factories.UserDocumentAccessFactory(document=document, user=user, role=role)
elif via == TEAM: elif via == TEAM:
@@ -146,10 +143,7 @@ def test_api_documents_link_configuration_update_authenticated_related_success(
) )
new_document_values = serializers.LinkDocumentSerializer( new_document_values = serializers.LinkDocumentSerializer(
instance=factories.DocumentFactory( instance=factories.DocumentFactory()
link_reach=models.LinkReachChoices.PUBLIC,
link_role=models.LinkRoleChoices.EDITOR,
)
).data ).data
with mock_reset_connections(document.id): with mock_reset_connections(document.id):
@@ -164,240 +158,3 @@ def test_api_documents_link_configuration_update_authenticated_related_success(
document_values = serializers.LinkDocumentSerializer(instance=document).data document_values = serializers.LinkDocumentSerializer(instance=document).data
for key, value in document_values.items(): for key, value in document_values.items():
assert value == new_document_values[key] assert value == new_document_values[key]
def test_api_documents_link_configuration_update_role_restricted_forbidden():
"""
Test that trying to set link_role on a document with restricted link_reach
returns a validation error.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.RESTRICTED,
link_role=models.LinkRoleChoices.READER,
)
factories.UserDocumentAccessFactory(
document=document, user=user, role=models.RoleChoices.OWNER
)
# Try to set a meaningful role on a restricted document
new_data = {
"link_reach": models.LinkReachChoices.RESTRICTED,
"link_role": models.LinkRoleChoices.EDITOR,
}
response = client.put(
f"/api/v1.0/documents/{document.id!s}/link-configuration/",
new_data,
format="json",
)
assert response.status_code == 400
assert "link_role" in response.json()
assert (
"Cannot set link_role when link_reach is 'restricted'"
in response.json()["link_role"][0]
)
def test_api_documents_link_configuration_update_link_reach_required():
"""
Test that link_reach is required when updating link configuration.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.PUBLIC,
link_role=models.LinkRoleChoices.READER,
)
factories.UserDocumentAccessFactory(
document=document, user=user, role=models.RoleChoices.OWNER
)
# Try to update without providing link_reach
new_data = {"link_role": models.LinkRoleChoices.EDITOR}
response = client.put(
f"/api/v1.0/documents/{document.id!s}/link-configuration/",
new_data,
format="json",
)
assert response.status_code == 400
assert "link_reach" in response.json()
assert "This field is required" in response.json()["link_reach"][0]
def test_api_documents_link_configuration_update_restricted_without_role_success(
mock_reset_connections, # pylint: disable=redefined-outer-name
):
"""
Test that setting link_reach to restricted without specifying link_role succeeds.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.PUBLIC,
link_role=models.LinkRoleChoices.READER,
)
factories.UserDocumentAccessFactory(
document=document, user=user, role=models.RoleChoices.OWNER
)
# Only specify link_reach, not link_role
new_data = {
"link_reach": models.LinkReachChoices.RESTRICTED,
}
with mock_reset_connections(document.id):
response = client.put(
f"/api/v1.0/documents/{document.id!s}/link-configuration/",
new_data,
format="json",
)
assert response.status_code == 200
document.refresh_from_db()
assert document.link_reach == models.LinkReachChoices.RESTRICTED
@pytest.mark.parametrize(
"reach", [models.LinkReachChoices.PUBLIC, models.LinkReachChoices.AUTHENTICATED]
)
@pytest.mark.parametrize("role", models.LinkRoleChoices.values)
def test_api_documents_link_configuration_update_non_restricted_with_valid_role_success(
reach,
role,
mock_reset_connections, # pylint: disable=redefined-outer-name
):
"""
Test that setting non-restricted link_reach with valid link_role succeeds.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.RESTRICTED,
link_role=models.LinkRoleChoices.READER,
)
factories.UserDocumentAccessFactory(
document=document, user=user, role=models.RoleChoices.OWNER
)
new_data = {
"link_reach": reach,
"link_role": role,
}
with mock_reset_connections(document.id):
response = client.put(
f"/api/v1.0/documents/{document.id!s}/link-configuration/",
new_data,
format="json",
)
assert response.status_code == 200
document.refresh_from_db()
assert document.link_reach == reach
assert document.link_role == role
def test_api_documents_link_configuration_update_with_ancestor_constraints():
"""
Test that link configuration respects ancestor constraints using get_select_options.
This test may need adjustment based on the actual get_select_options implementation.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
parent_document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.PUBLIC,
link_role=models.LinkRoleChoices.READER,
)
child_document = factories.DocumentFactory(
parent=parent_document,
link_reach=models.LinkReachChoices.PUBLIC,
link_role=models.LinkRoleChoices.READER,
)
factories.UserDocumentAccessFactory(
document=child_document, user=user, role=models.RoleChoices.OWNER
)
# Try to set child to PUBLIC when parent is RESTRICTED
new_data = {
"link_reach": models.LinkReachChoices.RESTRICTED,
"link_role": models.LinkRoleChoices.READER,
}
response = client.put(
f"/api/v1.0/documents/{child_document.id!s}/link-configuration/",
new_data,
format="json",
)
assert response.status_code == 400
assert "link_reach" in response.json()
assert (
"Link reach 'restricted' is not allowed based on parent"
in response.json()["link_reach"][0]
)
def test_api_documents_link_configuration_update_invalid_role_for_reach_validation():
"""
Test the specific validation logic that checks if link_role is allowed for link_reach.
This tests the code section that validates allowed_roles from get_select_options.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
parent_document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.AUTHENTICATED,
link_role=models.LinkRoleChoices.EDITOR,
)
child_document = factories.DocumentFactory(
parent=parent_document,
link_reach=models.LinkReachChoices.RESTRICTED,
link_role=models.LinkRoleChoices.READER,
)
factories.UserDocumentAccessFactory(
document=child_document, user=user, role=models.RoleChoices.OWNER
)
new_data = {
"link_reach": models.LinkReachChoices.AUTHENTICATED,
"link_role": models.LinkRoleChoices.READER, # This should be rejected
}
response = client.put(
f"/api/v1.0/documents/{child_document.id!s}/link-configuration/",
new_data,
format="json",
)
assert response.status_code == 400
assert "link_role" in response.json()
error_message = response.json()["link_role"][0]
assert (
"Link role 'reader' is not allowed for link reach 'authenticated'"
in error_message
)
assert "Allowed roles: editor" in error_message
@@ -63,13 +63,8 @@ def test_api_documents_list_format():
assert results[0] == { assert results[0] == {
"id": str(document.id), "id": str(document.id),
"abilities": document.get_abilities(user), "abilities": document.get_abilities(user),
"ancestors_link_reach": None,
"ancestors_link_role": None,
"computed_link_reach": document.computed_link_reach,
"computed_link_role": document.computed_link_role,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"), "created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id), "creator": str(document.creator.id),
"deleted_at": None,
"depth": 1, "depth": 1,
"excerpt": document.excerpt, "excerpt": document.excerpt,
"is_favorite": True, "is_favorite": True,
@@ -81,7 +76,7 @@ def test_api_documents_list_format():
"path": document.path, "path": document.path,
"title": document.title, "title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": access.role, "user_roles": [access.role],
} }
@@ -153,11 +148,11 @@ def test_api_documents_list_authenticated_direct(django_assert_num_queries):
str(child4_with_access.id), str(child4_with_access.id),
} }
with django_assert_num_queries(14): with django_assert_num_queries(12):
response = client.get("/api/v1.0/documents/") response = client.get("/api/v1.0/documents/")
# nb_accesses should now be cached # nb_accesses should now be cached
with django_assert_num_queries(6): with django_assert_num_queries(4):
response = client.get("/api/v1.0/documents/") response = client.get("/api/v1.0/documents/")
assert response.status_code == 200 assert response.status_code == 200
@@ -273,11 +268,11 @@ def test_api_documents_list_authenticated_link_reach_public_or_authenticated(
expected_ids = {str(document1.id), str(document2.id), str(visible_child.id)} expected_ids = {str(document1.id), str(document2.id), str(visible_child.id)}
with django_assert_num_queries(11): with django_assert_num_queries(10):
response = client.get("/api/v1.0/documents/") response = client.get("/api/v1.0/documents/")
# nb_accesses should now be cached # nb_accesses should now be cached
with django_assert_num_queries(5): with django_assert_num_queries(4):
response = client.get("/api/v1.0/documents/") response = client.get("/api/v1.0/documents/")
assert response.status_code == 200 assert response.status_code == 200
@@ -428,20 +423,3 @@ def test_api_documents_list_favorites_no_extra_queries(django_assert_num_queries
assert result["is_favorite"] is True assert result["is_favorite"] is True
else: else:
assert result["is_favorite"] is False assert result["is_favorite"] is False
def test_api_documents_list_throttling(settings):
"""Test api documents throttling."""
current_rate = settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["document"]
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["document"] = "2/minute"
client = APIClient()
for _i in range(2):
response = client.get("/api/v1.0/documents/")
assert response.status_code == 200
with mock.patch("core.api.throttling.capture_message") as mock_capture_message:
response = client.get("/api/v1.0/documents/")
assert response.status_code == 429
mock_capture_message.assert_called_once_with(
"Rate limit exceeded for scope document", "warning"
)
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["document"] = current_rate
@@ -312,84 +312,6 @@ def test_api_documents_list_filter_is_favorite_invalid():
assert len(results) == 5 assert len(results) == 5
# Filters: is_masked
def test_api_documents_list_filter_is_masked_true():
"""
Authenticated users should be able to filter documents they marked as masked.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(2, users=[user])
masked_documents = factories.DocumentFactory.create_batch(
3, users=[user], masked_by=[user]
)
unmasked_documents = factories.DocumentFactory.create_batch(2, users=[user])
for document in unmasked_documents:
models.LinkTrace.objects.create(document=document, user=user, is_masked=False)
response = client.get("/api/v1.0/documents/?is_masked=true")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 3
# Ensure all results are marked as masked by the current user
masked_documents_ids = [str(doc.id) for doc in masked_documents]
for result in results:
assert result["id"] in masked_documents_ids
def test_api_documents_list_filter_is_masked_false():
"""
Authenticated users should be able to filter documents they didn't mark as masked.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(2, users=[user])
masked_documents = factories.DocumentFactory.create_batch(
3, users=[user], masked_by=[user]
)
unmasked_documents = factories.DocumentFactory.create_batch(2, users=[user])
for document in unmasked_documents:
models.LinkTrace.objects.create(document=document, user=user, is_masked=False)
response = client.get("/api/v1.0/documents/?is_masked=false")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 4
# Ensure all results are not marked as masked by the current user
masked_documents_ids = [str(doc.id) for doc in masked_documents]
for result in results:
assert result["id"] not in masked_documents_ids
def test_api_documents_list_filter_is_masked_invalid():
"""Filtering with an invalid `is_masked` value should do nothing."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(2, users=[user])
factories.DocumentFactory.create_batch(3, users=[user], masked_by=[user])
unmasked_documents = factories.DocumentFactory.create_batch(2, users=[user])
for document in unmasked_documents:
models.LinkTrace.objects.create(document=document, user=user, is_masked=False)
response = client.get("/api/v1.0/documents/?is_masked=invalid")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 7
# Filters: title # Filters: title
@@ -1,353 +0,0 @@
"""Test mask document API endpoint for users in impress's core app."""
import pytest
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
@pytest.mark.parametrize(
"reach",
[
"restricted",
"authenticated",
"public",
],
)
@pytest.mark.parametrize("method", ["post", "delete"])
def test_api_document_mask_anonymous_user(method, reach):
"""Anonymous users should not be able to mask/unmask documents."""
document = factories.DocumentFactory(link_reach=reach)
response = getattr(APIClient(), method)(
f"/api/v1.0/documents/{document.id!s}/mask/"
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
# Verify in database
assert models.LinkTrace.objects.exists() is False
@pytest.mark.parametrize(
"reach, has_role",
[
["restricted", True],
["authenticated", False],
["authenticated", True],
["public", False],
["public", True],
],
)
def test_api_document_mask_authenticated_post_allowed(reach, has_role):
"""Authenticated users should be able to mask a document to which they have access."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach)
if has_role:
models.DocumentAccess.objects.create(document=document, user=user)
# Try masking the document without a link trace
response = client.post(f"/api/v1.0/documents/{document.id!s}/mask/")
assert response.status_code == 400
assert response.json() == {"detail": "User never accessed this document before."}
assert not models.LinkTrace.objects.filter(document=document, user=user).exists()
models.LinkTrace.objects.create(document=document, user=user)
# Mask document
response = client.post(f"/api/v1.0/documents/{document.id!s}/mask/")
assert response.status_code == 201
assert response.json() == {"detail": "Document was masked"}
assert models.LinkTrace.objects.filter(
document=document, user=user, is_masked=True
).exists()
def test_api_document_mask_authenticated_post_forbidden():
"""
Authenticated users should no be allowed to mask a document
to which they don't have access.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="restricted")
# Try masking
response = client.post(f"/api/v1.0/documents/{document.id!s}/mask/")
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
# Verify in database
assert (
models.LinkTrace.objects.filter(document=document, user=user).exists() is False
)
@pytest.mark.parametrize(
"reach, has_role",
[
["restricted", True],
["authenticated", False],
["authenticated", True],
["public", False],
["public", True],
],
)
def test_api_document_mask_authenticated_post_already_masked_allowed(reach, has_role):
"""POST should not create duplicate link trace if already marked."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach, masked_by=[user])
if has_role:
models.DocumentAccess.objects.create(document=document, user=user)
# Try masking again
response = client.post(f"/api/v1.0/documents/{document.id!s}/mask/")
assert response.status_code == 200
assert response.json() == {"detail": "Document was already masked"}
assert models.LinkTrace.objects.filter(
document=document, user=user, is_masked=True
).exists()
def test_api_document_mask_authenticated_post_already_masked_forbidden():
"""POST should not create duplicate masks if already marked."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="restricted", masked_by=[user])
# Try masking again
response = client.post(f"/api/v1.0/documents/{document.id!s}/mask/")
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
assert models.LinkTrace.objects.filter(document=document, user=user).exists()
@pytest.mark.parametrize(
"reach, has_role",
[
["restricted", True],
["authenticated", False],
["authenticated", True],
["public", False],
["public", True],
],
)
def test_api_document_mask_authenticated_post_unmasked_allowed(reach, has_role):
"""POST should not create duplicate link trace if unmasked."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach)
models.LinkTrace.objects.create(document=document, user=user, is_masked=False)
if has_role:
models.DocumentAccess.objects.create(document=document, user=user)
# Try masking again
response = client.post(f"/api/v1.0/documents/{document.id!s}/mask/")
assert response.status_code == 201
assert response.json() == {"detail": "Document was masked"}
assert models.LinkTrace.objects.filter(
document=document, user=user, is_masked=True
).exists()
def test_api_document_mask_authenticated_post_unmasked_forbidden():
"""POST should not create duplicate masks if unmasked."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="restricted")
models.LinkTrace.objects.create(document=document, user=user, is_masked=False)
# Try masking again
response = client.post(f"/api/v1.0/documents/{document.id!s}/mask/")
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
assert models.LinkTrace.objects.filter(
document=document, user=user, is_masked=False
).exists()
@pytest.mark.parametrize(
"reach, has_role",
[
["restricted", True],
["authenticated", False],
["authenticated", True],
["public", False],
["public", True],
],
)
def test_api_document_mask_authenticated_delete_allowed(reach, has_role):
"""Authenticated users should be able to unmask a document using DELETE."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach, masked_by=[user])
if has_role:
models.DocumentAccess.objects.create(document=document, user=user)
# Unmask document
response = client.delete(f"/api/v1.0/documents/{document.id!s}/mask/")
assert response.status_code == 204
assert response.content == b"" # No body
assert response.text == "" # Empty decoded text
assert "Content-Type" not in response.headers # No Content-Type for 204
assert models.LinkTrace.objects.filter(
document=document, user=user, is_masked=False
).exists()
def test_api_document_mask_authenticated_delete_forbidden():
"""
Authenticated users should not be allowed to unmask a document if
they don't have access to it.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="restricted", masked_by=[user])
# Unmask document
response = client.delete(f"/api/v1.0/documents/{document.id!s}/mask/")
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
assert models.LinkTrace.objects.filter(
document=document, user=user, is_masked=True
).exists()
@pytest.mark.parametrize(
"reach, has_role",
[
["restricted", True],
["authenticated", False],
["authenticated", True],
["public", False],
["public", True],
],
)
def test_api_document_mask_authenticated_delete_not_masked_allowed(reach, has_role):
"""DELETE should be idempotent if the document is not masked."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach)
if has_role:
models.DocumentAccess.objects.create(document=document, user=user)
# Try unmasking the document without a link trace
response = client.delete(f"/api/v1.0/documents/{document.id!s}/mask/")
assert response.status_code == 400
assert response.json() == {"detail": "User never accessed this document before."}
assert not models.LinkTrace.objects.filter(document=document, user=user).exists()
models.LinkTrace.objects.create(document=document, user=user, is_masked=False)
# Unmask document
response = client.delete(f"/api/v1.0/documents/{document.id!s}/mask/")
assert response.status_code == 200
assert response.json() == {"detail": "Document was already not masked"}
assert models.LinkTrace.objects.filter(
document=document, user=user, is_masked=False
).exists()
def test_api_document_mask_authenticated_delete_not_masked_forbidden():
"""DELETE should be idempotent if the document is not masked."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="restricted")
# Try to unmask when no entry exists
response = client.delete(f"/api/v1.0/documents/{document.id!s}/mask/")
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
assert (
models.LinkTrace.objects.filter(document=document, user=user).exists() is False
)
@pytest.mark.parametrize(
"reach, has_role",
[
["restricted", True],
["authenticated", False],
["authenticated", True],
["public", False],
["public", True],
],
)
def test_api_document_mask_authenticated_post_unmark_then_mark_again_allowed(
reach, has_role
):
"""A user should be able to mask, unmask, and mask a document again."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach)
if has_role:
models.DocumentAccess.objects.create(document=document, user=user)
models.LinkTrace.objects.create(document=document, user=user, is_masked=False)
url = f"/api/v1.0/documents/{document.id!s}/mask/"
# Mask document
response = client.post(url)
assert response.status_code == 201
# Unmask document
response = client.delete(url)
assert response.status_code == 204
assert response.content == b"" # No body
assert response.text == "" # Empty decoded text
assert "Content-Type" not in response.headers # No Content-Type for 204
# Mask document again
response = client.post(url)
assert response.status_code == 201
assert response.json() == {"detail": "Document was masked"}
assert models.LinkTrace.objects.filter(
document=document, user=user, is_masked=True
).exists()
@@ -12,7 +12,6 @@ from django.utils import timezone
import pytest import pytest
import requests import requests
from freezegun import freeze_time
from rest_framework.test import APIClient from rest_framework.test import APIClient
from core import factories, models from core import factories, models
@@ -53,11 +52,9 @@ def test_api_documents_media_auth_anonymous_public():
factories.DocumentFactory(id=document_id, link_reach="public", attachments=[key]) factories.DocumentFactory(id=document_id, link_reach="public", attachments=[key])
original_url = f"http://localhost/media/{key:s}" original_url = f"http://localhost/media/{key:s}"
now = timezone.now() response = APIClient().get(
with freeze_time(now): "/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url
response = APIClient().get( )
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 200 assert response.status_code == 200
@@ -67,7 +64,7 @@ def test_api_documents_media_auth_anonymous_public():
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=" "SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
in authorization in authorization
) )
assert response["X-Amz-Date"] == now.strftime("%Y%m%dT%H%M%SZ") assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL) s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}" file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}"
@@ -170,11 +167,9 @@ def test_api_documents_media_auth_anonymous_attachments():
parent = factories.DocumentFactory(link_reach="public") parent = factories.DocumentFactory(link_reach="public")
factories.DocumentFactory(parent=parent, link_reach="restricted", attachments=[key]) factories.DocumentFactory(parent=parent, link_reach="restricted", attachments=[key])
now = timezone.now() response = APIClient().get(
with freeze_time(now): "/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url
response = APIClient().get( )
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url
)
assert response.status_code == 200 assert response.status_code == 200
@@ -184,7 +179,7 @@ def test_api_documents_media_auth_anonymous_attachments():
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=" "SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
in authorization in authorization
) )
assert response["X-Amz-Date"] == now.strftime("%Y%m%dT%H%M%SZ") assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL) s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}" file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}"
@@ -226,11 +221,9 @@ def test_api_documents_media_auth_authenticated_public_or_authenticated(reach):
factories.DocumentFactory(id=document_id, link_reach=reach, attachments=[key]) factories.DocumentFactory(id=document_id, link_reach=reach, attachments=[key])
now = timezone.now() response = client.get(
with freeze_time(now): "/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url
response = client.get( )
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url
)
assert response.status_code == 200 assert response.status_code == 200
@@ -240,7 +233,7 @@ def test_api_documents_media_auth_authenticated_public_or_authenticated(reach):
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=" "SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
in authorization in authorization
) )
assert response["X-Amz-Date"] == now.strftime("%Y%m%dT%H%M%SZ") assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL) s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}" file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}"
@@ -314,11 +307,9 @@ def test_api_documents_media_auth_related(via, mock_user_teams):
mock_user_teams.return_value = ["lasuite", "unknown"] mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(document=document, team="lasuite") factories.TeamDocumentAccessFactory(document=document, team="lasuite")
now = timezone.now() response = client.get(
with freeze_time(now): "/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url
response = client.get( )
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url
)
assert response.status_code == 200 assert response.status_code == 200
@@ -328,7 +319,7 @@ def test_api_documents_media_auth_related(via, mock_user_teams):
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=" "SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
in authorization in authorization
) )
assert response["X-Amz-Date"] == now.strftime("%Y%m%dT%H%M%SZ") assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL) s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}" file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}"
@@ -382,12 +373,10 @@ def test_api_documents_media_auth_missing_status_metadata():
factories.DocumentFactory(id=document_id, link_reach="public", attachments=[key]) factories.DocumentFactory(id=document_id, link_reach="public", attachments=[key])
now = timezone.now()
original_url = f"http://localhost/media/{key:s}" original_url = f"http://localhost/media/{key:s}"
with freeze_time(now): response = APIClient().get(
response = APIClient().get( "/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url )
)
assert response.status_code == 200 assert response.status_code == 200
@@ -397,7 +386,7 @@ def test_api_documents_media_auth_missing_status_metadata():
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=" "SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
in authorization in authorization
) )
assert response["X-Amz-Date"] == now.strftime("%Y%m%dT%H%M%SZ") assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL) s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}" file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}"

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