Compare commits

..

3 Commits

Author SHA1 Message Date
Manuel Raynaud f51246b392 (backend) add comment viewset
This commit add the CRUD part to manage comment lifeycle. Permissions
are relying on the Document and Comment abilities. Comment viewset
depends on the Document route and is added to the
document_related_router. Dedicated serializer and permission are
created.
2025-08-28 08:26:16 +02:00
Manuel Raynaud a11a1911bc (backend) add Comment model
In order to store the comments on a document, we created a new model
Comment. User is nullable because anonymous users can comment a Document
is this one is public with a link_role commentator.
2025-08-27 16:38:42 +02:00
Manuel Raynaud 604e5e0eb2 (backend) add commentator role
To allow a user to comment a document we added a new role: commentator.
Commentator is higher than reader but lower than editor.
2025-08-26 17:55:53 +02:00
582 changed files with 21148 additions and 50426 deletions
@@ -1,24 +0,0 @@
name: 'Free Disk Space'
description: 'Free up disk space by removing large preinstalled items and cleaning up Docker'
runs:
using: "composite"
steps:
- name: Free disk space (Linux only)
if: runner.os == 'Linux'
shell: bash
run: |
echo "Disk usage before cleanup:"
df -h
# Remove large preinstalled items that are not used on GitHub-hosted runners
sudo rm -rf /usr/share/dotnet || true
sudo rm -rf /opt/ghc || true
sudo rm -rf /usr/local/lib/android || true
# Clean up Docker
docker system prune -af || true
docker volume prune -f || true
echo "Disk usage after cleanup:"
df -h
+1 -2
View File
@@ -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
+10 -35
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,15 +46,9 @@ 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 }}
-
name: Cleanup Docker after build
if: always()
run: |
docker system prune -af
docker volume prune -f
build-and-push-frontend: build-and-push-frontend:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -73,11 +64,8 @@ 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
@@ -94,15 +82,9 @@ jobs:
build-args: | build-args: |
DOCKER_USER=${{ env.DOCKER_USER }}:-1000 DOCKER_USER=${{ env.DOCKER_USER }}:-1000
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 }}
-
name: Cleanup Docker after build
if: always()
run: |
docker system prune -af
docker volume prune -f
build-and-push-y-provider: build-and-push-y-provider:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -118,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
@@ -134,23 +116,16 @@ 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 }}
-
name: Cleanup Docker after build
if: always()
run: |
docker system prune -af
docker volume prune -f
notify-argocd: notify-argocd:
needs: needs:
- build-and-push-backend
- build-and-push-frontend - build-and-push-frontend
- build-and-push-y-provider - 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
+1 -97
View File
@@ -19,8 +19,6 @@ jobs:
test-front: test-front:
needs: install-dependencies needs: install-dependencies
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -43,8 +41,6 @@ jobs:
lint-front: lint-front:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: install-dependencies needs: install-dependencies
permissions:
contents: read
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -89,9 +85,6 @@ jobs:
- 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: Free disk space before Docker
uses: ./.github/actions/free-disk-space
- name: Start Docker services - name: Start Docker services
run: make bootstrap-e2e FLUSH_ARGS='--no-input' run: make bootstrap-e2e FLUSH_ARGS='--no-input'
@@ -108,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
@@ -131,9 +124,6 @@ jobs:
- 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: Free disk space before Docker
uses: ./.github/actions/free-disk-space
- name: Start Docker services - name: Start Docker services
run: make bootstrap-e2e FLUSH_ARGS='--no-input' run: make bootstrap-e2e FLUSH_ARGS='--no-input'
@@ -146,89 +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"
uikit-theme-checker:
runs-on: ubuntu-latest
needs: install-dependencies
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22.x"
- 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: Build theme
run: cd src/frontend/apps/impress && yarn build-theme
- name: Ensure theme is up to date
shell: bash
run: |
if [[ -n "$(git status --porcelain)" ]]; then
echo "Error: build-theme produced git changes (tracked or untracked)."
echo "--- git status --porcelain ---"
git status --porcelain
echo "--- git diff ---"
git --no-pager diff
exit 1
fi
+9 -16
View File
@@ -19,24 +19,20 @@ jobs:
if: github.event_name == 'pull_request' # Makes sense only for pull requests if: github.event_name == 'pull_request' # Makes sense only for pull requests
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v2
with: with:
fetch-depth: 0 fetch-depth: 0
- 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 -- src/backend ':(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:
@@ -46,7 +42,7 @@ jobs:
github.event_name == 'pull_request' github.event_name == 'pull_request'
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v3
with: with:
fetch-depth: 50 fetch-depth: 50
- name: Check that the CHANGELOG has been modified in the current branch - name: Check that the CHANGELOG has been modified in the current branch
@@ -56,7 +52,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v2
- name: Check CHANGELOG max line length - name: Check CHANGELOG max line length
run: | run: |
max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L) max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L)
@@ -70,7 +66,7 @@ jobs:
if: github.event_name == 'pull_request' if: github.event_name == 'pull_request'
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v2
- name: Install codespell - name: Install codespell
run: pip install --user codespell run: pip install --user codespell
- name: Check for typos - name: Check for typos
@@ -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" \
@@ -92,12 +87,11 @@ jobs:
working-directory: src/backend working-directory: src/backend
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 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]
@@ -202,7 +195,7 @@ jobs:
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install -y gettext pandoc shared-mime-info sudo apt-get install -y gettext pandoc shared-mime-info
sudo wget https://raw.githubusercontent.com/suitenumerique/django-lasuite/refs/heads/main/assets/conf/mime.types -O /etc/mime.types sudo wget https://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types -O /etc/mime.types
- name: Generate a MO file from strings extracted from the project - name: Generate a MO file from strings extracted from the project
run: python manage.py compilemessages run: python manage.py compilemessages
-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
-7
View File
@@ -43,10 +43,6 @@ venv.bak/
env.d/development/*.local env.d/development/*.local
env.d/terraform env.d/terraform
# Docker
compose.override.yml
docker/auth/*.local
# npm # npm
node_modules node_modules
@@ -79,6 +75,3 @@ db.sqlite3
.vscode/ .vscode/
*.iml *.iml
.devcontainer .devcontainer
# Cursor rules
.cursorrules
+57 -410
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),
@@ -8,368 +10,26 @@ and this project adheres to
### Added ### Added
- ✨(frontend) Can print a doc #1832 - ✨(backend) Comments on text editor #1309
- ✨(backend) manage reconciliation requests for user accounts #1878
- ✨(backend) add management command to reset a Document #1882
### Changed ### Changed
- (frontend) Focus main container after navigation #1854 - (frontend) improve accessibility:
- 🚸(backend) sort user search results by proximity with the active user #1802 - #1248
- #1235
- #1275
- #1255
- #1262
- #1244
- #1270
- #1282
### Fixed ### Fixed
- 🐛(frontend) fix broadcast store sync #1846 - 🐛(makefile) Windows compatibility fix for Docker volume mounting #1264
- 🐛(minio) fix user permission error with Minio and Windows #1264
## [v4.5.0] - 2026-01-28 ## [3.5.0] - 2025-07-31
### Added
- ✨(frontend) integrate configurable Waffle #1795
- ✨ Import of documents #1609
- 🚨(CI) gives warning if theme not updated #1811
- ✨(frontend) Add stat for Crisp #1824
- ✨(auth) add silent login #1690
- 🔧(project) add DJANGO_EMAIL_URL_APP environment variable #1825
### Changed
- ♿(frontend) improve accessibility:
- ♿️(frontend) fix subdoc opening and emoji pick focus #1745
- ✨(backend) add field for button label in email template #1817
### Fixed
- ✅(e2e) fix e2e test for other browsers #1799
- 🐛(export) fix export column NaN #1819
- 🐛(frontend) add fallback for unsupported Blocknote languages #1810
- 🐛(frontend) fix emojipicker closing in tree #1808
- 🐛(frontend) display children in favorite #1782
- 🐛(frontend) preserve typed text after @ on escape #1833
### Removed
- 🔥(project) remove all code related to template #1780
### Security
- 🔒️(trivy) fix vulnerability about jaraco.context #1806
## [v4.4.0] - 2026-01-13
### Added
- ✨(backend) add documents/all endpoint with descendants #1553
- ✅(export) add PDF regression tests #1762
- 📝(docs) Add language configuration documentation #1757
- 🔒(helm) Set default security context #1750
- ✨(backend) use langfuse to monitor AI actions #1776
### Changed
- ♿(frontend) improve accessibility:
- ♿(frontend) make html export accessible to screen reader users #1743
- ♿(frontend) add missing label and fix Axes errors to improve a11y #1693
### Fixed
- ✅(backend) reduce flakiness on backend test #1769
- 🐛(frontend) fix clickable main content regression #1773
- 🐛(backend) fix TRASHBIN_CUTOFF_DAYS type error #1778
- 💄(frontend) fix icon position in callout block #1779
### Security
- 🔒️(backend) validate more strictly url used by cors-proxy endpoint #1768
- 🔒️(frontend) fix props vulnerability in Interlinking #1792
## [v4.3.0] - 2026-01-05
### Added
- ✨(helm) redirecting system #1697
- 📱(frontend) add comments for smaller device #1737
- ✨(project) add custom js support via config #1759
### Changed
- 🥅(frontend) intercept 401 error on GET threads #1754
- 🦺(frontend) check content type pdf on PdfBlock #1756
- ✈️(frontend) pause Posthog when offline #1755
### Fixed
- 🐛(frontend) fix tables deletion #1739
- 🐛(frontend) fix children not display when first resize #1753
## [v4.2.0] - 2025-12-17
### Added
- ✨(backend) allow to create a new user in a marketing system #1707
- ✨(backend) add async indexation of documents on save (or access save) #1276
- ✨(backend) add debounce mechanism to limit indexation jobs #1276
- ✨(api) add API route to search for indexed documents in Find #1276
- 🥅(frontend) add boundary error page #1728
### Changed
- 🛂(backend) stop throttling collaboration servers #1730
- 🚸(backend) use unaccented full name for user search #1637
- 🌐(backend) internationalize demo #1644
- ♿(frontend) improve accessibility:
- ♿️Improve keyboard accessibility for the document tree #1681
### Fixed
- 🐛(frontend) paste content with comments from another document #1732
- 🐛(frontend) Select text + Go back one page crash the app #1733
- 🐛(frontend) fix versioning conflict #1742
## [v4.1.0] - 2025-12-09
### Added
- ⚡️(frontend) export html #1669
### Changed
- ♿(frontend) improve accessibility:
- ♿(frontend) add skip to content button for keyboard accessibility #1624
- ♿(frontend) fix toggle panel button a11y labels #1634
- 🔒️(frontend) remove dangerouslySetInnerHTML from codebase #1712
- ⚡️(frontend) improve Comments feature #1687
### Fixed
- 🐛(nginx) fix / location to handle new static pages #1682
- 🐛(frontend) rerendering during resize window #1715
## [v4.0.0] - 2025-12-01
### Added
- ✨ Add comments feature to the editor #1330
- ✨(backend) Comments on text editor #1330
- ✨(frontend) link to create new doc #1574
### Changed
- ⚡️(sw) stop to cache external resources likes videos #1655
- 💥(frontend) upgrade to ui-kit v2 #1605
- ⚡️(frontend) improve perf on upload and table of contents #1662
- ♿(frontend) improve accessibility:
- ♿(frontend) improve share modal button accessibility #1626
- ♿(frontend) improve screen reader support in DocShare modal #1628
### Fixed
- 🐛(frontend) fix toolbar not activated when reader #1640
- 🐛(frontend) preserve left panel width on window resize #1588
- 🐛(frontend) prevent duplicate as first character in title #1595
## [v3.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
### Removed
- 🔥(backend) remove api managing templates
## [v3.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
### 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
## [v3.8.2] - 2025-10-17
### Fixed
- 🐛(service-worker) fix sw registration and page reload logic #1500
## [v3.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
## [v3.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
## [v3.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
## [v3.6.0] - 2025-09-04
### Added
- 👷(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
## [v3.5.0] - 2025-07-31
### Added ### Added
@@ -397,7 +57,7 @@ and this project adheres to
- 🐛(frontend) 401 redirection overridden #1214 - 🐛(frontend) 401 redirection overridden #1214
- 🐛(frontend) include root parent in search #1243 - 🐛(frontend) include root parent in search #1243
## [v3.4.2] - 2025-07-18 ## [3.4.2] - 2025-07-18
### Changed ### Changed
@@ -407,7 +67,7 @@ and this project adheres to
- 🐛(backend) improve prompt to not use code blocks delimiter #1188 - 🐛(backend) improve prompt to not use code blocks delimiter #1188
## [v3.4.1] - 2025-07-15 ## [3.4.1] - 2025-07-15
### Fixed ### Fixed
@@ -418,7 +78,7 @@ and this project adheres to
- 🐛(frontend) fix crash share modal on grid options #1174 - 🐛(frontend) fix crash share modal on grid options #1174
- 🐛(frontend) fix unfold subdocs not clickable at the bottom #1179 - 🐛(frontend) fix unfold subdocs not clickable at the bottom #1179
## [v3.4.0] - 2025-07-09 ## [3.4.0] - 2025-07-09
### Added ### Added
@@ -462,7 +122,7 @@ and this project adheres to
- 🔥(frontend) remove Beta from logo #1095 - 🔥(frontend) remove Beta from logo #1095
## [v3.3.0] - 2025-05-06 ## [3.3.0] - 2025-05-06
### Added ### Added
@@ -494,14 +154,14 @@ and this project adheres to
- 🔥(back) remove footer endpoint #948 - 🔥(back) remove footer endpoint #948
## [v3.2.1] - 2025-05-06 ## [3.2.1] - 2025-05-06
## Fixed ## Fixed
- 🐛(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
## [v3.2.0] - 2025-05-05 ## [3.2.0] - 2025-05-05
## Added ## Added
@@ -528,7 +188,7 @@ and this project adheres to
- 🐛(backend) race condition create doc #633 - 🐛(backend) race condition create doc #633
- 🐛(frontend) fix breaklines in custom blocks #908 - 🐛(frontend) fix breaklines in custom blocks #908
## [v3.1.0] - 2025-04-07 ## [3.1.0] - 2025-04-07
## Added ## Added
@@ -546,7 +206,7 @@ and this project adheres to
- 🐛(back) validate document content in serializer #822 - 🐛(back) validate document content in serializer #822
- 🐛(frontend) fix selection click past end of content #840 - 🐛(frontend) fix selection click past end of content #840
## [v3.0.0] - 2025-03-28 ## [3.0.0] - 2025-03-28
## Added ## Added
@@ -562,7 +222,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
## [v2.6.0] - 2025-03-21 ## [2.6.0] - 2025-03-21
## Added ## Added
@@ -580,7 +240,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
## [v2.5.0] - 2025-03-18 ## [2.5.0] - 2025-03-18
## Added ## Added
@@ -610,7 +270,7 @@ and this project adheres to
- 🚨(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
## [v2.4.0] - 2025-03-06 ## [2.4.0] - 2025-03-06
## Added ## Added
@@ -624,7 +284,7 @@ and this project adheres to
- 🐛(frontend) fix collaboration error #684 - 🐛(frontend) fix collaboration error #684
## [v2.3.0] - 2025-03-03 ## [2.3.0] - 2025-03-03
## Added ## Added
@@ -651,7 +311,7 @@ and this project adheres to
- ♻️(frontend) improve table pdf rendering - ♻️(frontend) improve table pdf rendering
- 🐛(email) invitation emails in receivers language - 🐛(email) invitation emails in receivers language
## [v2.2.0] - 2025-02-10 ## [2.2.0] - 2025-02-10
## Added ## Added
@@ -670,7 +330,7 @@ and this project adheres to
- 🐛(frontend) fix cursor breakline #609 - 🐛(frontend) fix cursor breakline #609
- 🐛(frontend) fix style pdf export #609 - 🐛(frontend) fix style pdf export #609
## [v2.1.0] - 2025-01-29 ## [2.1.0] - 2025-01-29
## Added ## Added
@@ -699,14 +359,14 @@ and this project adheres to
- 🔥(backend) remove "content" field from list serializer # 516 - 🔥(backend) remove "content" field from list serializer # 516
## [v2.0.1] - 2025-01-17 ## [2.0.1] - 2025-01-17
## Fixed ## Fixed
-🐛(frontend) share modal is shown when you don't have the abilities #557 -🐛(frontend) share modal is shown when you don't have the abilities #557
-🐛(frontend) title copy break app #564 -🐛(frontend) title copy break app #564
## [v2.0.0] - 2025-01-13 ## [2.0.0] - 2025-01-13
## Added ## Added
@@ -737,7 +397,7 @@ and this project adheres to
- 🐛(frontend) hide search and create doc button if not authenticated #555 - 🐛(frontend) hide search and create doc button if not authenticated #555
- 🐛(backend) race condition creation issue #556 - 🐛(backend) race condition creation issue #556
## [v1.10.0] - 2024-12-17 ## [1.10.0] - 2024-12-17
## Added ## Added
@@ -758,7 +418,7 @@ and this project adheres to
- 🐛(frontend) update doc editor height #481 - 🐛(frontend) update doc editor height #481
- 💄(frontend) add doc search #485 - 💄(frontend) add doc search #485
## [v1.9.0] - 2024-12-11 ## [1.9.0] - 2024-12-11
## Added ## Added
@@ -779,19 +439,19 @@ and this project adheres to
- 🐛(frontend) Fix hidden menu on Firefox #468 - 🐛(frontend) Fix hidden menu on Firefox #468
- 🐛(backend) fix sanitize problem IA #490 - 🐛(backend) fix sanitize problem IA #490
## [v1.8.2] - 2024-11-28 ## [1.8.2] - 2024-11-28
## Changed ## Changed
- ♻️(SW) change strategy html caching #460 - ♻️(SW) change strategy html caching #460
## [v1.8.1] - 2024-11-27 ## [1.8.1] - 2024-11-27
## Fixed ## Fixed
- 🐛(frontend) link not clickable and flickering firefox #457 - 🐛(frontend) link not clickable and flickering firefox #457
## [v1.8.0] - 2024-11-25 ## [1.8.0] - 2024-11-25
## Added ## Added
@@ -819,7 +479,7 @@ and this project adheres to
- 🐛(frontend) users have view access when revoked #387 - 🐛(frontend) users have view access when revoked #387
- 🐛(frontend) fix placeholder editable when double clicks #454 - 🐛(frontend) fix placeholder editable when double clicks #454
## [v1.7.0] - 2024-10-24 ## [1.7.0] - 2024-10-24
## Added ## Added
@@ -846,7 +506,7 @@ and this project adheres to
- 🔥(helm) remove infra related codes #366 - 🔥(helm) remove infra related codes #366
## [v1.6.0] - 2024-10-17 ## [1.6.0] - 2024-10-17
## Added ## Added
@@ -868,13 +528,13 @@ and this project adheres to
- 🐛(backend) fix nginx docker container #340 - 🐛(backend) fix nginx docker container #340
- 🐛(frontend) fix copy paste firefox #353 - 🐛(frontend) fix copy paste firefox #353
## [v1.5.1] - 2024-10-10 ## [1.5.1] - 2024-10-10
## Fixed ## Fixed
- 🐛(db) fix users duplicate #316 - 🐛(db) fix users duplicate #316
## [v1.5.0] - 2024-10-09 ## [1.5.0] - 2024-10-09
## Added ## Added
@@ -902,7 +562,7 @@ and this project adheres to
- 🔧(backend) fix configuration to avoid different ssl warning #297 - 🔧(backend) fix configuration to avoid different ssl warning #297
- 🐛(frontend) fix editor break line not working #302 - 🐛(frontend) fix editor break line not working #302
## [v1.4.0] - 2024-09-17 ## [1.4.0] - 2024-09-17
## Added ## Added
@@ -922,7 +582,7 @@ and this project adheres to
- 🐛(backend) Fix forcing ID when creating a document via API endpoint #234 - 🐛(backend) Fix forcing ID when creating a document via API endpoint #234
- 🐛 Rebuild frontend dev container from makefile #248 - 🐛 Rebuild frontend dev container from makefile #248
## [v1.3.0] - 2024-09-05 ## [1.3.0] - 2024-09-05
## Added ## Added
@@ -946,14 +606,14 @@ and this project adheres to
- 🔥(frontend) remove saving modal #213 - 🔥(frontend) remove saving modal #213
## [v1.2.1] - 2024-08-23 ## [1.2.1] - 2024-08-23
## Changed ## Changed
- ♻️ Change ordering docs datagrid #195 - ♻️ Change ordering docs datagrid #195
- 🔥(helm) use scaleway email #194 - 🔥(helm) use scaleway email #194
## [v1.2.0] - 2024-08-22 ## [1.2.0] - 2024-08-22
## Added ## Added
@@ -979,7 +639,7 @@ and this project adheres to
- 🔥(helm) remove htaccess #181 - 🔥(helm) remove htaccess #181
## [v1.1.0] - 2024-07-15 ## [1.1.0] - 2024-07-15
## Added ## Added
@@ -994,7 +654,7 @@ and this project adheres to
- ♻️(frontend) create a doc from a modal #132 - ♻️(frontend) create a doc from a modal #132
- ♻️(frontend) manage members from the share modal #140 - ♻️(frontend) manage members from the share modal #140
## [v1.0.0] - 2024-07-02 ## [1.0.0] - 2024-07-02
## Added ## Added
@@ -1032,27 +692,14 @@ and this project adheres to
- 💚(CI) Remove trigger workflow on push tags on CI (#68) - 💚(CI) Remove trigger workflow on push tags on CI (#68)
- 🔥(frontend) Remove coming soon page (#121) - 🔥(frontend) Remove coming soon page (#121)
## [v0.1.0] - 2024-05-24 ## [0.1.0] - 2024-05-24
## Added ## Added
- ✨(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/v4.5.0...main [unreleased]: https://github.com/suitenumerique/docs/compare/v3.5.0...main
[v4.5.0]: https://github.com/suitenumerique/docs/releases/v4.5.0
[v4.4.0]: https://github.com/suitenumerique/docs/releases/v4.4.0
[v4.3.0]: https://github.com/suitenumerique/docs/releases/v4.3.0
[v4.2.0]: https://github.com/suitenumerique/docs/releases/v4.2.0
[v4.1.0]: https://github.com/suitenumerique/docs/releases/v4.1.0
[v4.0.0]: https://github.com/suitenumerique/docs/releases/v4.0.0
[v3.10.0]: https://github.com/suitenumerique/docs/releases/v3.10.0
[v3.9.0]: https://github.com/suitenumerique/docs/releases/v3.9.0
[v3.8.2]: https://github.com/suitenumerique/docs/releases/v3.8.2
[v3.8.1]: https://github.com/suitenumerique/docs/releases/v3.8.1
[v3.8.0]: https://github.com/suitenumerique/docs/releases/v3.8.0
[v3.7.0]: https://github.com/suitenumerique/docs/releases/v3.7.0
[v3.6.0]: https://github.com/suitenumerique/docs/releases/v3.6.0
[v3.5.0]: https://github.com/suitenumerique/docs/releases/v3.5.0 [v3.5.0]: https://github.com/suitenumerique/docs/releases/v3.5.0
[v3.4.2]: https://github.com/suitenumerique/docs/releases/v3.4.2 [v3.4.2]: https://github.com/suitenumerique/docs/releases/v3.4.2
[v3.4.1]: https://github.com/suitenumerique/docs/releases/v3.4.1 [v3.4.1]: https://github.com/suitenumerique/docs/releases/v3.4.1
@@ -1077,12 +724,12 @@ and this project adheres to
[v1.8.0]: https://github.com/suitenumerique/docs/releases/v1.8.0 [v1.8.0]: https://github.com/suitenumerique/docs/releases/v1.8.0
[v1.7.0]: https://github.com/suitenumerique/docs/releases/v1.7.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 [v1.6.0]: https://github.com/suitenumerique/docs/releases/v1.6.0
[v1.5.1]: https://github.com/suitenumerique/docs/releases/v1.5.1 [1.5.1]: https://github.com/suitenumerique/docs/releases/v1.5.1
[v1.5.0]: https://github.com/suitenumerique/docs/releases/v1.5.0 [1.5.0]: https://github.com/suitenumerique/docs/releases/v1.5.0
[v1.4.0]: https://github.com/suitenumerique/docs/releases/v1.4.0 [1.4.0]: https://github.com/suitenumerique/docs/releases/v1.4.0
[v1.3.0]: https://github.com/suitenumerique/docs/releases/v1.3.0 [1.3.0]: https://github.com/suitenumerique/docs/releases/v1.3.0
[v1.2.1]: https://github.com/suitenumerique/docs/releases/v1.2.1 [1.2.1]: https://github.com/suitenumerique/docs/releases/v1.2.1
[v1.2.0]: https://github.com/suitenumerique/docs/releases/v1.2.0 [1.2.0]: https://github.com/suitenumerique/docs/releases/v1.2.0
[v1.1.0]: https://github.com/suitenumerique/docs/releases/v1.1.0 [1.1.0]: https://github.com/suitenumerique/docs/releases/v1.1.0
[v1.0.0]: https://github.com/suitenumerique/docs/releases/v1.0.0 [1.0.0]: https://github.com/suitenumerique/docs/releases/v1.0.0
[v0.1.0]: https://github.com/suitenumerique/docs/releases/v0.1.0 [0.1.0]: https://github.com/suitenumerique/docs/releases/v0.1.0
+6 -14
View File
@@ -4,7 +4,7 @@
FROM python:3.13.3-alpine AS base FROM python:3.13.3-alpine AS base
# Upgrade pip to its latest release to speed up dependencies installation # Upgrade pip to its latest release to speed up dependencies installation
RUN python -m pip install --upgrade pip 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 --no-cache
@@ -36,7 +36,7 @@ COPY ./src/mail /mail/app
WORKDIR /mail/app WORKDIR /mail/app
RUN yarn install --frozen-lockfile && \ RUN yarn install --frozen-lockfile && \
yarn build yarn build
# ---- static link collector ---- # ---- static link collector ----
@@ -58,7 +58,7 @@ WORKDIR /app
# collectstatic # collectstatic
RUN DJANGO_CONFIGURATION=Build \ RUN DJANGO_CONFIGURATION=Build \
python manage.py collectstatic --noinput python manage.py collectstatic --noinput
# Replace duplicated file by a symlink to decrease the overall size of the # Replace duplicated file by a symlink to decrease the overall size of the
# final image # final image
@@ -81,7 +81,7 @@ RUN apk add --no-cache \
pango \ pango \
shared-mime-info shared-mime-info
RUN wget https://raw.githubusercontent.com/suitenumerique/django-lasuite/refs/heads/main/assets/conf/mime.types -O /etc/mime.types RUN wget https://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types -O /etc/mime.types
# Copy entrypoint # Copy entrypoint
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
@@ -94,14 +94,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/
@@ -109,7 +101,7 @@ WORKDIR /app
# Generate compiled translation messages # Generate compiled translation messages
RUN DJANGO_CONFIGURATION=Build \ RUN DJANGO_CONFIGURATION=Build \
python manage.py compilemessages python manage.py compilemessages
# We wrap commands run in this container by the following entrypoint that # We wrap commands run in this container by the following entrypoint that
@@ -138,7 +130,7 @@ USER ${DOCKER_USER}
# Target database host (e.g. database engine following docker compose services # Target database host (e.g. database engine following docker compose services
# name) & port # name) & port
ENV DB_HOST=postgresql \ ENV DB_HOST=postgresql \
DB_PORT=5432 DB_PORT=5432
# Run django development server # Run django development server
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"] CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
+3 -76
View File
@@ -93,77 +93,13 @@ post-bootstrap: \
mails-build mails-build
.PHONY: post-bootstrap .PHONY: post-bootstrap
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 bootstrap: ## Prepare Docker developmentimages for the project
@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: \ bootstrap: \
pre-beautiful-bootstrap \
pre-bootstrap \ pre-bootstrap \
build \ build \
post-bootstrap \ post-bootstrap \
run \ run
post-beautiful-bootstrap
.PHONY: bootstrap .PHONY: bootstrap
bootstrap-e2e: ## Prepare Docker production images to be used for e2e tests bootstrap-e2e: ## Prepare Docker production images to be used for e2e tests
@@ -213,7 +149,6 @@ logs: ## display app-dev logs (follow mode)
.PHONY: logs .PHONY: logs
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 docspec
@$(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-development
@$(COMPOSE) up --force-recreate -d nginx @$(COMPOSE) up --force-recreate -d nginx
@@ -248,10 +183,6 @@ demo: ## flush db then create a demo for load testing purpose
@$(MANAGE) create_demo @$(MANAGE) create_demo
.PHONY: demo .PHONY: demo
index: ## index all documents to remote search
@$(MANAGE) index
.PHONY: index
# Nota bene: Black should come after isort just in case they don't agree... # Nota bene: Black should come after isort just in case they don't agree...
lint: ## lint back-end python sources lint: ## lint back-end python sources
lint: \ lint: \
@@ -411,10 +342,6 @@ run-frontend-development: ## Run the frontend in development mode
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
@@ -445,6 +372,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
+5 -22
View File
@@ -49,30 +49,19 @@ 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/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 🔧
### Test it ### Test it
You can test Docs on your browser by visiting this [demo document](https://docs.la-suite.eu/docs/9137bbb5-3e8a-4ff7-8a36-fcc4e8bd57f4/) You can test Docs on your browser by visiting this [demo document](https://impress-preprod.beta.numerique.gouv.fr/docs/6ee5aac4-4fb9-457d-95bf-bb56c2467713/)
### Run Docs locally ### Run Docs locally
@@ -141,12 +130,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:
-23
View File
@@ -16,29 +16,6 @@ the following command inside your docker container:
## [Unreleased] ## [Unreleased]
## [4.0.0] - 2025-11-26
- ⚠️ We updated `@gouvfr-lasuite/ui-kit` to `0.18.0`, so if you are customizing Docs with a css layer or with a custom template, you need to update your customization to follow the new design system structure.
More information about the changes in the design system can be found here:
- https://suitenumerique.github.io/cunningham/storybook/?path=/docs/migrating-from-v3-to-v4--docs
- https://github.com/suitenumerique/docs/pull/1605
- https://github.com/suitenumerique/docs/blob/main/docs/theming.md
- If you were using the `THEME_CUSTOMIZATION_FILE_PATH` and have overridden the header logo, you need to update your customization file to follow the new structure of the header, it is now:
```json
{
...,
"header": {
"icon": {
"src": "your_logo_src",
"width": "your_logo_width",
"height": "your_logo_height"
}
}
}
```
## [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/env.md) for more information.
+2 -6
View File
@@ -8,7 +8,6 @@ docker_build(
dockerfile='../Dockerfile', dockerfile='../Dockerfile',
only=['./src/backend', './src/mail', './docker'], only=['./src/backend', './src/mail', './docker'],
target = 'backend-production', target = 'backend-production',
build_args={'DOCKER_USER': '1000:1000'},
live_update=[ live_update=[
sync('../src/backend', '/app'), sync('../src/backend', '/app'),
run( run(
@@ -24,7 +23,6 @@ docker_build(
dockerfile='../src/frontend/servers/y-provider/Dockerfile', dockerfile='../src/frontend/servers/y-provider/Dockerfile',
only=['./src/frontend/', './docker/', './.dockerignore'], only=['./src/frontend/', './docker/', './.dockerignore'],
target = 'y-provider', target = 'y-provider',
build_args={'DOCKER_USER': '1000:1000'},
live_update=[ live_update=[
sync('../src/frontend/servers/y-provider/src', '/home/frontend/servers/y-provider/src'), sync('../src/frontend/servers/y-provider/src', '/home/frontend/servers/y-provider/src'),
] ]
@@ -36,16 +34,14 @@ docker_build(
dockerfile='../src/frontend/Dockerfile', dockerfile='../src/frontend/Dockerfile',
only=['./src/frontend', './docker', './.dockerignore'], only=['./src/frontend', './docker', './.dockerignore'],
target = 'impress', target = 'impress',
build_args={'DOCKER_USER': '1000:1000'},
live_update=[ live_update=[
sync('../src/frontend', '/home/frontend'), sync('../src/frontend', '/home/frontend'),
] ]
) )
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 = '''
-6
View File
@@ -1,6 +0,0 @@
#!/usr/bin/env bash
# shellcheck source=bin/_config.sh
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
_dc_run app-dev python -c 'from cryptography.fernet import Fernet;import sys; sys.stdout.write("\n" + Fernet.generate_key().decode() + "\n");'
+6 -27
View File
@@ -72,11 +72,6 @@ services:
- env.d/development/postgresql.local - env.d/development/postgresql.local
ports: ports:
- "8071:8000" - "8071:8000"
networks:
default: {}
lasuite:
aliases:
- impress
volumes: volumes:
- ./src/backend:/app - ./src/backend:/app
- ./data/static:/data/static - ./data/static:/data/static
@@ -97,9 +92,6 @@ services:
command: ["celery", "-A", "impress.celery_app", "worker", "-l", "DEBUG"] command: ["celery", "-A", "impress.celery_app", "worker", "-l", "DEBUG"]
environment: environment:
- DJANGO_CONFIGURATION=Development - DJANGO_CONFIGURATION=Development
networks:
- default
- lasuite
env_file: env_file:
- env.d/development/common - env.d/development/common
- env.d/development/common.local - env.d/development/common.local
@@ -115,11 +107,6 @@ services:
image: nginx:1.25 image: nginx:1.25
ports: ports:
- "8083:8083" - "8083:8083"
networks:
default: {}
lasuite:
aliases:
- nginx
volumes: volumes:
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro - ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
depends_on: depends_on:
@@ -197,20 +184,22 @@ services:
- env.d/development/kc_postgresql.local - 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
@@ -230,13 +219,3 @@ services:
kc_postgresql: kc_postgresql:
condition: service_healthy condition: service_healthy
restart: true restart: true
docspec:
image: ghcr.io/docspecio/api:2.6.3
ports:
- "4000:4000"
networks:
lasuite:
name: lasuite-network
driver: bridge
+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.test",
"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.test",
"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.test",
"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
View File
@@ -12,7 +12,6 @@ flowchart TD
Back --> DB("Database (PostgreSQL)") Back --> DB("Database (PostgreSQL)")
Back <--> Celery --> DB Back <--> Celery --> DB
Back ----> S3("Minio (S3)") Back ----> S3("Minio (S3)")
Back -- REST API --> Find
``` ```
### Architecture decision records ### Architecture decision records
Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

-177
View File
@@ -1,177 +0,0 @@
# Customization Guide 🛠
## Runtime Theming 🎨
### How to Use
To use this feature, simply set the `FRONTEND_CSS_URL` environment variable to the URL of your custom CSS file. For example:
```javascript
FRONTEND_CSS_URL=http://anything/custom-style.css
```
Once you've set this variable, Docs will load your custom CSS file and apply the styles to our frontend application.
### Benefits
This feature provides several benefits, including:
* **Easy customization** 🔄: With this feature, you can easily customize the look and feel of our application without requiring any code changes.
* **Flexibility** 🌈: You can use any CSS styles you like to create a custom theme that meets your needs.
* **Runtime theming** ⏱️: This feature allows you to change the theme of our application at runtime, without requiring a restart or recompilation.
### Example Use Case
Let's say you want to change the background color of our application to a custom color. You can create a custom CSS file with the following contents:
```css
body {
background-color: #3498db;
}
```
Then, set the `FRONTEND_CSS_URL` environment variable to the URL of your custom CSS file. Once you've done this, our application will load your custom CSS file and apply the styles, changing the background color to the custom color you specified.
----
## Runtime JavaScript Injection 🚀
### How to Use
To use this feature, simply set the `FRONTEND_JS_URL` environment variable to the URL of your custom JavaScript file. For example:
```javascript
FRONTEND_JS_URL=http://anything/custom-script.js
```
Once you've set this variable, Docs will load your custom JavaScript file and execute it in the browser, allowing you to modify the application's behavior at runtime.
### Benefits
This feature provides several benefits, including:
* **Dynamic customization** 🔄: With this feature, you can dynamically modify the behavior and appearance of our application without requiring any code changes.
* **Flexibility** 🌈: You can add custom functionality, modify existing features, or integrate third-party services.
* **Runtime injection** ⏱️: This feature allows you to inject JavaScript into the application at runtime, without requiring a restart or recompilation.
### Example Use Case
Let's say you want to add a custom menu to the application header. You can create a custom JavaScript file with the following contents:
```javascript
(function() {
'use strict';
function initCustomMenu() {
// Wait for the page to be fully loaded
const header = document.querySelector('header');
if (!header) return false;
// Create and inject your custom menu
const customMenu = document.createElement('div');
customMenu.innerHTML = '<button>Custom Menu</button>';
header.appendChild(customMenu);
console.log('Custom menu added successfully');
return true;
}
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initCustomMenu);
} else {
initCustomMenu();
}
})();
```
Then, set the `FRONTEND_JS_URL` environment variable to the URL of your custom JavaScript file. Once you've done this, our application will load your custom JavaScript file and execute it, adding your custom menu to the header.
----
## **Your Docs icon** 📝
You can add your own Docs icon 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.icon` 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 icon will be used.
----
## **Footer Configuration** 📝
The footer is configurable 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
`footer.default` is the fallback if the language is not supported.
---
Below is a visual example of a configured footer ⬇️:
![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
----
## **Waffle Configuration** 🧇
The Waffle (La Gaufre) is a widget that displays a grid of services.
![Waffle Configuration Example](./assets/waffle.png)
### Settings 🔧
```shellscript
THEME_CUSTOMIZATION_FILE_PATH=<path>
```
### Configuration
The Waffle can be configured in the theme customization file with the `waffle` key.
### Available Properties
See: [LaGaufreV2Props](https://github.com/suitenumerique/ui-kit/blob/main/src/components/la-gaufre/LaGaufreV2.tsx#L49)
### Complete Example
From the theme customization file: https://github.com/suitenumerique/docs/blob/main/src/helm/env.d/dev/configuration/theme/demo.json
### Behavior
- If `data.services` is provided, the Waffle will display those services statically
- If no data is provided, services can be fetched dynamically from an API endpoint thanks to the `apiUrl` property
+100 -120
View File
@@ -6,123 +6,103 @@ 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 | | AI_ALLOW_REACH_FROM | Users that can use AI must be this level. options are "public", "authenticated", "restricted" | authenticated |
| AI_API_KEY | AI key to be used for AI Base url | | | AI_API_KEY | AI key to be used for AI Base url | |
| AI_BASE_URL | OpenAI compatible AI base url | | | AI_BASE_URL | OpenAI compatible AI base url | |
| AI_FEATURE_ENABLED | Enable AI options | false | | AI_FEATURE_ENABLED | Enable AI options | false |
| AI_MODEL | AI Model to use | | | AI_MODEL | AI Model to use | |
| ALLOW_LOGOUT_GET_METHOD | Allow get logout method | true | | ALLOW_LOGOUT_GET_METHOD | Allow get logout method | true |
| API_USERS_LIST_LIMIT | Limit on API users | 5 | | API_USERS_LIST_LIMIT | Limit on API users | 5 |
| API_USERS_LIST_THROTTLE_RATE_BURST | Throttle rate for api on burst | 30/minute | | API_USERS_LIST_THROTTLE_RATE_BURST | Throttle rate for api on burst | 30/minute |
| API_USERS_LIST_THROTTLE_RATE_SUSTAINED | Throttle rate for api | 180/hour | | API_USERS_LIST_THROTTLE_RATE_SUSTAINED | Throttle rate for api | 180/hour |
| API_USERS_SEARCH_QUERY_MIN_LENGTH | Minimum characters to insert to search a user | 3 | | AWS_S3_ACCESS_KEY_ID | Access id for s3 endpoint | |
| AWS_S3_ACCESS_KEY_ID | Access id for s3 endpoint | | | AWS_S3_ENDPOINT_URL | S3 endpoint | |
| AWS_S3_ENDPOINT_URL | S3 endpoint | | | AWS_S3_REGION_NAME | Region name for s3 endpoint | |
| AWS_S3_REGION_NAME | Region name for s3 endpoint | | | AWS_S3_SECRET_ACCESS_KEY | Access key for s3 endpoint | |
| AWS_S3_SECRET_ACCESS_KEY | Access key for s3 endpoint | | | AWS_STORAGE_BUCKET_NAME | Bucket name for s3 endpoint | impress-media-storage |
| AWS_S3_SIGNATURE_VERSION | S3 signature version (`s3v4` or `s3`) | s3v4 | | CACHES_DEFAULT_TIMEOUT | Cache default timeout | 30 |
| AWS_STORAGE_BUCKET_NAME | Bucket name for s3 endpoint | impress-media-storage | | CACHES_KEY_PREFIX | The prefix used to every cache keys. | docs |
| CACHES_DEFAULT_TIMEOUT | Cache default timeout | 30 | | COLLABORATION_API_URL | Collaboration api host | |
| CACHES_DEFAULT_KEY_PREFIX | The prefix used to every cache keys. | docs | | COLLABORATION_SERVER_SECRET | Collaboration api secret | |
| COLLABORATION_API_URL | Collaboration api host | | | COLLABORATION_WS_NOT_CONNECTED_READY_ONLY | Users not connected to the collaboration server cannot edit | false |
| COLLABORATION_SERVER_SECRET | Collaboration api secret | | | COLLABORATION_WS_URL | Collaboration websocket url | |
| COLLABORATION_WS_NOT_CONNECTED_READY_ONLY | Users not connected to the collaboration server cannot edit | false | | CONVERSION_API_CONTENT_FIELD | Conversion api content field | content |
| COLLABORATION_WS_URL | Collaboration websocket url | | | CONVERSION_API_ENDPOINT | Conversion API endpoint | convert |
| CONVERSION_API_CONTENT_FIELD | Conversion api content field | content | | CONVERSION_API_SECURE | Require secure conversion api | false |
| CONVERSION_API_ENDPOINT | Conversion API endpoint | convert | | CONVERSION_API_TIMEOUT | Conversion api timeout | 30 |
| CONVERSION_API_SECURE | Require secure conversion api | false | | CRISP_WEBSITE_ID | Crisp website id for support | |
| CONVERSION_API_TIMEOUT | Conversion api timeout | 30 | | DB_ENGINE | Engine to use for database connections | django.db.backends.postgresql_psycopg2 |
| CONVERSION_FILE_MAX_SIZE | The file max size allowed when uploaded to convert it | 20971520 (20MB) | | DB_HOST | Host of the database | localhost |
| CONVERSION_FILE_EXTENSIONS_ALLOWED | Extension list managed by the conversion service | [".docx", ".md"] | DB_NAME | Name of the database | impress |
| CRISP_WEBSITE_ID | Crisp website id for support | | | DB_PASSWORD | Password to authenticate with | pass |
| DB_ENGINE | Engine to use for database connections | django.db.backends.postgresql_psycopg2 | | DB_PORT | Port of the database | 5432 |
| DB_HOST | Host of the database | localhost | | DB_USER | User to authenticate with | dinum |
| DB_NAME | Name of the database | impress | | DJANGO_ALLOWED_HOSTS | Allowed hosts | [] |
| DB_PASSWORD | Password to authenticate with | pass | | DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | Celery broker transport options | {} |
| DB_PORT | Port of the database | 5432 | | DJANGO_CELERY_BROKER_URL | Celery broker url | redis://redis:6379/0 |
| DB_USER | User to authenticate with | dinum | | DJANGO_CORS_ALLOW_ALL_ORIGINS | Allow all CORS origins | false |
| DJANGO_ALLOWED_HOSTS | Allowed hosts | [] | | DJANGO_CORS_ALLOWED_ORIGIN_REGEXES | List of origins allowed for CORS using regulair expressions | [] |
| DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | Celery broker transport options | {} | | DJANGO_CORS_ALLOWED_ORIGINS | List of origins allowed for CORS | [] |
| DJANGO_CELERY_BROKER_URL | Celery broker url | redis://redis:6379/0 | | DJANGO_CSRF_TRUSTED_ORIGINS | CSRF trusted origins | [] |
| DJANGO_CORS_ALLOWED_ORIGINS | List of origins allowed for CORS | [] | | DJANGO_EMAIL_BACKEND | Email backend library | django.core.mail.backends.smtp.EmailBackend |
| DJANGO_CORS_ALLOWED_ORIGIN_REGEXES | List of origins allowed for CORS using regulair expressions | [] | | DJANGO_EMAIL_BRAND_NAME | Brand name for email | |
| DJANGO_CORS_ALLOW_ALL_ORIGINS | Allow all CORS origins | false | | DJANGO_EMAIL_FROM | Email address used as sender | from@example.com |
| DJANGO_CSRF_TRUSTED_ORIGINS | CSRF trusted origins | [] | | DJANGO_EMAIL_HOST | Hostname of email | |
| DJANGO_EMAIL_BACKEND | Email backend library | django.core.mail.backends.smtp.EmailBackend | | DJANGO_EMAIL_HOST_PASSWORD | Password to authenticate with on the email host | |
| DJANGO_EMAIL_BRAND_NAME | Brand name for email | | | DJANGO_EMAIL_HOST_USER | User to authenticate with on the email host | |
| DJANGO_EMAIL_FROM | Email address used as sender | from@example.com | | DJANGO_EMAIL_LOGO_IMG | Logo for the email | |
| DJANGO_EMAIL_HOST | Hostname of email | | | DJANGO_EMAIL_PORT | Port used to connect to email host | |
| DJANGO_EMAIL_HOST_PASSWORD | Password to authenticate with on the email host | | | DJANGO_EMAIL_USE_SSL | Use ssl for email host connection | false |
| DJANGO_EMAIL_HOST_USER | User to authenticate with on the email host | | | DJANGO_EMAIL_USE_TLS | Use tls for email host connection | false |
| DJANGO_EMAIL_LOGO_IMG | Logo for the email | | | DJANGO_SECRET_KEY | Secret key | |
| DJANGO_EMAIL_PORT | Port used to connect to email host | | | DJANGO_SERVER_TO_SERVER_API_TOKENS | | [] |
| DJANGO_EMAIL_URL_APP | Url used in the email to go to the app | | | DOCUMENT_IMAGE_MAX_SIZE | Maximum size of document in bytes | 10485760 |
| DJANGO_EMAIL_USE_SSL | Use ssl for email host connection | false | | FRONTEND_CSS_URL | To add a external css file to the app | |
| DJANGO_EMAIL_USE_TLS | Use tls for email host connection | false | | FRONTEND_HOMEPAGE_FEATURE_ENABLED | Frontend feature flag to display the homepage | false |
| DJANGO_SECRET_KEY | Secret key | | | FRONTEND_THEME | Frontend theme to use | |
| DJANGO_SERVER_TO_SERVER_API_TOKENS | | [] | | LANGUAGE_CODE | Default language | en-us |
| DOCSPEC_API_URL | URL to endpoint of DocSpec conversion API | | | LOGGING_LEVEL_LOGGERS_APP | Application logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO |
| DOCUMENT_IMAGE_MAX_SIZE | Maximum size of document in bytes | 10485760 | | LOGGING_LEVEL_LOGGERS_ROOT | Default logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO |
| FRONTEND_CSS_URL | To add a external css file to the app | | | LOGIN_REDIRECT_URL | Login redirect url | |
| FRONTEND_JS_URL | To add a external js file to the app | | | LOGIN_REDIRECT_URL_FAILURE | Login redirect url on failure | |
| FRONTEND_HOMEPAGE_FEATURE_ENABLED | Frontend feature flag to display the homepage | false | | LOGOUT_REDIRECT_URL | Logout redirect url | |
| FRONTEND_THEME | Frontend theme to use | | | MALWARE_DETECTION_BACKEND | The malware detection backend use from the django-lasuite package | lasuite.malware_detection.backends.dummy.DummyBackend |
| LANGUAGE_CODE | Default language | en-us | | MALWARE_DETECTION_PARAMETERS | A dict containing all the parameters to initiate the malware detection backend | {"callback_path": "core.malware_detection.malware_detection_callback",} |
| LANGFUSE_SECRET_KEY | The Langfuse secret key used by the sdk | None | | MEDIA_BASE_URL | | |
| LANGFUSE_PUBLIC_KEY | The Langfuse public key used by the sdk | None | | NO_WEBSOCKET_CACHE_TIMEOUT | Cache used to store current editor session key when only users without websocket are editing a document | 120 |
| LANGFUSE_BASE_URL | The Langfuse base url used by the sdk | None | | OIDC_ALLOW_DUPLICATE_EMAILS | Allow duplicate emails | false |
| LASUITE_MARKETING_BACKEND | Backend used when SIGNUP_NEW_USER_TO_MARKETING_EMAIL is True. See https://github.com/suitenumerique/django-lasuite/blob/main/documentation/how-to-use-marketing-backend.md | lasuite.marketing.backends.dummy.DummyBackend | | OIDC_AUTH_REQUEST_EXTRA_PARAMS | OIDC extra auth parameters | {} |
| LASUITE_MARKETING_PARAMETERS | The parameters to configure LASUITE_MARKETING_BACKEND. See https://github.com/suitenumerique/django-lasuite/blob/main/documentation/how-to-use-marketing-backend.md | {} | | OIDC_CREATE_USER | Create used on OIDC | false |
| LOGGING_LEVEL_LOGGERS_APP | Application logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO | | OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | Fallback to email for identification | true |
| LOGGING_LEVEL_LOGGERS_ROOT | Default logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO | | OIDC_OP_AUTHORIZATION_ENDPOINT | Authorization endpoint for OIDC | |
| LOGIN_REDIRECT_URL | Login redirect url | | | OIDC_OP_JWKS_ENDPOINT | JWKS endpoint for OIDC | |
| LOGIN_REDIRECT_URL_FAILURE | Login redirect url on failure | | | OIDC_OP_LOGOUT_ENDPOINT | Logout endpoint for OIDC | |
| LOGOUT_REDIRECT_URL | Logout redirect url | | | OIDC_OP_TOKEN_ENDPOINT | Token endpoint for OIDC | |
| MALWARE_DETECTION_BACKEND | The malware detection backend use from the django-lasuite package | lasuite.malware_detection.backends.dummy.DummyBackend | | OIDC_OP_USER_ENDPOINT | User endpoint for OIDC | |
| MALWARE_DETECTION_PARAMETERS | A dict containing all the parameters to initiate the malware detection backend | {"callback_path": "core.malware_detection.malware_detection_callback",} | | OIDC_REDIRECT_ALLOWED_HOSTS | Allowed hosts for OIDC redirect url | [] |
| MEDIA_BASE_URL | | | | OIDC_REDIRECT_REQUIRE_HTTPS | Require https for OIDC redirect url | false |
| NO_WEBSOCKET_CACHE_TIMEOUT | Cache used to store current editor session key when only users without websocket are editing a document | 120 | | OIDC_RP_CLIENT_ID | Client id used for OIDC | impress |
| OIDC_ALLOW_DUPLICATE_EMAILS | Allow duplicate emails | false | | OIDC_RP_CLIENT_SECRET | Client secret used for OIDC | |
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | OIDC extra auth parameters | {} | | OIDC_RP_SCOPES | Scopes requested for OIDC | openid email |
| OIDC_CREATE_USER | Create used on OIDC | false | | OIDC_RP_SIGN_ALGO | verification algorithm used OIDC tokens | RS256 |
| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | Fallback to email for identification | true | | OIDC_STORE_ID_TOKEN | Store OIDC token | true |
| OIDC_OP_AUTHORIZATION_ENDPOINT | Authorization endpoint for OIDC | | | OIDC_USE_NONCE | Use nonce for OIDC | true |
| 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 | | | POSTHOG_KEY | Posthog key for analytics | |
| OIDC_OP_USER_ENDPOINT | User endpoint for OIDC | | | REDIS_URL | Cache url | redis://redis:6379/1 |
| OIDC_REDIRECT_ALLOWED_HOSTS | Allowed hosts for OIDC redirect url | [] | | SENTRY_DSN | Sentry host | |
| OIDC_REDIRECT_REQUIRE_HTTPS | Require https for OIDC redirect url | false | | SESSION_COOKIE_AGE | duration of the cookie session | 60*60*12 |
| OIDC_RP_CLIENT_ID | Client id used for OIDC | impress | | SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | | false |
| OIDC_RP_CLIENT_SECRET | Client secret used for OIDC | | | STORAGES_STATICFILES_BACKEND | | whitenoise.storage.CompressedManifestStaticFilesStorage |
| OIDC_RP_SCOPES | Scopes requested for OIDC | openid email | | THEME_CUSTOMIZATION_CACHE_TIMEOUT | Cache duration for the customization settings | 86400 |
| OIDC_RP_SIGN_ALGO | verification algorithm used OIDC tokens | RS256 | | 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 |
| OIDC_STORE_ID_TOKEN | Store OIDC token | true | | TRASHBIN_CUTOFF_DAYS | Trashbin cutoff | 30 |
| OIDC_USERINFO_FULLNAME_FIELDS | OIDC token claims to create full name | ["first_name", "last_name"] | | USER_OIDC_ESSENTIAL_CLAIMS | Essential claims in OIDC token | [] |
| OIDC_USERINFO_SHORTNAME_FIELD | OIDC token claims to create shortname | first_name | | Y_PROVIDER_API_BASE_URL | Y Provider url | |
| OIDC_USE_NONCE | Use nonce for OIDC | true | | Y_PROVIDER_API_KEY | Y provider API key | |
| POSTHOG_KEY | Posthog key for analytics | |
| REDIS_URL | Cache url | redis://redis:6379/1 |
| SEARCH_INDEXER_BATCH_SIZE | Size of each batch for indexation of all documents | 100000 |
| SEARCH_INDEXER_CLASS | Class of the backend for document indexation & search | |
| SEARCH_INDEXER_COUNTDOWN | Minimum debounce delay of indexation jobs (in seconds) | 1 |
| SEARCH_INDEXER_QUERY_LIMIT | Maximum number of results expected from search endpoint | 50 |
| SEARCH_INDEXER_SECRET | Token for indexation queries | |
| SEARCH_INDEXER_URL | Find application endpoint for indexation | |
| SENTRY_DSN | Sentry host | |
| SESSION_COOKIE_AGE | duration of the cookie session | 60*60*12 |
| SIGNUP_NEW_USER_TO_MARKETING_EMAIL | Register new user to the marketing onboarding. If True, see env LASUITE_MARKETING_* system | False |
| SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | | false |
| STORAGES_STATICFILES_BACKEND | | whitenoise.storage.CompressedManifestStaticFilesStorage |
| THEME_CUSTOMIZATION_CACHE_TIMEOUT | Cache duration for the customization settings | 86400 |
| 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 |
| TRASHBIN_CUTOFF_DAYS | Trashbin cutoff | 30 |
| USER_OIDC_ESSENTIAL_CLAIMS | Essential claims in OIDC token | [] |
| USER_RECONCILIATION_FORM_URL | URL of a third-party form for user reconciliation requests | |
| Y_PROVIDER_API_BASE_URL | Y Provider url | |
| Y_PROVIDER_API_KEY | Y provider API key | |
## impress-frontend image ## impress-frontend image
@@ -155,9 +135,9 @@ 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). * `xl-multi-column`: [AGPL-3.0](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.
+83 -88
View File
@@ -1,12 +1,3 @@
djangoSecretKey: &djangoSecretKey "lkjsdlfkjsldkfjslkdfjslkdjfslkdjf"
djangoSuperUserEmail: admin@example.com
djangoSuperUserPass: admin
aiApiKey: changeme
aiBaseUrl: changeme
oidc:
clientId: impress
clientSecret: ThisIsAnExampleKeyForDevPurposeOnly
image: image:
repository: lasuite/impress-backend repository: lasuite/impress-backend
pullPolicy: Always pullPolicy: Always
@@ -15,100 +6,86 @@ image:
backend: backend:
replicas: 1 replicas: 1
envVars: envVars:
COLLABORATION_API_URL: https://impress.127.0.0.1.nip.io/collaboration/api/
COLLABORATION_SERVER_SECRET: my-secret COLLABORATION_SERVER_SECRET: my-secret
DJANGO_CSRF_TRUSTED_ORIGINS: https://docs.127.0.0.1.nip.io DJANGO_CSRF_TRUSTED_ORIGINS: https://impress.127.0.0.1.nip.io
DJANGO_CONFIGURATION: Feature DJANGO_CONFIGURATION: Feature
DJANGO_ALLOWED_HOSTS: docs.127.0.0.1.nip.io DJANGO_ALLOWED_HOSTS: impress.127.0.0.1.nip.io
DJANGO_SERVER_TO_SERVER_API_TOKENS: secret-api-key DJANGO_SERVER_TO_SERVER_API_TOKENS: secret-api-key
DJANGO_SECRET_KEY: *djangoSecretKey DJANGO_SECRET_KEY: AgoodOrAbadKey
DJANGO_SETTINGS_MODULE: impress.settings DJANGO_SETTINGS_MODULE: impress.settings
DJANGO_SUPERUSER_PASSWORD: admin DJANGO_SUPERUSER_PASSWORD: admin
DJANGO_EMAIL_BRAND_NAME: "La Suite Numérique" DJANGO_EMAIL_BRAND_NAME: "La Suite Numérique"
DJANGO_EMAIL_HOST: "mailcatcher" DJANGO_EMAIL_HOST: "mailcatcher"
DJANGO_EMAIL_LOGO_IMG: https://docs.127.0.0.1.nip.io/assets/logo-suite-numerique.png DJANGO_EMAIL_LOGO_IMG: https://impress.127.0.0.1.nip.io/assets/logo-suite-numerique.png
DJANGO_EMAIL_PORT: 1025 DJANGO_EMAIL_PORT: 1025
DJANGO_EMAIL_URL_APP: https://docs.127.0.0.1.nip.io
DJANGO_EMAIL_USE_SSL: False DJANGO_EMAIL_USE_SSL: False
LOGGING_LEVEL_HANDLERS_CONSOLE: ERROR LOGGING_LEVEL_HANDLERS_CONSOLE: ERROR
LOGGING_LEVEL_LOGGERS_ROOT: INFO LOGGING_LEVEL_LOGGERS_ROOT: INFO
LOGGING_LEVEL_LOGGERS_APP: INFO LOGGING_LEVEL_LOGGERS_APP: INFO
OIDC_USERINFO_SHORTNAME_FIELD: "given_name" OIDC_OP_JWKS_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/certs
OIDC_USERINFO_FULLNAME_FIELDS: "given_name,usual_name" OIDC_OP_AUTHORIZATION_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/auth
OIDC_OP_JWKS_ENDPOINT: https://docs-keycloak.127.0.0.1.nip.io/realms/docs/protocol/openid-connect/certs OIDC_OP_TOKEN_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/token
OIDC_OP_AUTHORIZATION_ENDPOINT: https://docs-keycloak.127.0.0.1.nip.io/realms/docs/protocol/openid-connect/auth OIDC_OP_USER_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/userinfo
OIDC_OP_TOKEN_ENDPOINT: https://docs-keycloak.127.0.0.1.nip.io/realms/docs/protocol/openid-connect/token OIDC_OP_LOGOUT_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/session/end
OIDC_OP_USER_ENDPOINT: https://docs-keycloak.127.0.0.1.nip.io/realms/docs/protocol/openid-connect/userinfo OIDC_RP_CLIENT_ID: impress
OIDC_OP_LOGOUT_ENDPOINT: https://docs-keycloak.127.0.0.1.nip.io/realms/docs/protocol/openid-connect/logout
OIDC_RP_CLIENT_ID: docs
OIDC_RP_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly OIDC_RP_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
OIDC_RP_SIGN_ALGO: RS256 OIDC_RP_SIGN_ALGO: RS256
OIDC_RP_SCOPES: "openid email" OIDC_RP_SCOPES: "openid email"
LOGIN_REDIRECT_URL: https://docs.127.0.0.1.nip.io OIDC_VERIFY_SSL: False
LOGIN_REDIRECT_URL_FAILURE: https://docs.127.0.0.1.nip.io OIDC_USERINFO_SHORTNAME_FIELD: "given_name"
LOGOUT_REDIRECT_URL: https://docs.127.0.0.1.nip.io OIDC_USERINFO_FULLNAME_FIELDS: "given_name,usual_name"
DB_HOST: postgresql-dev-backend-postgres OIDC_REDIRECT_ALLOWED_HOSTS: https://impress.127.0.0.1.nip.io
DB_NAME: OIDC_AUTH_REQUEST_EXTRA_PARAMS: "{'acr_values': 'eidas1'}"
secretKeyRef: LOGIN_REDIRECT_URL: https://impress.127.0.0.1.nip.io
name: postgresql-dev-backend-postgres LOGIN_REDIRECT_URL_FAILURE: https://impress.127.0.0.1.nip.io
key: database LOGOUT_REDIRECT_URL: https://impress.127.0.0.1.nip.io
DB_USER: POSTHOG_KEY: "{'id': 'posthog_key', 'host': 'https://product.impress.127.0.0.1.nip.io'}"
secretKeyRef: DB_HOST: postgresql
name: postgresql-dev-backend-postgres DB_NAME: impress
key: username DB_USER: dinum
DB_PASSWORD: DB_PASSWORD: pass
secretKeyRef:
name: postgresql-dev-backend-postgres
key: password
DB_PORT: 5432 DB_PORT: 5432
REDIS_URL: redis://user:pass@redis-dev-backend-redis:6379/1 REDIS_URL: redis://default:pass@redis-master:6379/1
DJANGO_CELERY_BROKER_URL: redis://user:pass@redis-dev-backend-redis:6379/1 AWS_S3_ENDPOINT_URL: http://minio.impress.svc.cluster.local:9000
AWS_S3_ENDPOINT_URL: http://minio-dev-backend-minio.impress.svc.cluster.local:9000 AWS_S3_ACCESS_KEY_ID: root
AWS_S3_ACCESS_KEY_ID: dinum
AWS_S3_SECRET_ACCESS_KEY: password AWS_S3_SECRET_ACCESS_KEY: password
AWS_STORAGE_BUCKET_NAME: docs-media-storage AWS_STORAGE_BUCKET_NAME: impress-media-storage
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
USER_RECONCILIATION_FORM_URL: https://docs.127.0.0.1.nip.io
Y_PROVIDER_API_BASE_URL: http://impress-y-provider:443/api/ Y_PROVIDER_API_BASE_URL: http://impress-y-provider:443/api/
Y_PROVIDER_API_KEY: my-secret Y_PROVIDER_API_KEY: my-secret
CACHES_KEY_PREFIX: "{{ now | unixEpoch }}"
migrate: migrate:
command: command:
- "/bin/sh" - "/bin/sh"
- "-c" - "-c"
- | - |
while ! python manage.py check --database default > /dev/null 2>&1 python manage.py migrate --no-input &&
do python manage.py create_demo --force
echo "Database not ready"
sleep 2
done
echo "Database is ready"
python manage.py migrate --no-input
restartPolicy: Never restartPolicy: Never
command:
- "gunicorn"
- "-c"
- "/usr/local/etc/gunicorn/impress.py"
- "impress.wsgi:application"
- "--reload"
createsuperuser: createsuperuser:
command: command:
- "/bin/sh" - "/bin/sh"
- "-c" - "-c"
- | - |
while ! python manage.py check --database default > /dev/null 2>&1
do
echo "Database not ready"
sleep 2
done
echo "Database is ready"
python manage.py createsuperuser --email admin@example.com --password admin python manage.py createsuperuser --email admin@example.com --password admin
restartPolicy: Never restartPolicy: Never
# Extra volume mounts 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.13/site-packages/certifi/cacert.pem
subPath: cacert.pem subPath: cacert.pem
# Extra volumes 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
extraVolumes: extraVolumes:
- name: certs - name: certs
configMap: configMap:
@@ -117,7 +94,12 @@ backend:
- key: cacert.pem - key: cacert.pem
path: cacert.pem path: cacert.pem
frontend: frontend:
envVars:
PORT: 8080
NEXT_PUBLIC_API_ORIGIN: https://impress.127.0.0.1.nip.io
replicas: 1 replicas: 1
image: image:
repository: lasuite/impress-frontend repository: lasuite/impress-frontend
pullPolicy: Always pullPolicy: Always
@@ -132,47 +114,60 @@ yProvider:
tag: "latest" tag: "latest"
envVars: envVars:
COLLABORATION_BACKEND_BASE_URL: https://docs.127.0.0.1.nip.io
COLLABORATION_LOGGING: true COLLABORATION_LOGGING: true
COLLABORATION_SERVER_ORIGIN: https://docs.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
ingress: # Mount the certificate so yProvider can establish tls with the backend
enabled: true extraVolumeMounts:
host: docs.127.0.0.1.nip.io - name: certs
annotations: mountPath: /usr/local/share/ca-certificates/cacert.pem
nginx.ingress.kubernetes.io/proxy-body-size: 100m subPath: cacert.pem
ingressCollaborationWS: extraVolumes:
enabled: true - name: certs
host: docs.127.0.0.1.nip.io configMap:
name: certifi
ingressCollaborationApi: items:
enabled: true - key: cacert.pem
host: docs.127.0.0.1.nip.io path: cacert.pem
ingressAdmin:
enabled: true
host: docs.127.0.0.1.nip.io
posthog: posthog:
ingress: ingress:
enabled: false enabled: false
ingressAssets: ingressAssets:
enabled: false enabled: false
ingress:
enabled: true
host: impress.127.0.0.1.nip.io
ingressCollaborationWS:
enabled: true
host: impress.127.0.0.1.nip.io
ingressCollaborationApi:
enabled: true
host: impress.127.0.0.1.nip.io
ingressAdmin:
enabled: true
host: impress.127.0.0.1.nip.io
ingressMedia: ingressMedia:
enabled: true enabled: true
host: docs.127.0.0.1.nip.io host: impress.127.0.0.1.nip.io
annotations: annotations:
nginx.ingress.kubernetes.io/auth-url: https://docs.127.0.0.1.nip.io/api/v1.0/documents/media-auth/ nginx.ingress.kubernetes.io/auth-url: https://impress.127.0.0.1.nip.io/api/v1.0/documents/media-auth/
nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Amz-Date, X-Amz-Content-SHA256" nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Amz-Date, X-Amz-Content-SHA256"
nginx.ingress.kubernetes.io/upstream-vhost: minio-dev-backend-minio.impress.svc.cluster.local:9000 nginx.ingress.kubernetes.io/upstream-vhost: minio.impress.svc.cluster.local:9000
nginx.ingress.kubernetes.io/rewrite-target: /docs-media-storage/$1 nginx.ingress.kubernetes.io/rewrite-target: /impress-media-storage/$1
serviceMedia: serviceMedia:
host: minio-dev-backend-minio.impress.svc.cluster.local host: minio.impress.svc.cluster.local
port: 9000 port: 9000
File diff suppressed because it is too large Load Diff
+7 -23
View File
@@ -1,24 +1,8 @@
minio: auth:
rootUser: root
rootPassword: password
provisioning:
enabled: true enabled: true
image: minio/minio buckets:
name: minio - name: impress-media-storage
# serviceNameOverride: docs-minio versioning: true
ingress:
enabled: true
hostname: docs-minio.127.0.0.1.nip.io
tls:
enabled: true
secretName: docs-tls
consoleIngress:
enabled: true
hostname: docs-minio-console.127.0.0.1.nip.io
tls:
enabled: true
secretName: docs-tls
api:
port: 80
username: dinum
password: password
bucket: docs-media-storage
versioning: true
size: 1Gi
+5 -7
View File
@@ -1,9 +1,7 @@
postgres: auth:
enabled: true
name: postgres
#serviceNameOverride: postgres
image: postgres:16-alpine
username: dinum username: dinum
password: pass password: pass
database: dinum database: impress
size: 1Gi tls:
enabled: true
autoGenerated: true
+4 -7
View File
@@ -1,7 +1,4 @@
redis: auth:
enabled: true password: pass
name: redis architecture: standalone
#serviceNameOverride: redis
image: redis:8.2-alpine
username: user
password: pass
-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 🙏
-1
View File
@@ -127,7 +127,6 @@ DJANGO_EMAIL_FROM=<your email address>
DJANGO_EMAIL_BRAND_NAME=<brand name used in email templates> # e.g. "La Suite Numérique" 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" DJANGO_EMAIL_LOGO_IMG=<logo image to use in email templates.> # e.g. "https://docs.yourdomain.tld/assets/logo-suite-numerique.png"
DJANGO_EMAIL_URL_APP=<url used in email templates to go to the app> # e.g. "https://docs.yourdomain.tld"
``` ```
### AI ### AI
+61 -87
View File
@@ -7,7 +7,7 @@ This document is a step-by-step guide that describes how to install Docs on a k8
- k8s cluster with an nginx-ingress controller - k8s cluster with an nginx-ingress controller
- an OIDC provider (if you don't have one, we provide an example) - an OIDC provider (if you don't have one, we provide an example)
- a PostgreSQL server (if you don't have one, we provide an example) - a PostgreSQL server (if you don't have one, we provide an example)
- a Redis server (if you don't have one, we provide an example) - a Memcached server (if you don't have one, we provide an example)
- a S3 bucket (if you don't have one, we provide an example) - a S3 bucket (if you don't have one, we provide an example)
### Test cluster ### Test cluster
@@ -100,66 +100,50 @@ When your k8s cluster is ready (the ingress nginx controller is up), you can sta
Please remember that `*.127.0.0.1.nip.io` will always resolve to `127.0.0.1`, except in the k8s cluster where we configure CoreDNS to answer with the ingress-nginx service IP. Please remember that `*.127.0.0.1.nip.io` will always resolve to `127.0.0.1`, except in the k8s cluster where we configure CoreDNS to answer with the ingress-nginx service IP.
The namespace `impress` is already created, you can work in it and configure your kubectl cli to use it by default.
```
$ kubectl config set-context --current --namespace=impress
```
## Preparation ## Preparation
We provide our own helm chart for all development dependencies, it is available here https://github.com/suitenumerique/helm-dev-backend
This provided chart is for development purpose only and is not ready to use in production.
You can install it on your cluster to deploy keycloak, minio, postgresql and redis.
### What do you use to authenticate your users? ### What do you use to authenticate your users?
Docs uses OIDC, so if you already have an OIDC provider, obtain the necessary information to use it. In the next step, we will see how to configure Django (and thus Docs) to use it. If you do not have a provider, we will show you how to deploy a local Keycloak instance (this is not a production deployment, just a demo). Docs uses OIDC, so if you already have an OIDC provider, obtain the necessary information to use it. In the next step, we will see how to configure Django (and thus Docs) to use it. If you do not have a provider, we will show you how to deploy a local Keycloak instance (this is not a production deployment, just a demo).
``` ```
$ helm install --repo https://suitenumerique.github.io/helm-dev-backend -f docs/examples/helm/keycloak.values.yaml keycloak dev-backend $ kubectl create namespace impress
$ kubectl config set-context --current --namespace=impress
$ helm install keycloak oci://registry-1.docker.io/bitnamicharts/keycloak -f examples/keycloak.values.yaml
$ #wait until $ #wait until
$ kubectl get pods $ kubectl get po
NAME READY STATUS RESTARTS AGE NAME READY STATUS RESTARTS AGE
keycloak-dev-backend-keycloak-0 1/1 Running 0 20s keycloak-0 1/1 Running 0 6m48s
keycloak-dev-backend-keycloak-pg-0 1/1 Running 0 20s keycloak-postgresql-0 1/1 Running 0 6m48s
``` ```
From here the important information you will need are: From here the important information you will need are:
```yaml ```yaml
OIDC_OP_JWKS_ENDPOINT: https://docs-keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/certs OIDC_OP_JWKS_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/certs
OIDC_OP_AUTHORIZATION_ENDPOINT: https://docs-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://docs-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://docs-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://docs-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
OIDC_RP_SCOPES: "openid email" OIDC_RP_SCOPES: "openid email"
``` ```
You can find these values in **examples/helm/keycloak.values.yaml** You can find these values in **examples/keycloak.values.yaml**
### Find redis server connection values ### Find redis server connection values
Docs needs a redis so we start by deploying one: Docs needs a redis so we start by deploying one:
``` ```
$ helm install --repo https://suitenumerique.github.io/helm-dev-backend -f docs/examples/helm/redis.values.yaml redis dev-backend $ helm install redis oci://registry-1.docker.io/bitnamicharts/redis -f examples/redis.values.yaml
$ kubectl get pods $ kubectl get po
NAME READY STATUS RESTARTS AGE NAME READY STATUS RESTARTS AGE
keycloak-dev-backend-keycloak-0 1/1 Running 0 113s keycloak-0 1/1 Running 0 26m
keycloak-dev-backend-keycloak-pg-0 1/1 Running 0 113s keycloak-postgresql-0 1/1 Running 0 26m
redis-dev-backend-redis-68c9f66786-4dgxj 1/1 Running 0 2s redis-master-0 1/1 Running 0 35s
```
From here the important information you will need are:
```yaml
REDIS_URL: redis://user:pass@redis-dev-backend-redis:6379/1
DJANGO_CELERY_BROKER_URL: redis://user:pass@redis-dev-backend-redis:6379/1
``` ```
### Find postgresql connection values ### Find postgresql connection values
@@ -167,32 +151,22 @@ DJANGO_CELERY_BROKER_URL: redis://user:pass@redis-dev-backend-redis:6379/1
Docs uses a postgresql database as backend, so if you have a provider, obtain the necessary information to use it. If you don't, you can install a postgresql testing environment as follow: Docs uses a postgresql database as backend, so if you have a provider, obtain the necessary information to use it. If you don't, you can install a postgresql testing environment as follow:
``` ```
$ helm install --repo https://suitenumerique.github.io/helm-dev-backend -f docs/examples/helm/postgresql.values.yaml postgresql dev-backend $ helm install postgresql oci://registry-1.docker.io/bitnamicharts/postgresql -f examples/postgresql.values.yaml
$ kubectl get pods $ kubectl get po
NAME READY STATUS RESTARTS AGE NAME READY STATUS RESTARTS AGE
keycloak-dev-backend-keycloak-0 1/1 Running 0 3m42s keycloak-0 1/1 Running 0 28m
keycloak-dev-backend-keycloak-pg-0 1/1 Running 0 3m42s keycloak-postgresql-0 1/1 Running 0 28m
postgresql-dev-backend-postgres-0 1/1 Running 0 13s postgresql-0 1/1 Running 0 14m
redis-dev-backend-redis-68c9f66786-4dgxj 1/1 Running 0 111s redis-master-0 1/1 Running 0 42s
``` ```
From here the important information you will need are: From here the important information you will need are:
```yaml ```yaml
DB_HOST: postgresql-dev-backend-postgres DB_HOST: postgres-postgresql
DB_NAME: DB_NAME: impress
secretKeyRef: DB_USER: dinum
name: postgresql-dev-backend-postgres DB_PASSWORD: pass
key: database
DB_USER:
secretKeyRef:
name: postgresql-dev-backend-postgres
key: username
DB_PASSWORD:
secretKeyRef:
name: postgresql-dev-backend-postgres
key: password
DB_PORT: 5432 DB_PORT: 5432
``` ```
@@ -201,15 +175,15 @@ DB_PORT: 5432
Docs uses an s3 bucket to store documents, so if you have a provider obtain the necessary information to use it. If you don't, you can install a local minio testing environment as follow: Docs uses an s3 bucket to store documents, so if you have a provider obtain the necessary information to use it. If you don't, you can install a local minio testing environment as follow:
``` ```
$ helm install --repo https://suitenumerique.github.io/helm-dev-backend -f docs/examples/helm/minio.values.yaml minio dev-backend $ helm install minio oci://registry-1.docker.io/bitnamicharts/minio -f examples/minio.values.yaml
$ kubectl get pods $ kubectl get po
NAME READY STATUS RESTARTS AGE NAME READY STATUS RESTARTS AGE
keycloak-dev-backend-keycloak-0 1/1 Running 0 6m12s keycloak-0 1/1 Running 0 38m
keycloak-dev-backend-keycloak-pg-0 1/1 Running 0 6m12s keycloak-postgresql-0 1/1 Running 0 38m
minio-dev-backend-minio-0 1/1 Running 0 10s minio-84f5c66895-bbhsk 1/1 Running 0 42s
postgresql-dev-backend-postgres-0 1/1 Running 0 2m43s minio-provisioning-2b5sq 0/1 Completed 0 42s
redis-dev-backend-redis-68c9f66786-4dgxj 1/1 Running 0 4m21s postgresql-0 1/1 Running 0 24m
redis-master-0 1/1 Running 0 10m
``` ```
## Deployment ## Deployment
@@ -219,18 +193,20 @@ Now you are ready to deploy Docs without AI. AI requires more dependencies (Open
``` ```
$ helm repo add impress https://suitenumerique.github.io/docs/ $ helm repo add impress https://suitenumerique.github.io/docs/
$ helm repo update $ helm repo update
$ helm install impress impress/docs -f docs/examples/helm/impress.values.yaml $ helm install impress impress/docs -f examples/impress.values.yaml
$ kubectl get po $ kubectl get po
NAME READY STATUS RESTARTS AGE NAME READY STATUS RESTARTS AGE
impress-docs-backend-8494fb797d-8k8wt 1/1 Running 0 6m45s impress-docs-backend-96558758d-xtkbp 0/1 Running 0 79s
impress-docs-celery-worker-764b5dd98f-9qd6v 1/1 Running 0 6m45s impress-docs-backend-createsuperuser-r7ltc 0/1 Completed 0 79s
impress-docs-frontend-5b69b65cc4-s8pps 1/1 Running 0 6m45s impress-docs-backend-migrate-c949s 0/1 Completed 0 79s
impress-docs-y-provider-5fc7ccd8cc-6ttrf 1/1 Running 0 6m45s impress-docs-frontend-6749f644f7-p5s42 1/1 Running 0 79s
keycloak-dev-backend-keycloak-0 1/1 Running 0 24m impress-docs-y-provider-6947fd8f54-78f2l 1/1 Running 0 79s
keycloak-dev-backend-keycloak-pg-0 1/1 Running 0 24m keycloak-0 1/1 Running 0 48m
minio-dev-backend-minio-0 1/1 Running 0 8m24s keycloak-postgresql-0 1/1 Running 0 48m
postgresql-dev-backend-postgres-0 1/1 Running 0 20m minio-84f5c66895-bbhsk 1/1 Running 0 10m
redis-dev-backend-redis-68c9f66786-4dgxj 1/1 Running 0 22m minio-provisioning-2b5sq 0/1 Completed 0 10m
postgresql-0 1/1 Running 0 34m
redis-master-0 1/1 Running 0 20m
``` ```
## Test your deployment ## Test your deployment
@@ -239,15 +215,13 @@ In order to test your deployment you have to log into your instance. If you excl
``` ```
$ kubectl get ingress $ kubectl get ingress
NAME CLASS HOSTS ADDRESS PORTS AGE NAME CLASS HOSTS ADDRESS PORTS AGE
impress-docs <none> docs.127.0.0.1.nip.io localhost 80, 443 7m9s impress-docs <none> impress.127.0.0.1.nip.io localhost 80, 443 114s
impress-docs-admin <none> docs.127.0.0.1.nip.io localhost 80, 443 7m9s impress-docs-admin <none> impress.127.0.0.1.nip.io localhost 80, 443 114s
impress-docs-collaboration-api <none> docs.127.0.0.1.nip.io localhost 80, 443 7m9s impress-docs-collaboration-api <none> impress.127.0.0.1.nip.io localhost 80, 443 114s
impress-docs-media <none> docs.127.0.0.1.nip.io localhost 80, 443 7m9s impress-docs-media <none> impress.127.0.0.1.nip.io localhost 80, 443 114s
impress-docs-ws <none> docs.127.0.0.1.nip.io localhost 80, 443 7m9s impress-docs-ws <none> impress.127.0.0.1.nip.io localhost 80, 443 114s
keycloak-dev-backend-keycloak <none> docs-keycloak.127.0.0.1.nip.io localhost 80, 443 24m keycloak <none> keycloak.127.0.0.1.nip.io localhost 80 49m
minio-dev-backend-minio-api <none> docs-minio.127.0.0.1.nip.io localhost 80, 443 8m48s
minio-dev-backend-minio-console <none> docs-minio-console.127.0.0.1.nip.io localhost 80, 443 8m48s
``` ```
You can use Docs at https://docs.127.0.0.1.nip.io. The provisionning user in keycloak is docs/docs. You can use Docs at https://impress.127.0.0.1.nip.io. The provisionning user in keycloak is impress/impress.
-180
View File
@@ -1,180 +0,0 @@
# Language Configuration (2025-12)
This document explains how to configure and override the available languages in the Docs application.
## Default Languages
By default, the application supports the following languages (in priority order):
- English (en-us)
- French (fr-fr)
- German (de-de)
- Dutch (nl-nl)
- Spanish (es-es)
The default configuration is defined in `src/backend/impress/settings.py`:
```python
LANGUAGES = values.SingleNestedTupleValue(
(
("en-us", "English"),
("fr-fr", "Français"),
("de-de", "Deutsch"),
("nl-nl", "Nederlands"),
("es-es", "Español"),
)
)
```
## Overriding Languages
### Using Environment Variables
You can override the available languages by setting the `DJANGO_LANGUAGES` environment variable. This is the recommended approach for customizing language support without modifying the source code.
#### Format
The `DJANGO_LANGUAGES` variable expects a semicolon-separated list of language configurations, where each language is defined as `code,Display Name`:
```
DJANGO_LANGUAGES=code1,Name1;code2,Name2;code3,Name3
```
#### Example Configurations
**Example 1: English and French only**
```bash
DJANGO_LANGUAGES=en-us,English;fr-fr,Français
```
**Example 2: Add Italian and Chinese**
```bash
DJANGO_LANGUAGES=en-us,English;fr-fr,Français;de-de,Deutsch;it-it,Italiano;zh-cn,中文
```
**Example 3: Custom subset of languages**
```bash
DJANGO_LANGUAGES=fr-fr,Français;de-de,Deutsch;es-es,Español
```
### Configuration Files
#### Development Environment
For local development, you can set the `DJANGO_LANGUAGES` variable in your environment configuration file:
**File:** `env.d/development/common.local`
```bash
DJANGO_LANGUAGES=en-us,English;fr-fr,Français;de-de,Deutsch;it-it,Italiano;zh-cn,中文;
```
#### Production Environment
For production deployments, add the variable to your production environment configuration:
**File:** `env.d/production.dist/common`
```bash
DJANGO_LANGUAGES=en-us,English;fr-fr,Français
```
#### Docker Compose
When using Docker Compose, you can set the environment variable in your `compose.yml` or `compose.override.yml` file:
```yaml
services:
app:
environment:
- DJANGO_LANGUAGES=en-us,English;fr-fr,Français;de-de,Deutsch
```
## Important Considerations
### Language Codes
- Use standard language codes (ISO 639-1 with optional region codes)
- Format: `language-region` (e.g., `en-us`, `fr-fr`, `de-de`)
- Use lowercase for language codes and region identifiers
### Priority Order
Languages are listed in priority order. The first language in the list is used as the fallback language throughout the application when a specific translation is not available.
### Translation Availability
Before adding a new language, ensure that:
1. Translation files exist for that language in the `src/backend/locale/` directory
2. The frontend application has corresponding translation files
3. All required messages have been translated
#### Available Languages
The following languages have translation files available in `src/backend/locale/`:
- `br_FR` - Breton (France)
- `cn_CN` - Chinese (China) - *Note: Use `zh-cn` in DJANGO_LANGUAGES*
- `de_DE` - German (Germany) - Use `de-de`
- `en_US` - English (United States) - Use `en-us`
- `es_ES` - Spanish (Spain) - Use `es-es`
- `fr_FR` - French (France) - Use `fr-fr`
- `it_IT` - Italian (Italy) - Use `it-it`
- `nl_NL` - Dutch (Netherlands) - Use `nl-nl`
- `pt_PT` - Portuguese (Portugal) - Use `pt-pt`
- `ru_RU` - Russian (Russia) - Use `ru-ru`
- `sl_SI` - Slovenian (Slovenia) - Use `sl-si`
- `sv_SE` - Swedish (Sweden) - Use `sv-se`
- `tr_TR` - Turkish (Turkey) - Use `tr-tr`
- `uk_UA` - Ukrainian (Ukraine) - Use `uk-ua`
- `zh_CN` - Chinese (China) - Use `zh-cn`
**Note:** When configuring `DJANGO_LANGUAGES`, use lowercase with hyphens (e.g., `pt-pt`, `ru-ru`) rather than the directory name format.
### Translation Management
We use [Crowdin](https://crowdin.com/) to manage translations for the Docs application. Crowdin allows our community to contribute translations and helps maintain consistency across all supported languages.
**Want to add a new language or improve existing translations?**
If you would like us to support a new language or want to contribute to translations, please get in touch with the project maintainers. We can add new languages to our Crowdin project and coordinate translation efforts with the community.
### Cookie and Session
The application stores the user's language preference in a cookie named `docs_language`. The cookie path is set to `/` by default.
## Testing Language Configuration
After changing the language configuration:
1. Restart the application services
2. Verify the language selector displays the correct languages
3. Test switching between different languages
4. Confirm that content is displayed in the selected language
## Troubleshooting
### Languages not appearing
- Verify the environment variable is correctly formatted (semicolon-separated, comma between code and name)
- Check that there are no trailing spaces in language codes or names
- Ensure the application was restarted after changing the configuration
### Missing translations
If you add a new language but see untranslated text:
1. Check if translation files exist in `src/backend/locale/<language_code>/LC_MESSAGES/`
2. Run Django's `makemessages` and `compilemessages` commands to generate/update translations
3. Verify frontend translation files are available
## Related Configuration
- `LANGUAGE_CODE`: Default language code (default: `en-us`)
- `LANGUAGE_COOKIE_NAME`: Cookie name for storing user language preference (default: `docs_language`)
- `LANGUAGE_COOKIE_PATH`: Cookie path (default: `/`)
-41
View File
@@ -1,41 +0,0 @@
# Setup the Find search for Impress
This configuration will enable the fulltext search feature for Docs :
- Each save on **core.Document** or **core.DocumentAccess** will trigger the indexer
- The `api/v1.0/documents/search/` will work as a proxy with the Find API for fulltext search.
## Create an index service for Docs
Configure a **Service** for Docs application with these settings
- **Name**: `docs`<br>_request.auth.name of the Docs application._
- **Client id**: `impress`<br>_Name of the token audience or client_id of the Docs application._
See [how-to-use-indexer.md](how-to-use-indexer.md) for details.
## Configure settings of Docs
Add those Django settings the Docs application to enable the feature.
```shell
SEARCH_INDEXER_CLASS="core.services.search_indexers.FindDocumentIndexer"
SEARCH_INDEXER_COUNTDOWN=10 # Debounce delay in seconds for the indexer calls.
# The token from service "docs" of Find application (development).
SEARCH_INDEXER_SECRET="find-api-key-for-docs-with-exactly-50-chars-length"
SEARCH_INDEXER_URL="http://find:8000/api/v1.0/documents/index/"
# Search endpoint. Uses the OIDC token for authentication
SEARCH_INDEXER_QUERY_URL="http://find:8000/api/v1.0/documents/search/"
# Maximum number of results expected from the search endpoint
SEARCH_INDEXER_QUERY_LIMIT=50
```
We also need to enable the **OIDC Token** refresh or the authentication will fail quickly.
```shell
# Store OIDC tokens in the session
OIDC_STORE_ACCESS_TOKEN = True # Store the access token in the session
OIDC_STORE_REFRESH_TOKEN = True # Store the encrypted refresh token in the session
OIDC_STORE_REFRESH_TOKEN_KEY = "your-32-byte-encryption-key==" # Must be a valid Fernet key (32 url-safe base64-encoded bytes)
```
-11
View File
@@ -97,17 +97,6 @@ Production deployments differ significantly from development environments. The t
| 5433 | PostgreSQL (Keycloak) | | 5433 | PostgreSQL (Keycloak) |
| 1081 | MailCatcher | | 1081 | MailCatcher |
**With fulltext search service**
| Port | Service |
| --------- | --------------------- |
| 8081 | Find (Django) |
| 9200 | Opensearch |
| 9600 | Opensearch admin |
| 5601 | Opensearch dashboard |
| 25432 | PostgreSQL (Find) |
## 6. Sizing Guidelines ## 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. **RAM** start at 8 GB dev / 16 GB staging / 32 GB prod. Postgres and Keycloak are the first to OOM; scale them first.
+70
View File
@@ -0,0 +1,70 @@
# Runtime Theming 🎨
### How to Use
To use this feature, simply set the `FRONTEND_CSS_URL` environment variable to the URL of your custom CSS file. For example:
```javascript
FRONTEND_CSS_URL=http://anything/custom-style.css
```
Once you've set this variable, our application will load your custom CSS file and apply the styles to our frontend application.
### Benefits
This feature provides several benefits, including:
* **Easy customization** 🔄: With this feature, you can easily customize the look and feel of our application without requiring any code changes.
* **Flexibility** 🌈: You can use any CSS styles you like to create a custom theme that meets your needs.
* **Runtime theming** ⏱️: This feature allows you to change the theme of our application at runtime, without requiring a restart or recompilation.
### Example Use Case
Let's say you want to change the background color of our application to a custom color. You can create a custom CSS file with the following contents:
```css
body {
background-color: #3498db;
}
```
Then, set the `FRONTEND_CSS_URL` environment variable to the URL of your custom CSS file. Once you've done this, our application will load your custom CSS file and apply the styles, changing the background color to the custom color you specified.
----
# **Footer Configuration** 📝
The footer is configurable 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
`footer.default` is the fallback if the language is not supported.
---
Below is a visual example of a configured footer ⬇️:
![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
-30
View File
@@ -1,30 +0,0 @@
# User account reconciliation
It is possible to merge user accounts based on their email addresses.
Docs does not have an internal process to requests, but it allows the import of a CSV from an external form
(e.g. made with Grist) in the Django admin panel (in "Core" > "User reconciliation CSV imports" > "Add user reconciliation")
## CSV file format
The CSV must contain the following mandatory columns:
- `active_email`: the email of the user that will remain active after the process.
- `inactive_email`: the email of the user(s) that will be merged into the active user. It is possible to indicate several emails, so the user only has to make one request even if they have more than two accounts.
- `id`: a unique row id, so that entries already processed in a previous import are ignored.
The following columns are optional: `active_email_checked` and `inactive_email_checked` (both must contain `0` (False) or `1` (True), and both default to False.)
If present, it allows to indicate that the source form has a way to validate that the user making the request actually controls the email addresses, skipping the need to send confirmation emails (cf. below)
Once the CSV file is processed, this will create entries in "Core" > "User reconciliations" and send verification emails to validate that the user making the request actually controls the email addresses (unless `active_email_checked` and `inactive_email_checked` were set to `1` in the CSV)
In "Core" > "User reconciliations", an admin can then select all rows they wish to process and check the action "Process selected user reconciliations". Only rows that have the status `ready` and for which both emails have been validated will be processed.
## Settings
If there is a problem with the reconciliation attempt (e.g., one of the addresses given by the user does not match an existing account), the email signaling the error can give back the link to the reconciliation form. This is configured through the following environment variable:
```env
USER_RECONCILIATION_FORM_URL=<url used in the email for reconciliation with errors to allow a new requests>
# e.g. "https://yourgristinstance.tld/xxxx/UserReconciliationForm"
```
+1 -25
View File
@@ -20,7 +20,6 @@ DJANGO_EMAIL_BRAND_NAME="La Suite Numérique"
DJANGO_EMAIL_HOST="mailcatcher" DJANGO_EMAIL_HOST="mailcatcher"
DJANGO_EMAIL_LOGO_IMG="http://localhost:3000/assets/logo-suite-numerique.png" DJANGO_EMAIL_LOGO_IMG="http://localhost:3000/assets/logo-suite-numerique.png"
DJANGO_EMAIL_PORT=1025 DJANGO_EMAIL_PORT=1025
DJANGO_EMAIL_URL_APP="http://localhost:3000"
# Backend url # Backend url
IMPRESS_BASE_URL="http://localhost:8072" IMPRESS_BASE_URL="http://localhost:8072"
@@ -37,7 +36,6 @@ OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/impress/protocol/openid-connect/c
OIDC_OP_AUTHORIZATION_ENDPOINT=http://localhost:8083/realms/impress/protocol/openid-connect/auth OIDC_OP_AUTHORIZATION_ENDPOINT=http://localhost:8083/realms/impress/protocol/openid-connect/auth
OIDC_OP_TOKEN_ENDPOINT=http://nginx:8083/realms/impress/protocol/openid-connect/token OIDC_OP_TOKEN_ENDPOINT=http://nginx:8083/realms/impress/protocol/openid-connect/token
OIDC_OP_USER_ENDPOINT=http://nginx:8083/realms/impress/protocol/openid-connect/userinfo OIDC_OP_USER_ENDPOINT=http://nginx:8083/realms/impress/protocol/openid-connect/userinfo
OIDC_OP_INTROSPECTION_ENDPOINT=http://nginx:8083/realms/impress/protocol/openid-connect/token/introspect
OIDC_RP_CLIENT_ID=impress OIDC_RP_CLIENT_ID=impress
OIDC_RP_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly OIDC_RP_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly
@@ -48,20 +46,9 @@ LOGIN_REDIRECT_URL=http://localhost:3000
LOGIN_REDIRECT_URL_FAILURE=http://localhost:3000 LOGIN_REDIRECT_URL_FAILURE=http://localhost:3000
LOGOUT_REDIRECT_URL=http://localhost:3000 LOGOUT_REDIRECT_URL=http://localhost:3000
OIDC_REDIRECT_ALLOWED_HOSTS="localhost:8083,localhost:3000" OIDC_REDIRECT_ALLOWED_HOSTS=["http://localhost:8083", "http://localhost:3000"]
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"} OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
# Store OIDC tokens in the session. Needed by search/ endpoint.
# OIDC_STORE_ACCESS_TOKEN = True
# OIDC_STORE_REFRESH_TOKEN = True # Store the encrypted refresh token in the session.
# Must be a valid Fernet key (32 url-safe base64-encoded bytes)
# To create one, use the bin/fernetkey command.
# OIDC_STORE_REFRESH_TOKEN_KEY="your-32-byte-encryption-key=="
# User reconciliation
USER_RECONCILIATION_FORM_URL=http://localhost:3000
# AI # AI
AI_FEATURE_ENABLED=true AI_FEATURE_ENABLED=true
AI_BASE_URL=https://openaiendpoint.com AI_BASE_URL=https://openaiendpoint.com
@@ -79,14 +66,3 @@ COLLABORATION_WS_URL=ws://localhost:4444/collaboration/ws/
DJANGO_SERVER_TO_SERVER_API_TOKENS=server-api-token DJANGO_SERVER_TO_SERVER_API_TOKENS=server-api-token
Y_PROVIDER_API_BASE_URL=http://y-provider-development:4444/api/ Y_PROVIDER_API_BASE_URL=http://y-provider-development:4444/api/
Y_PROVIDER_API_KEY=yprovider-api-key Y_PROVIDER_API_KEY=yprovider-api-key
DOCSPEC_API_URL=http://docspec:4000/conversion
# Theme customization
THEME_CUSTOMIZATION_CACHE_TIMEOUT=15
# Indexer (disabled)
# SEARCH_INDEXER_CLASS="core.services.search_indexers.SearchIndexer"
SEARCH_INDEXER_SECRET=find-api-key-for-docs-with-exactly-50-chars-length # Key generated by create_demo in Find app.
SEARCH_INDEXER_URL="http://find:8000/api/v1.0/documents/index/"
SEARCH_INDEXER_QUERY_URL="http://find:8000/api/v1.0/documents/search/"
-4
View File
@@ -3,7 +3,3 @@ BURST_THROTTLE_RATES="200/minute"
COLLABORATION_API_URL=http://y-provider:4444/collaboration/api/ COLLABORATION_API_URL=http://y-provider:4444/collaboration/api/
SUSTAINED_THROTTLE_RATES="200/hour" SUSTAINED_THROTTLE_RATES="200/hour"
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
+1 -5
View File
@@ -24,8 +24,7 @@ DJANGO_EMAIL_FROM=<your email address>
#DJANGO_EMAIL_USE_SSL=true # A flag to enable or disable SSL 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_BRAND_NAME="La Suite Numérique"
DJANGO_EMAIL_LOGO_IMG="https://${DOCS_HOST}/assets/logo-suite-numerique.png" DJANGO_EMAIL_LOGO_IMG="https://${DOCS_HOST}/assets/logo-suite-numerique.png"
DJANGO_EMAIL_URL_APP="https://${DOCS_HOST}"
# Media # Media
AWS_S3_ENDPOINT_URL=https://${S3_HOST} AWS_S3_ENDPOINT_URL=https://${S3_HOST}
@@ -53,9 +52,6 @@ LOGOUT_REDIRECT_URL=https://${DOCS_HOST}
OIDC_REDIRECT_ALLOWED_HOSTS=["https://${DOCS_HOST}"] OIDC_REDIRECT_ALLOWED_HOSTS=["https://${DOCS_HOST}"]
# User reconciliation
#USER_RECONCILIATION_FORM_URL=https://${DOCS_HOST}
# AI # AI
#AI_FEATURE_ENABLED=true # is false by default #AI_FEATURE_ENABLED=true # is false by default
#AI_BASE_URL=https://openaiendpoint.com #AI_BASE_URL=https://openaiendpoint.com
+4 -26
View File
@@ -2,10 +2,6 @@
"extends": ["github>numerique-gouv/renovate-configuration"], "extends": ["github>numerique-gouv/renovate-configuration"],
"dependencyDashboard": true, "dependencyDashboard": true,
"labels": ["dependencies", "noChangeLog", "automated"], "labels": ["dependencies", "noChangeLog", "automated"],
"schedule": ["before 7am on monday"],
"prCreation": "not-pending",
"rebaseWhen": "conflicted",
"updateNotScheduled": false,
"packageRules": [ "packageRules": [
{ {
"enabled": false, "enabled": false,
@@ -19,36 +15,18 @@
"matchPackageNames": ["redis"], "matchPackageNames": ["redis"],
"allowedVersions": "<6.0.0" "allowedVersions": "<6.0.0"
}, },
{
"groupName": "allowed pylint versions",
"matchManagers": ["pep621"],
"matchPackageNames": ["pylint"],
"allowedVersions": "<4.0.0"
},
{
"groupName": "allowed django versions",
"matchManagers": ["pep621"],
"matchPackageNames": ["django"],
"allowedVersions": "<6.0.0"
},
{
"groupName": "allowed celery versions",
"matchManagers": ["pep621"],
"matchPackageNames": ["celery"],
"allowedVersions": "<5.6.0"
},
{ {
"enabled": false, "enabled": false,
"groupName": "ignored js dependencies", "groupName": "ignored js dependencies",
"matchManagers": ["npm"], "matchManagers": ["npm"],
"matchPackageNames": [ "matchPackageNames": [
"@next/eslint-plugin-next", "@hocuspocus/provider",
"eslint-config-next", "@hocuspocus/server",
"docx",
"eslint",
"fetch-mock", "fetch-mock",
"next",
"node", "node",
"node-fetch", "node-fetch",
"react-resizable-panels",
"workbox-webpack-plugin" "workbox-webpack-plugin"
] ]
} }
+18 -40
View File
@@ -1,14 +1,21 @@
"""Admin classes and registrations for core app.""" """Admin classes and registrations for core app."""
from django.contrib import admin, messages from django.contrib import admin
from django.contrib.auth import admin as auth_admin from django.contrib.auth import admin as auth_admin
from django.shortcuts import redirect
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 core import models from . import models
from core.tasks.user_reconciliation import user_reconciliation_csv_import_job
class TemplateAccessInline(admin.TabularInline):
"""Inline admin class for template accesses."""
autocomplete_fields = ["user"]
model = models.TemplateAccess
extra = 0
@admin.register(models.User) @admin.register(models.User)
@@ -63,6 +70,7 @@ class UserAdmin(auth_admin.UserAdmin):
}, },
), ),
) )
inlines = (TemplateAccessInline,)
list_display = ( list_display = (
"id", "id",
"sub", "sub",
@@ -97,46 +105,15 @@ class UserAdmin(auth_admin.UserAdmin):
search_fields = ("id", "sub", "admin_email", "email", "full_name") search_fields = ("id", "sub", "admin_email", "email", "full_name")
@admin.register(models.UserReconciliationCsvImport) @admin.register(models.Template)
class UserReconciliationCsvImportAdmin(admin.ModelAdmin): class TemplateAdmin(admin.ModelAdmin):
"""Admin class for UserReconciliationCsvImport model.""" """Template admin interface declaration."""
list_display = ("id", "__str__", "created_at", "status") inlines = (TemplateAccessInline,)
def save_model(self, request, obj, form, change):
"""Override save_model to trigger the import task on creation."""
super().save_model(request, obj, form, change)
if not change:
user_reconciliation_csv_import_job.delay(obj.pk)
messages.success(request, _("Import job created and queued."))
return redirect("..")
@admin.action(description=_("Process selected user reconciliations"))
def process_reconciliation(_modeladmin, _request, queryset):
"""
Admin action to process selected user reconciliations.
The action will process only entries that are ready and have both emails checked.
"""
processable_entries = queryset.filter(
status="ready", active_email_checked=True, inactive_email_checked=True
)
for entry in processable_entries:
entry.process_reconciliation_request()
@admin.register(models.UserReconciliation)
class UserReconciliationAdmin(admin.ModelAdmin):
"""Admin class for UserReconciliation model."""
list_display = ["id", "__str__", "created_at", "status"]
actions = [process_reconciliation]
class DocumentAccessInline(admin.TabularInline): class DocumentAccessInline(admin.TabularInline):
"""Inline admin class for document accesses.""" """Inline admin class for template accesses."""
autocomplete_fields = ["user"] autocomplete_fields = ["user"]
model = models.DocumentAccess model = models.DocumentAccess
@@ -180,6 +157,7 @@ class DocumentAdmin(TreeAdmin):
}, },
), ),
) )
form = movenodeform_factory(models.Document)
inlines = (DocumentAccessInline,) inlines = (DocumentAccessInline,)
list_display = ( list_display = (
"id", "id",
-11
View File
@@ -2,7 +2,6 @@
import unicodedata import unicodedata
from django.conf import settings
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
import django_filters import django_filters
@@ -129,13 +128,3 @@ class ListDocumentFilter(DocumentFilter):
queryset_method = queryset.filter if bool(value) else queryset.exclude queryset_method = queryset.filter if bool(value) else queryset.exclude
return queryset_method(link_traces__user=user, link_traces__is_masked=True) 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=settings.API_USERS_SEARCH_QUERY_MIN_LENGTH, max_length=254
)
+2 -2
View File
@@ -98,10 +98,10 @@ class CanCreateInvitationPermission(permissions.BasePermission):
class ResourceWithAccessPermission(permissions.BasePermission): class ResourceWithAccessPermission(permissions.BasePermission):
"""A permission class for invitations.""" """A permission class for templates and invitations."""
def has_permission(self, request, view): def has_permission(self, request, view):
"""check create permission.""" """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):
+89 -229
View File
@@ -1,56 +1,33 @@
"""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
from base64 import b64decode from base64 import b64decode
from os.path import splitext
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 serializers
from core import choices, enums, models, utils, validators from core import choices, enums, models, utils
from core.services import mime_types
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,
Converter, YdocConverter,
) )
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."""
@@ -61,6 +38,30 @@ class UserLightSerializer(UserSerializer):
read_only_fields = ["full_name", "short_name"] read_only_fields = ["full_name", "short_name"]
class TemplateAccessSerializer(serializers.ModelSerializer):
"""Serialize template accesses."""
abilities = serializers.SerializerMethodField(read_only=True)
class Meta:
model = models.TemplateAccess
resource_field_name = "template"
fields = ["id", "user", "team", "role", "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."""
@@ -69,7 +70,6 @@ class ListDocumentSerializer(serializers.ModelSerializer):
nb_accesses_direct = serializers.IntegerField(read_only=True) nb_accesses_direct = serializers.IntegerField(read_only=True)
user_role = serializers.SerializerMethodField(read_only=True) user_role = 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
@@ -82,7 +82,6 @@ class ListDocumentSerializer(serializers.ModelSerializer):
"computed_link_role", "computed_link_role",
"created_at", "created_at",
"creator", "creator",
"deleted_at",
"depth", "depth",
"excerpt", "excerpt",
"is_favorite", "is_favorite",
@@ -105,7 +104,6 @@ class ListDocumentSerializer(serializers.ModelSerializer):
"computed_link_role", "computed_link_role",
"created_at", "created_at",
"creator", "creator",
"deleted_at",
"depth", "depth",
"excerpt", "excerpt",
"is_favorite", "is_favorite",
@@ -147,10 +145,6 @@ class ListDocumentSerializer(serializers.ModelSerializer):
request = self.context.get("request") request = self.context.get("request")
return instance.get_role(request.user) if request else None return instance.get_role(request.user) if request else None
def get_deleted_at(self, instance):
"""Return the deleted_at of the current document."""
return instance.ancestors_deleted_at
class DocumentLightSerializer(serializers.ModelSerializer): class DocumentLightSerializer(serializers.ModelSerializer):
"""Minial document serializer for nesting in document accesses.""" """Minial document serializer for nesting in document accesses."""
@@ -166,9 +160,6 @@ class DocumentSerializer(ListDocumentSerializer):
content = serializers.CharField(required=False) content = serializers.CharField(required=False)
websocket = serializers.BooleanField(required=False, write_only=True) websocket = serializers.BooleanField(required=False, write_only=True)
file = serializers.FileField(
required=False, write_only=True, allow_null=True, max_length=255
)
class Meta: class Meta:
model = models.Document model = models.Document
@@ -182,10 +173,8 @@ class DocumentSerializer(ListDocumentSerializer):
"content", "content",
"created_at", "created_at",
"creator", "creator",
"deleted_at",
"depth", "depth",
"excerpt", "excerpt",
"file",
"is_favorite", "is_favorite",
"link_role", "link_role",
"link_reach", "link_reach",
@@ -207,7 +196,6 @@ class DocumentSerializer(ListDocumentSerializer):
"computed_link_role", "computed_link_role",
"created_at", "created_at",
"creator", "creator",
"deleted_at",
"depth", "depth",
"is_favorite", "is_favorite",
"link_role", "link_role",
@@ -255,30 +243,6 @@ class DocumentSerializer(ListDocumentSerializer):
return value return value
def validate_file(self, file):
"""Add file size and type constraints as defined in settings."""
if not file:
return None
# Validate file size
if file.size > settings.CONVERSION_FILE_MAX_SIZE:
max_size = settings.CONVERSION_FILE_MAX_SIZE // (1024 * 1024)
raise serializers.ValidationError(
f"File size exceeds the maximum limit of {max_size:d} MB."
)
_name, extension = splitext(file.name)
if extension.lower() not in settings.CONVERSION_FILE_EXTENSIONS_ALLOWED:
raise serializers.ValidationError(
(
f"File extension {extension} is not allowed. Allowed extensions"
f" are: {settings.CONVERSION_FILE_EXTENSIONS_ALLOWED}."
)
)
return file
def save(self, **kwargs): def save(self, **kwargs):
""" """
Process the content field to extract attachment keys and update the document's Process the content field to extract attachment keys and update the document's
@@ -438,7 +402,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(
@@ -467,9 +431,7 @@ class ServerCreateDocumentSerializer(serializers.Serializer):
language = user.language or language language = user.language or language
try: try:
document_content = Converter().convert( document_content = YdocConverter().convert(validated_data["content"])
validated_data["content"], mime_types.MARKDOWN, mime_types.YJS
)
except ConversionError as err: except ConversionError as err:
raise serializers.ValidationError( raise serializers.ValidationError(
{"content": ["Could not convert content"]} {"content": ["Could not convert content"]}
@@ -524,10 +486,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 = [
@@ -535,58 +493,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):
""" """
@@ -668,6 +574,52 @@ class FileUploadSerializer(serializers.Serializer):
return attrs return attrs
class TemplateSerializer(serializers.ModelSerializer):
"""Serialize templates."""
abilities = serializers.SerializerMethodField(read_only=True)
accesses = TemplateAccessSerializer(many=True, read_only=True)
class Meta:
model = models.Template
fields = [
"id",
"title",
"accesses",
"abilities",
"css",
"code",
"is_public",
]
read_only_fields = ["id", "accesses", "abilities"]
def get_abilities(self, document) -> dict:
"""Return abilities of the logged-in user on the instance."""
request = self.context.get("request")
if request:
return document.get_abilities(request.user)
return {}
# pylint: disable=abstract-method
class DocumentGenerationSerializer(serializers.Serializer):
"""Serializer to receive a request to generate a document on a template."""
body = serializers.CharField(label=_("Body"))
body_type = serializers.ChoiceField(
choices=["html", "markdown"],
label=_("Body type"),
required=False,
default="html",
)
format = serializers.ChoiceField(
choices=["pdf", "docx"],
label=_("Format"),
required=False,
default="pdf",
)
class InvitationSerializer(serializers.ModelSerializer): class InvitationSerializer(serializers.ModelSerializer):
"""Serialize invitations.""" """Serialize invitations."""
@@ -712,9 +664,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):
@@ -749,9 +698,7 @@ class DocumentAskForAccessCreateSerializer(serializers.Serializer):
"""Serializer for creating a document ask for access.""" """Serializer for creating a document ask for access."""
role = serializers.ChoiceField( role = serializers.ChoiceField(
choices=[ choices=models.RoleChoices.choices,
role for role in choices.RoleChoices if role != models.RoleChoices.OWNER
],
required=False, required=False,
default=models.RoleChoices.READER, default=models.RoleChoices.READER,
) )
@@ -775,11 +722,11 @@ class DocumentAskForAccessSerializer(serializers.ModelSerializer):
] ]
read_only_fields = ["id", "document", "user", "role", "created_at", "abilities"] read_only_fields = ["id", "document", "user", "role", "created_at", "abilities"]
def get_abilities(self, instance) -> dict: def get_abilities(self, invitation) -> 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 request: if request:
return instance.get_abilities(request.user) return invitation.get_abilities(request.user)
return {} return {}
@@ -856,132 +803,45 @@ class MoveDocumentSerializer(serializers.Serializer):
) )
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): class CommentSerializer(serializers.ModelSerializer):
"""Serialize comments (nested under a thread) with reactions and abilities.""" """Serialize comments."""
user = UserLightSerializer(read_only=True) user = UserLightSerializer(read_only=True)
abilities = serializers.SerializerMethodField() abilities = serializers.SerializerMethodField(read_only=True)
reactions = ReactionSerializer(many=True, read_only=True)
class Meta: class Meta:
model = models.Comment model = models.Comment
fields = [ fields = [
"id", "id",
"user", "content",
"body",
"created_at", "created_at",
"updated_at", "updated_at",
"reactions", "user",
"document",
"abilities", "abilities",
] ]
read_only_fields = [ read_only_fields = [
"id", "id",
"user",
"created_at", "created_at",
"updated_at", "updated_at",
"reactions", "user",
"document",
"abilities", "abilities",
] ]
def validate(self, attrs): def get_abilities(self, comment) -> dict:
"""Validate comment data.""" """Return abilities of the logged-in user on the instance."""
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") request = self.context.get("request")
if request: if request:
return obj.get_abilities(request.user) return comment.get_abilities(request.user)
return {} 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): def validate(self, attrs):
"""Validate thread data.""" """Validate invitation data."""
request = self.context.get("request") request = self.context.get("request")
user = getattr(request, "user", None) user = getattr(request, "user", None)
attrs["document_id"] = self.context["resource_id"] attrs["document_id"] = self.context["resource_id"]
attrs["creator_id"] = user.id if user else None attrs["user_id"] = user.id if user else None
return attrs 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 {}
class SearchDocumentSerializer(serializers.Serializer):
"""Serializer for fulltext search requests through Find application"""
q = serializers.CharField(required=True, allow_blank=False, trim_whitespace=True)
page_size = serializers.IntegerField(
required=False, min_value=1, max_value=50, default=20
)
page = serializers.IntegerField(required=False, min_value=1, default=1)
-51
View File
@@ -1,51 +0,0 @@
"""Throttling modules for the API."""
from django.conf import settings
from lasuite.drf.throttling import MonitoredScopedRateThrottle
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"
class DocumentThrottle(MonitoredScopedRateThrottle):
"""
Throttle for document-related endpoints, with an exception for requests from the
collaboration server.
"""
scope = "document"
def allow_request(self, request, view):
"""
Override to skip throttling for requests from the collaboration server.
Verifies the X-Y-Provider-Key header contains a valid Y_PROVIDER_API_KEY.
Using a custom header instead of Authorization to avoid triggering
authentication middleware.
"""
y_provider_header = request.headers.get("X-Y-Provider-Key", "")
# Check if this is a valid y-provider request and exempt from throttling
y_provider_key = getattr(settings, "Y_PROVIDER_API_KEY", None)
if y_provider_key and y_provider_header == y_provider_key:
return True
return super().allow_request(request, view)
File diff suppressed because it is too large Load Diff
+7 -15
View File
@@ -1,19 +1,11 @@
"""Impress Core application""" """Impress Core application"""
# from django.apps import AppConfig
from django.apps import AppConfig # from django.utils.translation import gettext_lazy as _
from django.utils.translation import gettext_lazy as _
class CoreConfig(AppConfig): # class CoreConfig(AppConfig):
"""Configuration class for the impress core app.""" # """Configuration class for the impress core app."""
name = "core" # name = "core"
app_label = "core" # app_label = "core"
verbose_name = _("Impress core application") # verbose_name = _("impress core application")
def ready(self):
"""
Import signals when the app is ready.
"""
# pylint: disable=import-outside-toplevel, unused-import
from . import signals # noqa: PLC0415
@@ -6,7 +6,6 @@ import os
from django.conf import settings from django.conf import settings
from django.core.exceptions import SuspiciousOperation from django.core.exceptions import SuspiciousOperation
from lasuite.marketing.tasks import create_or_update_contact
from lasuite.oidc_login.backends import ( from lasuite.oidc_login.backends import (
OIDCAuthenticationBackend as LaSuiteOIDCAuthenticationBackend, OIDCAuthenticationBackend as LaSuiteOIDCAuthenticationBackend,
) )
@@ -58,22 +57,3 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
return self.UserModel.objects.get_user_by_sub_or_email(sub, email) return self.UserModel.objects.get_user_by_sub_or_email(sub, email)
except DuplicateEmailError as err: except DuplicateEmailError as err:
raise SuspiciousOperation(err.message) from err raise SuspiciousOperation(err.message) from err
def post_get_or_create_user(self, user, claims, is_new_user):
"""
Post-processing after user creation or retrieval.
Args:
user (User): The user instance.
claims (dict): The claims dictionary.
is_new_user (bool): Indicates if the user was newly created.
Returns:
- None
"""
if is_new_user and settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL:
create_or_update_contact.delay(
email=user.email, attributes={"DOCS_SOURCE": ["SIGNIN"]}
)
+2 -2
View File
@@ -33,7 +33,7 @@ class LinkRoleChoices(PriorityTextChoices):
"""Defines the possible roles a link can offer on a document.""" """Defines the possible roles a link can offer on a document."""
READER = "reader", _("Reader") # Can read READER = "reader", _("Reader") # Can read
COMMENTER = "commenter", _("Commenter") # Can read and comment COMMENTATOR = "commentator", _("Commentator") # Can read and comment
EDITOR = "editor", _("Editor") # Can read and edit EDITOR = "editor", _("Editor") # Can read and edit
@@ -41,7 +41,7 @@ class RoleChoices(PriorityTextChoices):
"""Defines the possible roles a user can have in a resource.""" """Defines the possible roles a user can have in a resource."""
READER = "reader", _("Reader") # Can read READER = "reader", _("Reader") # Can read
COMMENTER = "commenter", _("Commenter") # Can read and comment COMMENTATOR = "commentator", _("Commentator") # Can read and comment
EDITOR = "editor", _("Editor") # Can read and edit EDITOR = "editor", _("Editor") # Can read and edit
ADMIN = "administrator", _("Administrator") # Can read, edit, delete and share ADMIN = "administrator", _("Administrator") # Can read, edit, delete and share
OWNER = "owner", _("Owner") OWNER = "owner", _("Owner")
+56 -38
View File
@@ -53,6 +53,15 @@ class UserFactory(factory.django.DjangoModelFactory):
if create and (extracted is True): if create and (extracted is True):
UserDocumentAccessFactory(user=self, role="owner") UserDocumentAccessFactory(user=self, role="owner")
@factory.post_generation
def with_owned_template(self, create, extracted, **kwargs):
"""
Create a template for which the user is owner to check
that there is no interference
"""
if create and (extracted is True):
UserTemplateAccessFactory(user=self, role="owner")
class ParentNodeFactory(factory.declarations.ParameteredAttribute): class ParentNodeFactory(factory.declarations.ParameteredAttribute):
"""Custom factory attribute for setting the parent node.""" """Custom factory attribute for setting the parent node."""
@@ -193,6 +202,50 @@ class DocumentAskForAccessFactory(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 TemplateFactory(factory.django.DjangoModelFactory):
"""A factory to create templates"""
class Meta:
model = models.Template
django_get_or_create = ("title",)
skip_postgeneration_save = True
title = factory.Sequence(lambda n: f"template{n}")
is_public = factory.Faker("boolean")
@factory.post_generation
def users(self, create, extracted, **kwargs):
"""Add users to template from a given list of users with or without roles."""
if create and extracted:
for item in extracted:
if isinstance(item, models.User):
UserTemplateAccessFactory(template=self, user=item)
else:
UserTemplateAccessFactory(template=self, user=item[0], role=item[1])
class UserTemplateAccessFactory(factory.django.DjangoModelFactory):
"""Create fake template user accesses for testing."""
class Meta:
model = models.TemplateAccess
template = factory.SubFactory(TemplateFactory)
user = factory.SubFactory(UserFactory)
role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices])
class TeamTemplateAccessFactory(factory.django.DjangoModelFactory):
"""Create fake template team accesses for testing."""
class Meta:
model = models.TemplateAccess
template = factory.SubFactory(TemplateFactory)
team = factory.Sequence(lambda n: f"team{n}")
role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices])
class InvitationFactory(factory.django.DjangoModelFactory): class InvitationFactory(factory.django.DjangoModelFactory):
"""A factory to create invitations for a user""" """A factory to create invitations for a user"""
@@ -205,47 +258,12 @@ class InvitationFactory(factory.django.DjangoModelFactory):
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): class CommentFactory(factory.django.DjangoModelFactory):
"""A factory to create comments for a thread""" """A factory to create comments for a document"""
class Meta: class Meta:
model = models.Comment model = models.Comment
thread = factory.SubFactory(ThreadFactory) document = factory.SubFactory(DocumentFactory)
user = factory.SubFactory(UserFactory) user = factory.SubFactory(UserFactory)
body = factory.Faker("text") content = 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)
@@ -1,150 +0,0 @@
"""Clean a document by resetting it (keeping its title) and deleting all descendants."""
import logging
from django.conf import settings
from django.core.files.storage import default_storage
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from django.db.models import Q
from botocore.exceptions import ClientError
from core.choices import LinkReachChoices, LinkRoleChoices, RoleChoices
from core.models import Document, DocumentAccess, Invitation, Thread
logger = logging.getLogger("impress.commands.clean_document")
class Command(BaseCommand):
"""Reset a document (keeping its title) and delete all its descendants."""
help = __doc__
def add_arguments(self, parser):
"""Define command arguments."""
parser.add_argument(
"document_id",
type=str,
help="UUID of the document to clean",
)
parser.add_argument(
"-f",
"--force",
action="store_true",
default=False,
help="Force command execution despite DEBUG is set to False",
)
parser.add_argument(
"-t",
"--title",
type=str,
default=None,
help="Update the document title to this value",
)
parser.add_argument(
"--link_reach",
type=str,
default=LinkReachChoices.RESTRICTED,
choices=LinkReachChoices,
help="Update the link_reach to this value",
)
parser.add_argument(
"--link_role",
type=str,
default=LinkRoleChoices.READER,
choices=LinkRoleChoices,
help="update the link_role to this value",
)
def handle(self, *args, **options):
"""Execute the clean_document command."""
if not settings.DEBUG and not options["force"]:
raise CommandError(
"This command is not meant to be used in production environment "
"except you know what you are doing, if so use --force parameter"
)
document_id = options["document_id"]
try:
document = Document.objects.get(pk=document_id)
except (Document.DoesNotExist, ValueError) as err:
raise CommandError(f"Document {document_id} does not exist.") from err
descendants = list(document.get_descendants())
descendant_ids = [doc.id for doc in descendants]
all_documents = [document, *descendants]
# Collect all attachment keys before the transaction clears them
all_attachment_keys = []
for doc in all_documents:
all_attachment_keys.extend(doc.attachments)
self.stdout.write(
f"Cleaning document {document_id} and deleting "
f"{len(descendants)} descendant(s)..."
)
with transaction.atomic():
# Clean accesses and invitations on the root document
access_count, _ = DocumentAccess.objects.filter(
Q(document_id=document.id) & ~Q(role=RoleChoices.OWNER)
).delete()
self.stdout.write(f"Deleted {access_count} access(es) on root document.")
invitation_count, _ = Invitation.objects.filter(
document_id=document.id
).delete()
self.stdout.write(
f"Deleted {invitation_count} invitation(s) on root document."
)
thread_count, _ = Thread.objects.filter(document_id=document.id).delete()
self.stdout.write(f"Deleted {thread_count} thread(s) on root document.")
# Reset root document fields
update_fields = {
"excerpt": None,
"link_reach": options["link_reach"],
"link_role": options["link_role"],
"attachments": [],
}
if options["title"] is not None:
update_fields["title"] = options["title"]
Document.objects.filter(id=document.id).update(**update_fields)
if options["title"] is not None:
self.stdout.write(
f'Reset fields on root document (title set to "{options["title"]}").'
)
else:
self.stdout.write("Reset fields on root document (title kept).")
# Delete all descendants (cascades accesses and invitations)
if descendants:
deleted_count, _ = Document.objects.filter(
id__in=descendant_ids
).delete()
self.stdout.write(f"Deleted {deleted_count} descendant(s).")
# Delete S3 content outside the transaction (S3 is not transactional)
s3_client = default_storage.connection.meta.client
bucket = default_storage.bucket_name
for doc in all_documents:
try:
s3_client.delete_object(Bucket=bucket, Key=doc.file_key)
except ClientError:
logger.warning("Failed to delete S3 file for document %s", doc.id)
self.stdout.write(f"Deleted S3 content for {len(all_documents)} document(s).")
for key in all_attachment_keys:
try:
s3_client.delete_object(Bucket=bucket, Key=key)
except ClientError:
logger.warning("Failed to delete S3 attachment %s", key)
self.stdout.write(f"Deleted {len(all_attachment_keys)} attachment(s) from S3.")
self.stdout.write("Done.")
@@ -1,52 +0,0 @@
"""
Handle search setup that needs to be done at bootstrap time.
"""
import logging
import time
from django.core.management.base import BaseCommand, CommandError
from core.services.search_indexers import get_document_indexer
logger = logging.getLogger("docs.search.bootstrap_search")
class Command(BaseCommand):
"""Index all documents to remote search service"""
help = __doc__
def add_arguments(self, parser):
"""Add argument to require forcing execution when not in debug mode."""
parser.add_argument(
"--batch-size",
action="store",
dest="batch_size",
type=int,
default=50,
help="Indexation query batch size",
)
def handle(self, *args, **options):
"""Launch and log search index generation."""
indexer = get_document_indexer()
if not indexer:
raise CommandError("The indexer is not enabled or properly configured.")
logger.info("Starting to regenerate Find index...")
start = time.perf_counter()
batch_size = options["batch_size"]
try:
count = indexer.index(batch_size=batch_size)
except Exception as err:
raise CommandError("Unable to regenerate index") from err
duration = time.perf_counter() - start
logger.info(
"Search index regenerated from %d document(s) in %.2f seconds.",
count,
duration,
)
@@ -2,8 +2,6 @@
from django.db import migrations, models from django.db import migrations, models
import core.validators
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
@@ -35,17 +33,4 @@ class Migration(migrations.Migration):
verbose_name="language", 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",
),
),
] ]
@@ -0,0 +1,146 @@
# Generated by Django 5.2.4 on 2025-08-26 08:11
import uuid
import django.db.models.deletion
from django.conf import settings
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="document",
name="link_role",
field=models.CharField(
choices=[
("reader", "Reader"),
("commentator", "Commentator"),
("editor", "Editor"),
],
default="reader",
max_length=20,
),
),
migrations.AlterField(
model_name="documentaccess",
name="role",
field=models.CharField(
choices=[
("reader", "Reader"),
("commentator", "Commentator"),
("editor", "Editor"),
("administrator", "Administrator"),
("owner", "Owner"),
],
default="reader",
max_length=20,
),
),
migrations.AlterField(
model_name="documentaskforaccess",
name="role",
field=models.CharField(
choices=[
("reader", "Reader"),
("commentator", "Commentator"),
("editor", "Editor"),
("administrator", "Administrator"),
("owner", "Owner"),
],
default="reader",
max_length=20,
),
),
migrations.AlterField(
model_name="invitation",
name="role",
field=models.CharField(
choices=[
("reader", "Reader"),
("commentator", "Commentator"),
("editor", "Editor"),
("administrator", "Administrator"),
("owner", "Owner"),
],
default="reader",
max_length=20,
),
),
migrations.AlterField(
model_name="templateaccess",
name="role",
field=models.CharField(
choices=[
("reader", "Reader"),
("commentator", "Commentator"),
("editor", "Editor"),
("administrator", "Administrator"),
("owner", "Owner"),
],
default="reader",
max_length=20,
),
),
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",
),
),
("content", models.TextField()),
(
"document",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="comments",
to="core.document",
),
),
(
"user",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="comments",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "Comment",
"verbose_name_plural": "Comments",
"db_table": "impress_comment",
"ordering": ("-created_at",),
},
),
]
@@ -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.",
)
],
},
),
]
@@ -1,37 +0,0 @@
# Generated by Django 5.2.8 on 2025-11-20 09:56
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("core", "0026_comments"),
]
operations = [
migrations.RunSQL(
sql="""
CREATE OR REPLACE FUNCTION public.immutable_unaccent(regdictionary, text)
RETURNS text
LANGUAGE c IMMUTABLE PARALLEL SAFE STRICT AS
'$libdir/unaccent', 'unaccent_dict';
CREATE OR REPLACE FUNCTION public.f_unaccent(text)
RETURNS text
LANGUAGE sql IMMUTABLE PARALLEL SAFE STRICT
RETURN public.immutable_unaccent(regdictionary 'public.unaccent', $1);
CREATE INDEX IF NOT EXISTS user_email_unaccent_trgm_idx
ON impress_user
USING gin (f_unaccent(email) gin_trgm_ops);
CREATE INDEX IF NOT EXISTS user_full_name_unaccent_trgm_idx
ON impress_user
USING gin (f_unaccent(full_name) gin_trgm_ops);
""",
reverse_sql="""
DROP INDEX IF EXISTS user_email_unaccent_trgm_idx;
DROP INDEX IF EXISTS user_full_name_unaccent_trgm_idx;
""",
),
]
@@ -1,26 +0,0 @@
# Generated by Django 5.2.9 on 2026-01-09 14:18
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("core", "0027_auto_20251120_0956"),
]
operations = [
migrations.RemoveField(
model_name="templateaccess",
name="template",
),
migrations.RemoveField(
model_name="templateaccess",
name="user",
),
migrations.DeleteModel(
name="Template",
),
migrations.DeleteModel(
name="TemplateAccess",
),
]
@@ -1,178 +0,0 @@
# Generated by Django 5.2.11 on 2026-02-10 15:47
import uuid
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0028_remove_templateaccess_template_and_more"),
]
operations = [
migrations.CreateModel(
name="UserReconciliationCsvImport",
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",
),
),
(
"file",
models.FileField(upload_to="imports/", verbose_name="CSV file"),
),
(
"status",
models.CharField(
choices=[
("pending", "Pending"),
("running", "Running"),
("done", "Done"),
("error", "Error"),
],
default="pending",
max_length=20,
),
),
("logs", models.TextField(blank=True)),
],
options={
"verbose_name": "user reconciliation CSV import",
"verbose_name_plural": "user reconciliation CSV imports",
"db_table": "impress_user_reconciliation_csv_import",
},
),
migrations.CreateModel(
name="UserReconciliation",
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",
),
),
(
"active_email",
models.EmailField(
max_length=254, verbose_name="Active email address"
),
),
(
"inactive_email",
models.EmailField(
max_length=254, verbose_name="Email address to deactivate"
),
),
("active_email_checked", models.BooleanField(default=False)),
("inactive_email_checked", models.BooleanField(default=False)),
(
"active_email_confirmation_id",
models.UUIDField(
default=uuid.uuid4, editable=False, null=True, unique=True
),
),
(
"inactive_email_confirmation_id",
models.UUIDField(
default=uuid.uuid4, editable=False, null=True, unique=True
),
),
(
"source_unique_id",
models.CharField(
blank=True,
max_length=100,
null=True,
verbose_name="Unique ID in the source file",
),
),
(
"status",
models.CharField(
choices=[
("pending", "Pending"),
("ready", "Ready"),
("done", "Done"),
("error", "Error"),
],
default="pending",
max_length=20,
),
),
("logs", models.TextField(blank=True)),
(
"active_user",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="active_user",
to=settings.AUTH_USER_MODEL,
),
),
(
"inactive_user",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="inactive_user",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "user reconciliation",
"verbose_name_plural": "user reconciliations",
"db_table": "impress_user_reconciliation",
"ordering": ["-created_at"],
},
),
]
+236 -619
View File
File diff suppressed because it is too large Load Diff
+2 -6
View File
@@ -3,14 +3,10 @@
from django.conf import settings from django.conf import settings
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from openai import OpenAI
from core import enums from core import enums
if settings.LANGFUSE_PUBLIC_KEY:
from langfuse.openai import OpenAI
else:
from openai import OpenAI
AI_ACTIONS = { AI_ACTIONS = {
"prompt": ( "prompt": (
"Answer the prompt using markdown formatting for structure and emphasis. " "Answer the prompt using markdown formatting for structure and emphasis. "
+18 -118
View File
@@ -1,17 +1,11 @@
"""Y-Provider API services.""" """Converter services."""
import logging
import typing
from base64 import b64encode from base64 import b64encode
from django.conf import settings from django.conf import settings
import requests import requests
from core.services import mime_types
logger = logging.getLogger(__name__)
class ConversionError(Exception): class ConversionError(Exception):
"""Base exception for conversion-related errors.""" """Base exception for conversion-related errors."""
@@ -25,81 +19,8 @@ class ServiceUnavailableError(ConversionError):
"""Raised when the conversion service is unavailable.""" """Raised when the conversion service is unavailable."""
class ConverterProtocol(typing.Protocol):
"""Protocol for converter classes."""
def convert(self, data, content_type, accept):
"""Convert content from one format to another."""
class Converter:
"""Orchestrates conversion between different formats using specialized converters."""
docspec: ConverterProtocol
ydoc: ConverterProtocol
def __init__(self):
self.docspec = DocSpecConverter()
self.ydoc = YdocConverter()
def convert(self, data, content_type, accept):
"""Convert input into other formats using external microservices."""
if content_type == mime_types.DOCX and accept == mime_types.YJS:
blocknote_data = self.docspec.convert(
data, mime_types.DOCX, mime_types.BLOCKNOTE
)
return self.ydoc.convert(
blocknote_data, mime_types.BLOCKNOTE, mime_types.YJS
)
return self.ydoc.convert(data, content_type, accept)
class DocSpecConverter:
"""Service class for DocSpec conversion-related operations."""
def _request(self, url, data, content_type):
"""Make a request to the DocSpec API."""
response = requests.post(
url,
headers={"Accept": mime_types.BLOCKNOTE},
files={"file": ("document.docx", data, content_type)},
timeout=settings.CONVERSION_API_TIMEOUT,
verify=settings.CONVERSION_API_SECURE,
)
if not response.ok:
logger.error(
"DocSpec API error: url=%s, status=%d, response=%s",
url,
response.status_code,
response.text[:200] if response.text else "empty",
)
response.raise_for_status()
return response
def convert(self, data, content_type, accept):
"""Convert a Document to BlockNote."""
if not data:
raise ValidationError("Input data cannot be empty")
if content_type != mime_types.DOCX or accept != mime_types.BLOCKNOTE:
raise ValidationError(
f"Conversion from {content_type} to {accept} is not supported."
)
try:
return self._request(settings.DOCSPEC_API_URL, data, content_type).content
except requests.RequestException as err:
logger.exception("DocSpec service error: url=%s", settings.DOCSPEC_API_URL)
raise ServiceUnavailableError(
"Failed to connect to DocSpec conversion service",
) from err
class YdocConverter: class YdocConverter:
"""Service class for YDoc conversion-related operations.""" """Service class for conversion-related operations."""
@property @property
def auth_header(self): def auth_header(self):
@@ -107,47 +28,26 @@ class YdocConverter:
# 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 f"Bearer {settings.Y_PROVIDER_API_KEY}"
def _request(self, url, data, content_type, accept): def convert(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,
)
if not response.ok:
logger.error(
"Y-Provider API error: url=%s, status=%d, response=%s",
url,
response.status_code,
response.text[:200] if response.text else "empty",
)
response.raise_for_status()
return response
def convert(self, data, content_type=mime_types.MARKDOWN, accept=mime_types.YJS):
"""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 data: if not text:
raise ValidationError("Input data cannot be empty") raise ValidationError("Input text cannot be empty")
url = f"{settings.Y_PROVIDER_API_BASE_URL}{settings.CONVERSION_API_ENDPOINT}/"
try: try:
response = self._request(url, data, content_type, accept) response = requests.post(
if accept == mime_types.YJS: f"{settings.Y_PROVIDER_API_BASE_URL}{settings.CONVERSION_API_ENDPOINT}/",
return b64encode(response.content).decode("utf-8") data=text,
if accept in {mime_types.MARKDOWN, "text/html"}: headers={
return response.text "Authorization": self.auth_header,
if accept == mime_types.JSON: "Content-Type": "text/markdown",
return response.json() },
raise ValidationError("Unsupported format") timeout=settings.CONVERSION_API_TIMEOUT,
verify=settings.CONVERSION_API_SECURE,
)
response.raise_for_status()
return b64encode(response.content).decode("utf-8")
except requests.RequestException as err: except requests.RequestException as err:
logger.exception("Y-Provider service error: url=%s", url)
raise ServiceUnavailableError( raise ServiceUnavailableError(
f"Failed to connect to YDoc conversion service {content_type}, {accept}", "Failed to connect to conversion service",
) from err ) from err
-8
View File
@@ -1,8 +0,0 @@
"""MIME type constants for document conversion."""
BLOCKNOTE = "application/vnd.blocknote+json"
YJS = "application/vnd.yjs.doc"
MARKDOWN = "text/markdown"
JSON = "application/json"
DOCX = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
HTML = "text/html"
@@ -1,298 +0,0 @@
"""Document search index management utilities and indexers"""
import logging
from abc import ABC, abstractmethod
from collections import defaultdict
from functools import cache
from django.conf import settings
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ImproperlyConfigured
from django.db.models import Subquery
from django.utils.module_loading import import_string
import requests
from core import models, utils
logger = logging.getLogger(__name__)
@cache
def get_document_indexer():
"""Returns an instance of indexer service if enabled and properly configured."""
classpath = settings.SEARCH_INDEXER_CLASS
# For this usecase an empty indexer class is not an issue but a feature.
if not classpath:
logger.info("Document indexer is not configured (see SEARCH_INDEXER_CLASS)")
return None
try:
indexer_class = import_string(settings.SEARCH_INDEXER_CLASS)
return indexer_class()
except ImportError as err:
logger.error("SEARCH_INDEXER_CLASS setting is not valid : %s", err)
except ImproperlyConfigured as err:
logger.error("Document indexer is not properly configured : %s", err)
return None
def get_batch_accesses_by_users_and_teams(paths):
"""
Get accesses related to a list of document paths,
grouped by users and teams, including all ancestor paths.
"""
ancestor_map = utils.get_ancestor_to_descendants_map(
paths, steplen=models.Document.steplen
)
ancestor_paths = list(ancestor_map.keys())
access_qs = models.DocumentAccess.objects.filter(
document__path__in=ancestor_paths
).values("document__path", "user__sub", "team")
access_by_document_path = defaultdict(lambda: {"users": set(), "teams": set()})
for access in access_qs:
ancestor_path = access["document__path"]
user_sub = access["user__sub"]
team = access["team"]
for descendant_path in ancestor_map.get(ancestor_path, []):
if user_sub:
access_by_document_path[descendant_path]["users"].add(str(user_sub))
if team:
access_by_document_path[descendant_path]["teams"].add(team)
return dict(access_by_document_path)
def get_visited_document_ids_of(queryset, user):
"""
Returns the ids of the documents that have a linktrace to the user and NOT owned.
It will be use to limit the opensearch responses to the public documents already
"visited" by the user.
"""
if isinstance(user, AnonymousUser):
return []
qs = models.LinkTrace.objects.filter(user=user)
docs = (
queryset.exclude(accesses__user=user)
.filter(
deleted_at__isnull=True,
ancestors_deleted_at__isnull=True,
)
.filter(pk__in=Subquery(qs.values("document_id")))
.order_by("pk")
.distinct("pk")
)
return [str(id) for id in docs.values_list("pk", flat=True)]
class BaseDocumentIndexer(ABC):
"""
Base class for document indexers.
Handles batching and access resolution. Subclasses must implement both
`serialize_document()` and `push()` to define backend-specific behavior.
"""
def __init__(self):
"""
Initialize the indexer.
"""
self.batch_size = settings.SEARCH_INDEXER_BATCH_SIZE
self.indexer_url = settings.SEARCH_INDEXER_URL
self.indexer_secret = settings.SEARCH_INDEXER_SECRET
self.search_url = settings.SEARCH_INDEXER_QUERY_URL
self.search_limit = settings.SEARCH_INDEXER_QUERY_LIMIT
if not self.indexer_url:
raise ImproperlyConfigured(
"SEARCH_INDEXER_URL must be set in Django settings."
)
if not self.indexer_secret:
raise ImproperlyConfigured(
"SEARCH_INDEXER_SECRET must be set in Django settings."
)
if not self.search_url:
raise ImproperlyConfigured(
"SEARCH_INDEXER_QUERY_URL must be set in Django settings."
)
def index(self, queryset=None, batch_size=None):
"""
Fetch documents in batches, serialize them, and push to the search backend.
Args:
queryset (optional): Document queryset
Defaults to all documents without filter.
batch_size (int, optional): Number of documents per batch.
Defaults to settings.SEARCH_INDEXER_BATCH_SIZE.
"""
last_id = 0
count = 0
queryset = queryset or models.Document.objects.all()
batch_size = batch_size or self.batch_size
while True:
documents_batch = list(
queryset.filter(
id__gt=last_id,
).order_by("id")[:batch_size]
)
if not documents_batch:
break
doc_paths = [doc.path for doc in documents_batch]
last_id = documents_batch[-1].id
accesses_by_document_path = get_batch_accesses_by_users_and_teams(doc_paths)
serialized_batch = [
self.serialize_document(document, accesses_by_document_path)
for document in documents_batch
if document.content or document.title
]
if serialized_batch:
self.push(serialized_batch)
count += len(serialized_batch)
return count
@abstractmethod
def serialize_document(self, document, accesses):
"""
Convert a Document instance to a JSON-serializable format for indexing.
Must be implemented by subclasses.
"""
@abstractmethod
def push(self, data):
"""
Push a batch of serialized documents to the backend.
Must be implemented by subclasses.
"""
# pylint: disable-next=too-many-arguments,too-many-positional-arguments
def search(self, text, token, visited=(), nb_results=None):
"""
Search for documents in Find app.
Ensure the same default ordering as "Docs" list : -updated_at
Returns ids of the documents
Args:
text (str): Text search content.
token (str): OIDC Authentication token.
visited (list, optional):
List of ids of active public documents with LinkTrace
Defaults to settings.SEARCH_INDEXER_BATCH_SIZE.
nb_results (int, optional):
The number of results to return.
Defaults to 50 if not specified.
"""
nb_results = nb_results or self.search_limit
response = self.search_query(
data={
"q": text,
"visited": visited,
"services": ["docs"],
"nb_results": nb_results,
"order_by": "updated_at",
"order_direction": "desc",
},
token=token,
)
return [d["_id"] for d in response]
@abstractmethod
def search_query(self, data, token) -> dict:
"""
Retrieve documents from the Find app API.
Must be implemented by subclasses.
"""
class SearchIndexer(BaseDocumentIndexer):
"""
Document indexer that pushes documents to La Suite Find app.
"""
def serialize_document(self, document, accesses):
"""
Convert a Document to the JSON format expected by La Suite Find.
Args:
document (Document): The document instance.
accesses (dict): Mapping of document ID to user/team access.
Returns:
dict: A JSON-serializable dictionary.
"""
doc_path = document.path
doc_content = document.content
text_content = utils.base64_yjs_to_text(doc_content) if doc_content else ""
return {
"id": str(document.id),
"title": document.title or "",
"content": text_content,
"depth": document.depth,
"path": document.path,
"numchild": document.numchild,
"created_at": document.created_at.isoformat(),
"updated_at": document.updated_at.isoformat(),
"users": list(accesses.get(doc_path, {}).get("users", set())),
"groups": list(accesses.get(doc_path, {}).get("teams", set())),
"reach": document.computed_link_reach,
"size": len(text_content.encode("utf-8")),
"is_active": not bool(document.ancestors_deleted_at),
}
def search_query(self, data, token) -> requests.Response:
"""
Retrieve documents from the Find app API.
Args:
data (dict): search data
token (str): OICD token
Returns:
dict: A JSON-serializable dictionary.
"""
response = requests.post(
self.search_url,
json=data,
headers={"Authorization": f"Bearer {token}"},
timeout=10,
)
response.raise_for_status()
return response.json()
def push(self, data):
"""
Push a batch of documents to the Find backend.
Args:
data (list): List of document dictionaries.
"""
response = requests.post(
self.indexer_url,
json=data,
headers={"Authorization": f"Bearer {self.indexer_secret}"},
timeout=10,
)
response.raise_for_status()
-51
View File
@@ -1,51 +0,0 @@
"""
Declare and configure the signals for the impress core application
"""
from functools import partial
from django.core.cache import cache
from django.db import transaction
from django.db.models import signals
from django.dispatch import receiver
from core import models
from core.tasks.search import trigger_batch_document_indexer
from core.utils import get_users_sharing_documents_with_cache_key
@receiver(signals.post_save, sender=models.Document)
def document_post_save(sender, instance, **kwargs): # pylint: disable=unused-argument
"""
Asynchronous call to the document indexer at the end of the transaction.
Note : Within the transaction we can have an empty content and a serialization
error.
"""
transaction.on_commit(partial(trigger_batch_document_indexer, instance))
@receiver(signals.post_save, sender=models.DocumentAccess)
def document_access_post_save(sender, instance, created, **kwargs): # pylint: disable=unused-argument
"""
Asynchronous call to the document indexer at the end of the transaction.
Clear cache for the affected user.
"""
if not created:
transaction.on_commit(
partial(trigger_batch_document_indexer, instance.document)
)
# Invalidate cache for the user
if instance.user:
cache_key = get_users_sharing_documents_with_cache_key(instance.user)
cache.delete(cache_key)
@receiver(signals.post_delete, sender=models.DocumentAccess)
def document_access_post_delete(sender, instance, **kwargs): # pylint: disable=unused-argument
"""
Clear cache for the affected user when document access is deleted.
"""
if instance.user:
cache_key = get_users_sharing_documents_with_cache_key(instance.user)
cache.delete(cache_key)
-95
View File
@@ -1,95 +0,0 @@
"""Trigger document indexation using celery task."""
from logging import getLogger
from django.conf import settings
from django.core.cache import cache
from django.db.models import Q
from django_redis.cache import RedisCache
from core import models
from core.services.search_indexers import (
get_document_indexer,
)
from impress.celery_app import app
logger = getLogger(__file__)
@app.task
def document_indexer_task(document_id):
"""Celery Task : Sends indexation query for a document."""
indexer = get_document_indexer()
if indexer:
logger.info("Start document %s indexation", document_id)
indexer.index(models.Document.objects.filter(pk=document_id))
def batch_indexer_throttle_acquire(timeout: int = 0, atomic: bool = True):
"""
Enable the task throttle flag for a delay.
Uses redis locks if available to ensure atomic changes
"""
key = "document-batch-indexer-throttle"
# Redis is used as cache database (not in tests). Use the lock feature here
# to ensure atomicity of changes to the throttle flag.
if isinstance(cache, RedisCache) and atomic:
with cache.locks(key):
return batch_indexer_throttle_acquire(timeout, atomic=False)
# Use add() here :
# - set the flag and returns true if not exist
# - do nothing and return false if exist
return cache.add(key, 1, timeout=timeout)
@app.task
def batch_document_indexer_task(timestamp):
"""Celery Task : Sends indexation query for a batch of documents."""
indexer = get_document_indexer()
if indexer:
queryset = models.Document.objects.filter(
Q(updated_at__gte=timestamp)
| Q(deleted_at__gte=timestamp)
| Q(ancestors_deleted_at__gte=timestamp)
)
count = indexer.index(queryset)
logger.info("Indexed %d documents", count)
def trigger_batch_document_indexer(item):
"""
Trigger indexation task with debounce a delay set by the SEARCH_INDEXER_COUNTDOWN setting.
Args:
document (Document): The document instance.
"""
countdown = int(settings.SEARCH_INDEXER_COUNTDOWN)
# DO NOT create a task if indexation if disabled
if not settings.SEARCH_INDEXER_CLASS:
return
if countdown > 0:
# Each time this method is called during a countdown, we increment the
# counter and each task decrease it, so the index be run only once.
if batch_indexer_throttle_acquire(timeout=countdown):
logger.info(
"Add task for batch document indexation from updated_at=%s in %d seconds",
item.updated_at.isoformat(),
countdown,
)
batch_document_indexer_task.apply_async(
args=[item.updated_at], countdown=countdown
)
else:
logger.info("Skip task for batch document %s indexation", item.pk)
else:
document_indexer_task.apply(args=[item.pk])
@@ -1,135 +0,0 @@
"""Processing tasks for user reconciliation CSV imports."""
import csv
import traceback
import uuid
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
from django.db import IntegrityError
from botocore.exceptions import ClientError
from core.models import UserReconciliation, UserReconciliationCsvImport
from impress.celery_app import app
def _process_row(row, job, counters):
"""Process a single row from the CSV file."""
source_unique_id = row["id"].strip()
# Skip entries if they already exist with this source_unique_id
if UserReconciliation.objects.filter(source_unique_id=source_unique_id).exists():
counters["already_processed_source_ids"] += 1
return counters
active_email_checked = row.get("active_email_checked", "0") == "1"
inactive_email_checked = row.get("inactive_email_checked", "0") == "1"
active_email = row["active_email"]
inactive_emails = row["inactive_email"].split("|")
try:
validate_email(active_email)
except ValidationError:
job.send_reconciliation_error_email(
recipient_email=inactive_emails[0], other_email=active_email
)
job.logs += f"Invalid active email address on row {source_unique_id}."
counters["rows_with_errors"] += 1
return counters
for inactive_email in inactive_emails:
try:
validate_email(inactive_email)
except (ValidationError, ValueError):
job.send_reconciliation_error_email(
recipient_email=active_email, other_email=inactive_email
)
job.logs += f"Invalid inactive email address on row {source_unique_id}.\n"
counters["rows_with_errors"] += 1
continue
if inactive_email == active_email:
job.send_reconciliation_error_email(
recipient_email=active_email, other_email=inactive_email
)
job.logs += (
f"Error on row {source_unique_id}: "
f"{active_email} set as both active and inactive email.\n"
)
counters["rows_with_errors"] += 1
continue
_rec_entry = UserReconciliation.objects.create(
active_email=active_email,
inactive_email=inactive_email,
active_email_checked=active_email_checked,
inactive_email_checked=inactive_email_checked,
active_email_confirmation_id=uuid.uuid4(),
inactive_email_confirmation_id=uuid.uuid4(),
source_unique_id=source_unique_id,
status="pending",
)
counters["rec_entries_created"] += 1
return counters
@app.task
def user_reconciliation_csv_import_job(job_id):
"""Process a UserReconciliationCsvImport job.
Creates UserReconciliation entries from the CSV file.
Does some sanity checks on the data:
- active_email and inactive_email must be valid email addresses
- active_email and inactive_email cannot be the same
Rows with errors are logged in the job logs and skipped, but do not cause
the entire job to fail or prevent the next rows from being processed.
"""
# Imports the CSV file, breaks it into UserReconciliation items
job = UserReconciliationCsvImport.objects.get(id=job_id)
job.status = "running"
job.save()
counters = {
"rec_entries_created": 0,
"rows_with_errors": 0,
"already_processed_source_ids": 0,
}
try:
with job.file.open(mode="r") as f:
reader = csv.DictReader(f)
if not {"active_email", "inactive_email", "id"}.issubset(reader.fieldnames):
raise KeyError(
"CSV is missing mandatory columns: active_email, inactive_email, id"
)
for row in reader:
counters = _process_row(row, job, counters)
job.status = "done"
job.logs += (
f"Import completed successfully. {reader.line_num} rows processed."
f" {counters['rec_entries_created']} reconciliation entries created."
f" {counters['already_processed_source_ids']} rows were already processed."
f" {counters['rows_with_errors']} rows had errors."
)
except (
csv.Error,
KeyError,
ValidationError,
ValueError,
IntegrityError,
OSError,
ClientError,
) as e:
# Catch expected I/O/CSV/model errors and record traceback in logs for debugging
job.status = "error"
job.logs += f"{e!s}\n{traceback.format_exc()}"
finally:
job.save()
@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html>
<head>
<title>Generate Document</title>
</head>
<body>
<h2>Generate Document</h2>
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_p }}
<button type="submit">Generate PDF</button>
</form>
</body>
</html>
+1 -1
View File
@@ -1,4 +1,4 @@
"""Custom template tags for the core application of Docs.""" """Custom template tags for the core application of People."""
import base64 import base64
@@ -2,9 +2,9 @@
import random import random
import re import re
from unittest import mock
from django.core.exceptions import SuspiciousOperation from django.core.exceptions import SuspiciousOperation
from django.test.utils import override_settings
import pytest import pytest
import responses import responses
@@ -12,10 +12,7 @@ from cryptography.fernet import Fernet
from lasuite.oidc_login.backends import get_oidc_refresh_token from lasuite.oidc_login.backends import get_oidc_refresh_token
from core import models from core import models
from core.authentication.backends import ( from core.authentication.backends import OIDCAuthenticationBackend
OIDCAuthenticationBackend,
create_or_update_contact,
)
from core.factories import UserFactory from core.factories import UserFactory
pytestmark = pytest.mark.django_db pytestmark = pytest.mark.django_db
@@ -60,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
) )
@@ -217,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
) )
@@ -259,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
) )
@@ -322,6 +319,85 @@ def test_authentication_getter_new_user_with_email(monkeypatch):
assert models.User.objects.count() == 1 assert models.User.objects.count() == 1
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
@responses.activate
def test_authentication_get_userinfo_json_response():
"""Test get_userinfo method with a JSON response."""
responses.add(
responses.GET,
re.compile(r".*/userinfo"),
json={
"first_name": "John",
"last_name": "Doe",
"email": "john.doe@example.com",
},
status=200,
)
oidc_backend = OIDCAuthenticationBackend()
result = oidc_backend.get_userinfo("fake_access_token", None, None)
assert result["first_name"] == "John"
assert result["last_name"] == "Doe"
assert result["email"] == "john.doe@example.com"
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
@responses.activate
def test_authentication_get_userinfo_token_response(monkeypatch, settings):
"""Test get_userinfo method with a token response."""
settings.OIDC_RP_SIGN_ALGO = "HS256" # disable JWKS URL call
responses.add(
responses.GET,
re.compile(r".*/userinfo"),
body="fake.jwt.token",
status=200,
content_type="application/jwt",
)
def mock_verify_token(self, token): # pylint: disable=unused-argument
return {
"first_name": "Jane",
"last_name": "Doe",
"email": "jane.doe@example.com",
}
monkeypatch.setattr(OIDCAuthenticationBackend, "verify_token", mock_verify_token)
oidc_backend = OIDCAuthenticationBackend()
result = oidc_backend.get_userinfo("fake_access_token", None, None)
assert result["first_name"] == "Jane"
assert result["last_name"] == "Doe"
assert result["email"] == "jane.doe@example.com"
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
@responses.activate
def test_authentication_get_userinfo_invalid_response(settings):
"""
Test get_userinfo method with an invalid JWT response that
causes verify_token to raise an error.
"""
settings.OIDC_RP_SIGN_ALGO = "HS256" # disable JWKS URL call
responses.add(
responses.GET,
re.compile(r".*/userinfo"),
body="fake.jwt.token",
status=200,
content_type="application/jwt",
)
oidc_backend = OIDCAuthenticationBackend()
with pytest.raises(
SuspiciousOperation,
match="User info response was not valid JWT",
):
oidc_backend.get_userinfo("fake_access_token", None, None)
def test_authentication_getter_existing_disabled_user_via_sub( def test_authentication_getter_existing_disabled_user_via_sub(
django_assert_num_queries, monkeypatch django_assert_num_queries, monkeypatch
): ):
@@ -433,79 +509,3 @@ def test_authentication_session_tokens(
assert user is not None assert user is not None
assert request.session["oidc_access_token"] == "test-access-token" assert request.session["oidc_access_token"] == "test-access-token"
assert get_oidc_refresh_token(request.session) == "test-refresh-token" assert get_oidc_refresh_token(request.session) == "test-refresh-token"
def test_authentication_post_get_or_create_user_new_user_to_marketing_email(settings):
"""
New user and SIGNUP_NEW_USER_TO_MARKETING_EMAIL enabled should create a contact
in the marketing backend.
"""
user = UserFactory()
settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = True
klass = OIDCAuthenticationBackend()
with mock.patch.object(
create_or_update_contact, "delay"
) as mock_create_or_update_contact:
klass.post_get_or_create_user(user, {}, True)
mock_create_or_update_contact.assert_called_once_with(
email=user.email, attributes={"DOCS_SOURCE": ["SIGNIN"]}
)
def test_authentication_post_get_or_create_user_new_user_to_marketing_email_disabled(
settings,
):
"""
New user and SIGNUP_NEW_USER_TO_MARKETING_EMAIL disabled should not create a contact
in the marketing backend.
"""
user = UserFactory()
settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = False
klass = OIDCAuthenticationBackend()
with mock.patch.object(
create_or_update_contact, "delay"
) as mock_create_or_update_contact:
klass.post_get_or_create_user(user, {}, True)
mock_create_or_update_contact.assert_not_called()
def test_authentication_post_get_or_create_user_existing_user_to_marketing_email(
settings,
):
"""
Existing user and SIGNUP_NEW_USER_TO_MARKETING_EMAIL enabled should not create a contact
in the marketing backend.
"""
user = UserFactory()
settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = True
klass = OIDCAuthenticationBackend()
with mock.patch.object(
create_or_update_contact, "delay"
) as mock_create_or_update_contact:
klass.post_get_or_create_user(user, {}, False)
mock_create_or_update_contact.assert_not_called()
def test_authentication_post_get_or_create_user_existing_user_to_marketing_email_disabled(
settings,
):
"""
Existing user and SIGNUP_NEW_USER_TO_MARKETING_EMAIL disabled should not create a contact
in the marketing backend.
"""
user = UserFactory()
settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = False
klass = OIDCAuthenticationBackend()
with mock.patch.object(
create_or_update_contact, "delay"
) as mock_create_or_update_contact:
klass.post_get_or_create_user(user, {}, False)
mock_create_or_update_contact.assert_not_called()
@@ -1,313 +0,0 @@
"""Unit tests for the `clean_document` management command."""
import random
from unittest import mock
from uuid import uuid4
from django.core.management import CommandError, call_command
import pytest
from botocore.exceptions import ClientError
from core import choices, factories, models
from core.choices import LinkReachChoices, LinkRoleChoices
pytestmark = pytest.mark.django_db
def test_clean_document_with_descendants(settings):
"""The command should reset the root (keeping title) and delete descendants."""
settings.DEBUG = True
# Create a root document with subdocuments
root = factories.DocumentFactory(
title="Root",
link_reach=LinkReachChoices.PUBLIC,
link_role=LinkRoleChoices.EDITOR,
)
child = factories.DocumentFactory(
parent=root,
title="Child",
link_reach=LinkReachChoices.AUTHENTICATED,
link_role=LinkRoleChoices.EDITOR,
)
grandchild = factories.DocumentFactory(
parent=child,
title="Grandchild",
)
# Create accesses and invitations
factories.UserDocumentAccessFactory.create_batch(
5,
document=root,
role=random.choice(
[
role
for role in choices.RoleChoices
if role not in choices.PRIVILEGED_ROLES
],
),
)
# One owner role
factories.UserDocumentAccessFactory(document=root, role=choices.RoleChoices.OWNER)
factories.UserDocumentAccessFactory(document=child)
factories.InvitationFactory(document=root)
factories.InvitationFactory(document=child)
factories.ThreadFactory.create_batch(5, document=root)
assert models.Invitation.objects.filter(document=root).exists()
assert models.Thread.objects.filter(document=root).exists()
assert models.DocumentAccess.objects.filter(document=root).exists()
with mock.patch(
"core.management.commands.clean_document.default_storage"
) as mock_storage:
call_command("clean_document", str(root.id), "--force")
# Root document should still exist with title kept and other fields reset
root.refresh_from_db()
assert root.title == "Root"
assert root.excerpt is None
assert root.link_reach == LinkReachChoices.RESTRICTED
assert root.link_role == LinkRoleChoices.READER
assert root.attachments == []
# Accesses and invitations on root should be deleted. Only owner should be kept
keeping_accesses = list(models.DocumentAccess.objects.filter(document=root))
assert len(keeping_accesses) == 1
assert keeping_accesses[0].role == models.RoleChoices.OWNER
assert not models.Invitation.objects.filter(document=root).exists()
assert not models.Thread.objects.filter(document=root).exists()
# Descendants should be deleted entirely
assert not models.Document.objects.filter(id__in=[child.id, grandchild.id]).exists()
# Root should have no descendants
root.refresh_from_db()
assert root.get_descendants().count() == 0
# S3 delete should have been called for document files + attachments
delete_calls = mock_storage.connection.meta.client.delete_object.call_args_list
assert len(delete_calls) == 3
def test_clean_document_invalid_uuid(settings):
"""The command should raise an error for a non-existent document."""
settings.DEBUG = True
fake_id = str(uuid4())
with pytest.raises(CommandError, match=f"Document {fake_id} does not exist."):
call_command("clean_document", fake_id, "--force")
def test_clean_document_no_force_in_production(settings):
"""The command should require --force when DEBUG is False."""
settings.DEBUG = False
doc = factories.DocumentFactory()
with pytest.raises(CommandError, match="not meant to be used in production"):
call_command("clean_document", str(doc.id))
def test_clean_document_single_document(settings):
"""The command should work on a single document without children."""
settings.DEBUG = True
doc = factories.DocumentFactory(
title="Single",
link_reach=LinkReachChoices.PUBLIC,
link_role=LinkRoleChoices.EDITOR,
)
factories.UserDocumentAccessFactory.create_batch(
5,
document=doc,
role=random.choice(
[
role
for role in choices.RoleChoices
if role not in choices.PRIVILEGED_ROLES
],
),
)
# One owner role
factories.UserDocumentAccessFactory(document=doc, role=choices.RoleChoices.OWNER)
factories.ThreadFactory.create_batch(5, document=doc)
factories.InvitationFactory(document=doc)
with mock.patch(
"core.management.commands.clean_document.default_storage"
) as mock_storage:
call_command("clean_document", str(doc.id), "--force")
# Accesses and invitations on root should be deleted. Only owner should be kept
keeping_accesses = list(models.DocumentAccess.objects.filter(document=doc))
assert len(keeping_accesses) == 1
assert keeping_accesses[0].role == models.RoleChoices.OWNER
assert not models.Invitation.objects.filter(document=doc).exists()
assert not models.Thread.objects.filter(document=doc).exists()
doc.refresh_from_db()
assert doc.title == "Single"
assert doc.excerpt is None
assert doc.link_reach == LinkReachChoices.RESTRICTED
assert doc.link_role == LinkRoleChoices.READER
assert doc.attachments == []
mock_storage.connection.meta.client.delete_object.assert_called_once()
def test_clean_document_with_title_option(settings):
"""The --title option should update the document title."""
settings.DEBUG = True
doc = factories.DocumentFactory(
title="Old Title",
link_reach=LinkReachChoices.PUBLIC,
link_role=LinkRoleChoices.EDITOR,
)
with mock.patch("core.management.commands.clean_document.default_storage"):
call_command("clean_document", str(doc.id), "--force", "--title", "New Title")
doc.refresh_from_db()
assert doc.title == "New Title"
assert doc.excerpt is None
assert doc.link_reach == LinkReachChoices.RESTRICTED
assert doc.link_role == LinkRoleChoices.READER
assert doc.attachments == []
def test_clean_document_deletes_attachments_from_s3(settings):
"""The command should delete attachment files from S3."""
settings.DEBUG = True
root = factories.DocumentFactory(
attachments=["root-id/attachments/file1.png", "root-id/attachments/file2.pdf"],
)
child = factories.DocumentFactory(
parent=root,
attachments=["child-id/attachments/file3.png"],
)
with mock.patch(
"core.management.commands.clean_document.default_storage"
) as mock_storage:
call_command("clean_document", str(root.id), "--force")
delete_calls = mock_storage.connection.meta.client.delete_object.call_args_list
deleted_keys = [call.kwargs["Key"] for call in delete_calls]
# Document files (root + child)
assert root.file_key in deleted_keys
assert child.file_key in deleted_keys
# Attachment files
assert "root-id/attachments/file1.png" in deleted_keys
assert "root-id/attachments/file2.pdf" in deleted_keys
assert "child-id/attachments/file3.png" in deleted_keys
assert len(delete_calls) == 5
def test_clean_document_s3_errors_do_not_stop_command(settings):
"""S3 deletion errors should be logged but not stop the command."""
settings.DEBUG = True
doc = factories.DocumentFactory(
attachments=["doc-id/attachments/file1.png"],
)
with mock.patch(
"core.management.commands.clean_document.default_storage"
) as mock_storage:
mock_storage.connection.meta.client.delete_object.side_effect = ClientError(
{"Error": {"Code": "500", "Message": "Internal Error"}},
"DeleteObject",
)
# Command should complete without raising
call_command("clean_document", str(doc.id), "--force")
def test_clean_document_with_options(settings):
"""Run the command using optional argument link_reach and link_role."""
settings.DEBUG = True
# Create a root document with subdocuments
root = factories.DocumentFactory(
title="Root",
link_reach=LinkReachChoices.PUBLIC,
link_role=LinkRoleChoices.READER,
)
child = factories.DocumentFactory(
parent=root,
title="Child",
link_reach=LinkReachChoices.AUTHENTICATED,
link_role=LinkRoleChoices.EDITOR,
)
grandchild = factories.DocumentFactory(
parent=child,
title="Grandchild",
)
# Create accesses and invitations
factories.UserDocumentAccessFactory.create_batch(
5,
document=root,
role=random.choice(
[
role
for role in choices.RoleChoices
if role not in choices.PRIVILEGED_ROLES
],
),
)
# One owner role
factories.UserDocumentAccessFactory(document=root, role=choices.RoleChoices.OWNER)
factories.UserDocumentAccessFactory(document=child)
factories.InvitationFactory(document=root)
factories.InvitationFactory(document=child)
factories.ThreadFactory.create_batch(5, document=root)
assert models.Invitation.objects.filter(document=root).exists()
assert models.Thread.objects.filter(document=root).exists()
assert models.DocumentAccess.objects.filter(document=root).exists()
with mock.patch(
"core.management.commands.clean_document.default_storage"
) as mock_storage:
call_command(
"clean_document",
str(root.id),
"--force",
"--link_reach",
"public",
"--link_role",
"editor",
)
# Root document should still exist with title kept and other fields reset
root.refresh_from_db()
assert root.title == "Root"
assert root.excerpt is None
assert root.link_reach == LinkReachChoices.PUBLIC
assert root.link_role == LinkRoleChoices.EDITOR
assert root.attachments == []
# Accesses and invitations on root should be deleted. Only owner should be kept
keeping_accesses = list(models.DocumentAccess.objects.filter(document=root))
assert len(keeping_accesses) == 1
assert keeping_accesses[0].role == models.RoleChoices.OWNER
assert not models.Invitation.objects.filter(document=root).exists()
assert not models.Thread.objects.filter(document=root).exists()
# Descendants should be deleted entirely
assert not models.Document.objects.filter(id__in=[child.id, grandchild.id]).exists()
# Root should have no descendants
root.refresh_from_db()
assert root.get_descendants().count() == 0
# S3 delete should have been called for document files + attachments
delete_calls = mock_storage.connection.meta.client.delete_object.call_args_list
assert len(delete_calls) == 3
@@ -1,65 +0,0 @@
"""
Unit test for `index` command.
"""
from operator import itemgetter
from unittest import mock
from django.core.management import CommandError, call_command
from django.db import transaction
import pytest
from core import factories
from core.services.search_indexers import SearchIndexer
@pytest.mark.django_db
@pytest.mark.usefixtures("indexer_settings")
def test_index():
"""Test the command `index` that run the Find app indexer for all the available documents."""
user = factories.UserFactory()
indexer = SearchIndexer()
with transaction.atomic():
doc = factories.DocumentFactory()
empty_doc = factories.DocumentFactory(title=None, content="")
no_title_doc = factories.DocumentFactory(title=None)
factories.UserDocumentAccessFactory(document=doc, user=user)
factories.UserDocumentAccessFactory(document=empty_doc, user=user)
factories.UserDocumentAccessFactory(document=no_title_doc, user=user)
accesses = {
str(doc.path): {"users": [user.sub]},
str(empty_doc.path): {"users": [user.sub]},
str(no_title_doc.path): {"users": [user.sub]},
}
with mock.patch.object(SearchIndexer, "push") as mock_push:
call_command("index")
push_call_args = [call.args[0] for call in mock_push.call_args_list]
# called once but with a batch of docs
mock_push.assert_called_once()
assert sorted(push_call_args[0], key=itemgetter("id")) == sorted(
[
indexer.serialize_document(doc, accesses),
indexer.serialize_document(no_title_doc, accesses),
],
key=itemgetter("id"),
)
@pytest.mark.django_db
@pytest.mark.usefixtures("indexer_settings")
def test_index_improperly_configured(indexer_settings):
"""The command should raise an exception if the indexer is not configured"""
indexer_settings.SEARCH_INDEXER_CLASS = None
with pytest.raises(CommandError) as err:
call_command("index")
assert str(err.value) == "The indexer is not enabled or properly configured."
-27
View File
@@ -24,30 +24,3 @@ def mock_user_teams():
"core.models.User.teams", new_callable=mock.PropertyMock "core.models.User.teams", new_callable=mock.PropertyMock
) as mock_teams: ) as mock_teams:
yield mock_teams yield mock_teams
@pytest.fixture(name="indexer_settings")
def indexer_settings_fixture(settings):
"""
Setup valid settings for the document indexer. Clear the indexer cache.
"""
# pylint: disable-next=import-outside-toplevel
from core.services.search_indexers import ( # noqa: PLC0415
get_document_indexer,
)
get_document_indexer.cache_clear()
settings.SEARCH_INDEXER_CLASS = "core.services.search_indexers.SearchIndexer"
settings.SEARCH_INDEXER_SECRET = "ThisIsAKeyForTest"
settings.SEARCH_INDEXER_URL = "http://localhost:8081/api/v1.0/documents/index/"
settings.SEARCH_INDEXER_QUERY_URL = (
"http://localhost:8081/api/v1.0/documents/search/"
)
settings.SEARCH_INDEXER_COUNTDOWN = 1
yield settings
# clear cache to prevent issues with other tests
get_document_indexer.cache_clear()
@@ -1,6 +0,0 @@
active_email,inactive_email,active_email_checked,inactive_email_checked,status,id
"user.test40@example.com","user.test41@example.com",0,0,pending,1
"user.test42@example.com","user.test43@example.com",0,1,pending,2
"user.test44@example.com","user.test45@example.com",1,0,pending,3
"user.test46@example.com","user.test47@example.com",1,1,pending,4
"user.test48@example.com","user.test49@example.com",1,1,pending,5
1 active_email inactive_email active_email_checked inactive_email_checked status id
2 user.test40@example.com user.test41@example.com 0 0 pending 1
3 user.test42@example.com user.test43@example.com 0 1 pending 2
4 user.test44@example.com user.test45@example.com 1 0 pending 3
5 user.test46@example.com user.test47@example.com 1 1 pending 4
6 user.test48@example.com user.test49@example.com 1 1 pending 5
@@ -1,2 +0,0 @@
active_email,inactive_email,active_email_checked,inactive_email_checked,status,id
"user.test40@example.com",,0,0,pending,40
1 active_email inactive_email active_email_checked inactive_email_checked status id
2 user.test40@example.com 0 0 pending 40
@@ -1,5 +0,0 @@
merge_accept,active_email,inactive_email,status,id
true,user.test10@example.com,user.test11@example.com|user.test12@example.com,pending,10
true,user.test30@example.com,user.test31@example.com|user.test32@example.com|user.test33@example.com|user.test34@example.com|user.test35@example.com,pending,11
true,user.test20@example.com,user.test21@example.com,pending,12
true,user.test22@example.com,user.test23@example.com,pending,13
1 merge_accept active_email inactive_email status id
2 true user.test10@example.com user.test11@example.com|user.test12@example.com pending 10
3 true user.test30@example.com user.test31@example.com|user.test32@example.com|user.test33@example.com|user.test34@example.com|user.test35@example.com pending 11
4 true user.test20@example.com user.test21@example.com pending 12
5 true user.test22@example.com user.test23@example.com pending 13
@@ -1,2 +0,0 @@
merge_accept,active_email,inactive_email,status,id
true,user.test20@example.com,user.test20@example.com,pending,20
1 merge_accept active_email inactive_email status id
2 true user.test20@example.com user.test20@example.com pending 20
@@ -1,6 +0,0 @@
active_email,inactive_email,active_email_checked,inactive_email_checked,status
"user.test40@example.com","user.test41@example.com",0,0,pending
"user.test42@example.com","user.test43@example.com",0,1,pending
"user.test44@example.com","user.test45@example.com",1,0,pending
"user.test46@example.com","user.test47@example.com",1,1,pending
"user.test48@example.com","user.test49@example.com",1,1,pending
1 active_email inactive_email active_email_checked inactive_email_checked status
2 user.test40@example.com user.test41@example.com 0 0 pending
3 user.test42@example.com user.test43@example.com 0 1 pending
4 user.test44@example.com user.test45@example.com 1 0 pending
5 user.test46@example.com user.test47@example.com 1 1 pending
6 user.test48@example.com user.test49@example.com 1 1 pending
@@ -4,7 +4,6 @@ Test document accesses API endpoints for users in impress's core app.
# pylint: disable=too-many-lines # 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
@@ -293,7 +292,7 @@ def test_api_document_accesses_retrieve_set_role_to_child():
} }
assert result_dict[str(document_access_other_user.id)] == [ assert result_dict[str(document_access_other_user.id)] == [
"reader", "reader",
"commenter", "commentator",
"editor", "editor",
"administrator", "administrator",
"owner", "owner",
@@ -302,7 +301,7 @@ def test_api_document_accesses_retrieve_set_role_to_child():
# Add an access for the other user on the parent # Add an access for the other user on the parent
parent_access_other_user = factories.UserDocumentAccessFactory( parent_access_other_user = factories.UserDocumentAccessFactory(
document=parent, user=other_user, role="commenter" document=parent, user=other_user, role="commentator"
) )
response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/") response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/")
@@ -315,7 +314,7 @@ def test_api_document_accesses_retrieve_set_role_to_child():
result["id"]: result["abilities"]["set_role_to"] for result in content result["id"]: result["abilities"]["set_role_to"] for result in content
} }
assert result_dict[str(document_access_other_user.id)] == [ assert result_dict[str(document_access_other_user.id)] == [
"commenter", "commentator",
"editor", "editor",
"administrator", "administrator",
"owner", "owner",
@@ -323,7 +322,7 @@ def test_api_document_accesses_retrieve_set_role_to_child():
assert result_dict[str(parent_access.id)] == [] assert result_dict[str(parent_access.id)] == []
assert result_dict[str(parent_access_other_user.id)] == [ assert result_dict[str(parent_access_other_user.id)] == [
"reader", "reader",
"commenter", "commentator",
"editor", "editor",
"administrator", "administrator",
"owner", "owner",
@@ -336,28 +335,28 @@ def test_api_document_accesses_retrieve_set_role_to_child():
[ [
["administrator", "reader", "reader", "reader"], ["administrator", "reader", "reader", "reader"],
[ [
["reader", "commenter", "editor", "administrator"], ["reader", "commentator", "editor", "administrator"],
[], [],
[], [],
["reader", "commenter", "editor", "administrator"], ["reader", "commentator", "editor", "administrator"],
], ],
], ],
[ [
["owner", "reader", "reader", "reader"], ["owner", "reader", "reader", "reader"],
[ [
["reader", "commenter", "editor", "administrator", "owner"], ["reader", "commentator", "editor", "administrator", "owner"],
[], [],
[], [],
["reader", "commenter", "editor", "administrator", "owner"], ["reader", "commentator", "editor", "administrator", "owner"],
], ],
], ],
[ [
["owner", "reader", "reader", "owner"], ["owner", "reader", "reader", "owner"],
[ [
["reader", "commenter", "editor", "administrator", "owner"], ["reader", "commentator", "editor", "administrator", "owner"],
[], [],
[], [],
["reader", "commenter", "editor", "administrator", "owner"], ["reader", "commentator", "editor", "administrator", "owner"],
], ],
], ],
], ],
@@ -418,44 +417,44 @@ def test_api_document_accesses_list_authenticated_related_same_user(roles, resul
[ [
["administrator", "reader", "reader", "reader"], ["administrator", "reader", "reader", "reader"],
[ [
["reader", "commenter", "editor", "administrator"], ["reader", "commentator", "editor", "administrator"],
[], [],
[], [],
["reader", "commenter", "editor", "administrator"], ["reader", "commentator", "editor", "administrator"],
], ],
], ],
[ [
["owner", "reader", "reader", "reader"], ["owner", "reader", "reader", "reader"],
[ [
["reader", "commenter", "editor", "administrator", "owner"], ["reader", "commentator", "editor", "administrator", "owner"],
[], [],
[], [],
["reader", "commenter", "editor", "administrator", "owner"], ["reader", "commentator", "editor", "administrator", "owner"],
], ],
], ],
[ [
["owner", "reader", "reader", "owner"], ["owner", "reader", "reader", "owner"],
[ [
["reader", "commenter", "editor", "administrator", "owner"], ["reader", "commentator", "editor", "administrator", "owner"],
[], [],
[], [],
["reader", "commenter", "editor", "administrator", "owner"], ["reader", "commentator", "editor", "administrator", "owner"],
], ],
], ],
[ [
["reader", "reader", "reader", "owner"], ["reader", "reader", "reader", "owner"],
[ [
["reader", "commenter", "editor", "administrator", "owner"], ["reader", "commentator", "editor", "administrator", "owner"],
[], [],
[], [],
["reader", "commenter", "editor", "administrator", "owner"], ["reader", "commentator", "editor", "administrator", "owner"],
], ],
], ],
[ [
["reader", "administrator", "reader", "editor"], ["reader", "administrator", "reader", "editor"],
[ [
["reader", "commenter", "editor", "administrator"], ["reader", "commentator", "editor", "administrator"],
["reader", "commenter", "editor", "administrator"], ["reader", "commentator", "editor", "administrator"],
[], [],
[], [],
], ],
@@ -463,7 +462,7 @@ def test_api_document_accesses_list_authenticated_related_same_user(roles, resul
[ [
["editor", "editor", "administrator", "editor"], ["editor", "editor", "administrator", "editor"],
[ [
["reader", "commenter", "editor", "administrator"], ["reader", "commentator", "editor", "administrator"],
[], [],
["editor", "administrator"], ["editor", "administrator"],
[], [],
@@ -1348,24 +1347,3 @@ def test_api_document_accesses_delete_owners_last_owner_child_team(
assert response.status_code == 204 assert response.status_code == 204
assert models.DocumentAccess.objects.count() == 1 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"
)
@@ -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
@@ -1,427 +0,0 @@
"""
Tests for Documents API endpoint in impress's core app: all
The 'all' endpoint returns ALL documents (including descendants) that the user has access to.
This is different from the 'list' endpoint which only returns top-level documents.
"""
from datetime import timedelta
from unittest import mock
from django.utils import timezone
import pytest
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
@pytest.mark.parametrize("role", models.LinkRoleChoices.values)
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
def test_api_documents_all_anonymous(reach, role):
"""
Anonymous users should not be able to list any documents via the all endpoint
whatever the link reach and link role.
"""
parent = factories.DocumentFactory(link_reach=reach, link_role=role)
factories.DocumentFactory(parent=parent, link_reach=reach, link_role=role)
response = APIClient().get("/api/v1.0/documents/all/")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 0
def test_api_documents_all_authenticated_with_children():
"""
Authenticated users should see all documents including children,
even though children don't have DocumentAccess records.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Create a document tree: parent -> child -> grandchild
parent = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=parent, user=user, role="owner")
child = factories.DocumentFactory(parent=parent)
grandchild = factories.DocumentFactory(parent=child)
# Verify setup
assert models.DocumentAccess.objects.filter(document=parent).count() == 1
assert models.DocumentAccess.objects.filter(document=child).count() == 0
assert models.DocumentAccess.objects.filter(document=grandchild).count() == 0
response = client.get("/api/v1.0/documents/all/")
assert response.status_code == 200
results = response.json()["results"]
# All three documents should be returned (parent + child + grandchild)
assert len(results) == 3
results_ids = {result["id"] for result in results}
assert results_ids == {str(parent.id), str(child.id), str(grandchild.id)}
depths = {result["depth"] for result in results}
assert depths == {1, 2, 3}
def test_api_documents_all_authenticated_multiple_trees():
"""
Users should see all accessible documents from multiple document trees.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Tree 1: User has access
tree1_parent = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=tree1_parent, user=user)
tree1_child = factories.DocumentFactory(parent=tree1_parent)
# Tree 2: User has access
tree2_parent = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=tree2_parent, user=user)
tree2_child1 = factories.DocumentFactory(parent=tree2_parent)
tree2_child2 = factories.DocumentFactory(parent=tree2_parent)
# Tree 3: User does NOT have access
tree3_parent = factories.DocumentFactory()
factories.DocumentFactory(parent=tree3_parent)
response = client.get("/api/v1.0/documents/all/")
assert response.status_code == 200
results = response.json()["results"]
# Should return 5 documents (tree1: 2, tree2: 3, tree3: 0)
assert len(results) == 5
results_ids = {result["id"] for result in results}
expected_ids = {
str(tree1_parent.id),
str(tree1_child.id),
str(tree2_parent.id),
str(tree2_child1.id),
str(tree2_child2.id),
}
assert results_ids == expected_ids
def test_api_documents_all_authenticated_explicit_access_to_parent_and_child():
"""
When a user has explicit DocumentAccess to both parent AND child,
both should appear in the 'all' endpoint results (unlike 'list' which deduplicates).
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Parent with explicit access
parent = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=parent, user=user)
# Child also has explicit access (e.g., shared separately)
child = factories.DocumentFactory(parent=parent)
factories.UserDocumentAccessFactory(document=child, user=user)
# Grandchild has no explicit access
grandchild = factories.DocumentFactory(parent=child)
# Verify setup
assert models.DocumentAccess.objects.filter(document=parent).count() == 1
assert models.DocumentAccess.objects.filter(document=child).count() == 1
assert models.DocumentAccess.objects.filter(document=grandchild).count() == 0
response = client.get("/api/v1.0/documents/all/")
assert response.status_code == 200
results = response.json()["results"]
# All three should appear
assert len(results) == 3
results_ids = {result["id"] for result in results}
assert results_ids == {str(parent.id), str(child.id), str(grandchild.id)}
# Each document should appear exactly once (no duplicates)
results_ids_list = [result["id"] for result in results]
assert len(results_ids_list) == len(set(results_ids_list)) # No duplicates
def test_api_documents_all_authenticated_via_team(mock_user_teams):
"""
Users should see all documents (including descendants) for documents accessed via teams.
"""
mock_user_teams.return_value = ["team1", "team2"]
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Document tree via team1
parent1 = factories.DocumentFactory()
factories.TeamDocumentAccessFactory(document=parent1, team="team1")
child1 = factories.DocumentFactory(parent=parent1)
# Document tree via team2
parent2 = factories.DocumentFactory()
factories.TeamDocumentAccessFactory(document=parent2, team="team2")
child2 = factories.DocumentFactory(parent=parent2)
# Document tree via unknown team
parent3 = factories.DocumentFactory()
factories.TeamDocumentAccessFactory(document=parent3, team="team3")
factories.DocumentFactory(parent=parent3)
response = client.get("/api/v1.0/documents/all/")
assert response.status_code == 200
results = response.json()["results"]
# Should return 4 documents (team1: 2, team2: 2, team3: 0)
assert len(results) == 4
results_ids = {result["id"] for result in results}
expected_ids = {
str(parent1.id),
str(child1.id),
str(parent2.id),
str(child2.id),
}
assert results_ids == expected_ids
def test_api_documents_all_authenticated_soft_deleted():
"""
Soft-deleted documents and their descendants should not be included.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Active tree
active_parent = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=active_parent, user=user)
active_child = factories.DocumentFactory(parent=active_parent)
# Soft-deleted tree
deleted_parent = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=deleted_parent, user=user)
_deleted_child = factories.DocumentFactory(parent=deleted_parent)
deleted_parent.soft_delete()
response = client.get("/api/v1.0/documents/all/")
assert response.status_code == 200
results = response.json()["results"]
# Should only return active documents
assert len(results) == 2
results_ids = {result["id"] for result in results}
assert results_ids == {str(active_parent.id), str(active_child.id)}
def test_api_documents_all_authenticated_permanently_deleted():
"""
Permanently deleted documents should not be included.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Active tree
active_parent = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=active_parent, user=user)
active_child = factories.DocumentFactory(parent=active_parent)
# Permanently deleted tree (deleted > 30 days ago)
deleted_parent = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=deleted_parent, user=user)
_deleted_child = factories.DocumentFactory(parent=deleted_parent)
fourty_days_ago = timezone.now() - timedelta(days=40)
with mock.patch("django.utils.timezone.now", return_value=fourty_days_ago):
deleted_parent.soft_delete()
response = client.get("/api/v1.0/documents/all/")
assert response.status_code == 200
results = response.json()["results"]
# Should only return active documents
assert len(results) == 2
results_ids = {result["id"] for result in results}
assert results_ids == {str(active_parent.id), str(active_child.id)}
def test_api_documents_all_authenticated_link_reach_restricted():
"""
Documents with link_reach=restricted accessed via LinkTrace should not appear
in the all endpoint results.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Document with direct access (should appear)
parent_with_access = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=parent_with_access, user=user)
child_with_access = factories.DocumentFactory(parent=parent_with_access)
# Document with only LinkTrace and restricted reach (should NOT appear)
parent_restricted = factories.DocumentFactory(
link_reach="restricted", link_traces=[user]
)
factories.DocumentFactory(parent=parent_restricted)
response = client.get("/api/v1.0/documents/all/")
assert response.status_code == 200
results = response.json()["results"]
# Only documents with direct access should appear
assert len(results) == 2
results_ids = {result["id"] for result in results}
assert results_ids == {str(parent_with_access.id), str(child_with_access.id)}
@pytest.mark.parametrize("reach", ["public", "authenticated"])
def test_api_documents_all_authenticated_link_reach_public_or_authenticated(reach):
"""
Documents with link_reach=public or authenticated accessed via LinkTrace
should appear with all their descendants.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Document accessed via LinkTrace with non-restricted reach
parent = factories.DocumentFactory(link_reach=reach, link_traces=[user])
child = factories.DocumentFactory(parent=parent)
grandchild = factories.DocumentFactory(parent=child)
response = client.get("/api/v1.0/documents/all/")
assert response.status_code == 200
results = response.json()["results"]
# All descendants should be included
assert len(results) == 3
results_ids = {result["id"] for result in results}
assert results_ids == {str(parent.id), str(child.id), str(grandchild.id)}
def test_api_documents_all_format():
"""Validate the format of documents as returned by the all endpoint."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
access = factories.UserDocumentAccessFactory(document=document, user=user)
child = factories.DocumentFactory(parent=document)
response = client.get("/api/v1.0/documents/all/")
assert response.status_code == 200
content = response.json()
results = content.pop("results")
# Check pagination structure
assert content == {
"count": 2,
"next": None,
"previous": None,
}
# Verify parent document format
parent_result = [r for r in results if r["id"] == str(document.id)][0]
assert parent_result == {
"id": str(document.id),
"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"),
"creator": str(document.creator.id),
"deleted_at": None,
"depth": 1,
"excerpt": document.excerpt,
"is_favorite": False,
"link_reach": document.link_reach,
"link_role": document.link_role,
"nb_accesses_ancestors": 1,
"nb_accesses_direct": 1,
"numchild": 1,
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": access.role,
}
# Verify child document format
child_result = [r for r in results if r["id"] == str(child.id)][0]
assert child_result["depth"] == 2
assert child_result["user_role"] == access.role # Inherited from parent
assert child_result["nb_accesses_direct"] == 0 # No direct access on child
def test_api_documents_all_distinct():
"""
A document should only appear once even if the user has multiple access paths to it.
"""
user = factories.UserFactory()
other_user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Document with multiple accesses for the same user
document = factories.DocumentFactory(users=[user, other_user])
child = factories.DocumentFactory(parent=document)
response = client.get("/api/v1.0/documents/all/")
assert response.status_code == 200
results = response.json()["results"]
# Should return 2 documents (parent + child), each appearing once
assert len(results) == 2
results_ids = [result["id"] for result in results]
assert results_ids.count(str(document.id)) == 1
assert results_ids.count(str(child.id)) == 1
def test_api_documents_all_comparison_with_list():
"""
The 'all' endpoint should return more documents than 'list' when there are children.
'list' returns only top-level documents, 'all' returns all descendants.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Create a document tree
parent = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=parent, user=user)
child = factories.DocumentFactory(parent=parent)
grandchild = factories.DocumentFactory(parent=child)
# Call list endpoint
list_response = client.get("/api/v1.0/documents/")
list_results = list_response.json()["results"]
# Call all endpoint
all_response = client.get("/api/v1.0/documents/all/")
all_results = all_response.json()["results"]
# list should return only parent
assert len(list_results) == 1
assert list_results[0]["id"] == str(parent.id)
# all should return parent + child + grandchild
assert len(all_results) == 3
all_ids = {result["id"] for result in all_results}
assert all_ids == {str(parent.id), str(child.id), str(grandchild.id)}
@@ -1,7 +1,6 @@
"""Test API for document ask for access.""" """Test API for document ask for access."""
import uuid import uuid
from unittest import mock
from django.core import mail from django.core import mail
@@ -115,10 +114,7 @@ def test_api_documents_ask_for_access_create_authenticated_non_root_document():
assert response.status_code == 404 assert response.status_code == 404
@pytest.mark.parametrize( def test_api_documents_ask_for_access_create_authenticated_specific_role():
"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. Authenticated users should be able to create a document ask for access with a specific role.
""" """
@@ -130,35 +126,17 @@ def test_api_documents_ask_for_access_create_authenticated_specific_role(role):
response = client.post( response = client.post(
f"/api/v1.0/documents/{document.id}/ask-for-access/", f"/api/v1.0/documents/{document.id}/ask-for-access/",
data={"role": role}, data={"role": RoleChoices.EDITOR},
) )
assert response.status_code == 201 assert response.status_code == 201
assert DocumentAskForAccess.objects.filter( assert DocumentAskForAccess.objects.filter(
document=document, document=document,
user=user, user=user,
role=role, role=RoleChoices.EDITOR,
).exists() ).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(): 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.""" """Authenticated users with existing access can ask for access with a different role."""
user = UserFactory() user = UserFactory()
@@ -287,7 +265,6 @@ def test_api_documents_ask_for_access_list_authenticated_own_request():
"update": False, "update": False,
"partial_update": False, "partial_update": False,
"retrieve": False, "retrieve": False,
"set_role_to": [],
}, },
} }
], ],
@@ -357,16 +334,6 @@ def test_api_documents_ask_for_access_list_owner_or_admin(role):
response = client.get(f"/api/v1.0/documents/{document.id}/ask-for-access/") response = client.get(f"/api/v1.0/documents/{document.id}/ask-for-access/")
assert response.status_code == 200 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() == { assert response.json() == {
"count": 3, "count": 3,
"next": None, "next": None,
@@ -386,7 +353,6 @@ def test_api_documents_ask_for_access_list_owner_or_admin(role):
"update": True, "update": True,
"partial_update": True, "partial_update": True,
"retrieve": True, "retrieve": True,
"set_role_to": expected_set_role_to,
}, },
} }
for document_ask_for_access in document_ask_for_accesses for document_ask_for_access in document_ask_for_accesses
@@ -479,14 +445,6 @@ def test_api_documents_ask_for_access_retrieve_owner_or_admin(role):
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/" f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/"
) )
assert response.status_code == 200 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() == { assert response.json() == {
"id": str(document_ask_for_access.id), "id": str(document_ask_for_access.id),
"document": str(document.id), "document": str(document.id),
@@ -501,7 +459,6 @@ def test_api_documents_ask_for_access_retrieve_owner_or_admin(role):
"update": True, "update": True,
"partial_update": True, "partial_update": True,
"retrieve": True, "retrieve": True,
"set_role_to": expected_set_role_to,
}, },
} }
@@ -791,53 +748,6 @@ def test_api_documents_ask_for_access_accept_authenticated_owner_or_admin_update
assert document_access.role == RoleChoices.ADMIN 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]) @pytest.mark.parametrize("role", [RoleChoices.OWNER, RoleChoices.ADMIN])
def test_api_documents_ask_for_access_accept_authenticated_non_root_document(role): def test_api_documents_ask_for_access_accept_authenticated_non_root_document(role):
""" """
@@ -858,35 +768,3 @@ def test_api_documents_ask_for_access_accept_authenticated_non_root_document(rol
f"/api/v1.0/documents/{child.id}/ask-for-access/{document_ask_for_access.id}/accept/" f"/api/v1.0/documents/{child.id}/ask-for-access/{document_ask_for_access.id}/accept/"
) )
assert response.status_code == 404 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
)
@@ -41,7 +41,6 @@ def test_api_documents_children_list_anonymous_public_standalone(
"computed_link_role": child1.computed_link_role, "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),
@@ -64,7 +63,6 @@ def test_api_documents_children_list_anonymous_public_standalone(
"computed_link_role": child2.computed_link_role, "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),
@@ -117,7 +115,6 @@ def test_api_documents_children_list_anonymous_public_parent(django_assert_num_q
"computed_link_role": child1.computed_link_role, "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),
@@ -140,7 +137,6 @@ def test_api_documents_children_list_anonymous_public_parent(django_assert_num_q
"computed_link_role": child2.computed_link_role, "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),
@@ -212,7 +208,6 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic
"computed_link_role": child1.computed_link_role, "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),
@@ -235,7 +230,6 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic
"computed_link_role": child2.computed_link_role, "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),
@@ -293,7 +287,6 @@ def test_api_documents_children_list_authenticated_public_or_authenticated_paren
"computed_link_role": child1.computed_link_role, "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),
@@ -316,7 +309,6 @@ def test_api_documents_children_list_authenticated_public_or_authenticated_paren
"computed_link_role": child2.computed_link_role, "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),
@@ -401,7 +393,6 @@ def test_api_documents_children_list_authenticated_related_direct(
"computed_link_role": child1.computed_link_role, "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),
@@ -424,7 +415,6 @@ def test_api_documents_children_list_authenticated_related_direct(
"computed_link_role": child2.computed_link_role, "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),
@@ -485,7 +475,6 @@ def test_api_documents_children_list_authenticated_related_parent(
"computed_link_role": child1.computed_link_role, "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),
@@ -508,7 +497,6 @@ def test_api_documents_children_list_authenticated_related_parent(
"computed_link_role": child2.computed_link_role, "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),
@@ -621,7 +609,6 @@ def test_api_documents_children_list_authenticated_related_team_members(
"computed_link_role": child1.computed_link_role, "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),
@@ -644,7 +631,6 @@ def test_api_documents_children_list_authenticated_related_team_members(
"computed_link_role": child2.computed_link_role, "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),
@@ -17,45 +17,42 @@ pytestmark = pytest.mark.django_db
def test_list_comments_anonymous_user_public_document(): def test_list_comments_anonymous_user_public_document():
"""Anonymous users should be allowed to list comments on a public document.""" """Anonymous users should be allowed to list comments on a public document."""
document = factories.DocumentFactory( document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.COMMENTER link_reach="public", link_role=models.LinkRoleChoices.COMMENTATOR
) )
thread = factories.ThreadFactory(document=document) comment1, comment2 = factories.CommentFactory.create_batch(2, document=document)
comment1, comment2 = factories.CommentFactory.create_batch(2, thread=thread)
# other comments not linked to the document # other comments not linked to the document
factories.CommentFactory.create_batch(2) factories.CommentFactory.create_batch(2)
response = APIClient().get( response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/comments/")
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/"
)
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == { assert response.json() == {
"count": 2, "count": 2,
"next": None, "next": None,
"previous": None, "previous": None,
"results": [ "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), "id": str(comment2.id),
"body": comment2.body, "content": comment2.content,
"created_at": comment2.created_at.isoformat().replace("+00:00", "Z"), "created_at": comment2.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": comment2.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": comment2.updated_at.isoformat().replace("+00:00", "Z"),
"user": { "user": {
"full_name": comment2.user.full_name, "full_name": comment2.user.full_name,
"short_name": comment2.user.short_name, "short_name": comment2.user.short_name,
}, },
"document": str(comment2.document.id),
"abilities": comment2.get_abilities(AnonymousUser()), "abilities": comment2.get_abilities(AnonymousUser()),
"reactions": [], },
{
"id": str(comment1.id),
"content": comment1.content,
"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,
},
"document": str(comment1.document.id),
"abilities": comment1.get_abilities(AnonymousUser()),
}, },
], ],
} }
@@ -65,16 +62,13 @@ def test_list_comments_anonymous_user_public_document():
def test_list_comments_anonymous_user_non_public_document(link_reach): 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.""" """Anonymous users should not be allowed to list comments on a non-public document."""
document = factories.DocumentFactory( document = factories.DocumentFactory(
link_reach=link_reach, link_role=models.LinkRoleChoices.COMMENTER link_reach=link_reach, link_role=models.LinkRoleChoices.COMMENTATOR
) )
thread = factories.ThreadFactory(document=document) factories.CommentFactory(document=document)
factories.CommentFactory(thread=thread)
# other comments not linked to the document # other comments not linked to the document
factories.CommentFactory.create_batch(2) factories.CommentFactory.create_batch(2)
response = APIClient().get( response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/comments/")
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/"
)
assert response.status_code == 401 assert response.status_code == 401
@@ -82,49 +76,46 @@ def test_list_comments_authenticated_user_accessible_document():
"""Authenticated users should be allowed to list comments on an accessible document.""" """Authenticated users should be allowed to list comments on an accessible document."""
user = factories.UserFactory() user = factories.UserFactory()
document = factories.DocumentFactory( document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.COMMENTER)] link_reach="restricted", users=[(user, models.LinkRoleChoices.COMMENTATOR)]
) )
thread = factories.ThreadFactory(document=document) comment1 = factories.CommentFactory(document=document)
comment1 = factories.CommentFactory(thread=thread) comment2 = factories.CommentFactory(document=document, user=user)
comment2 = factories.CommentFactory(thread=thread, user=user)
# other comments not linked to the document # other comments not linked to the document
factories.CommentFactory.create_batch(2) factories.CommentFactory.create_batch(2)
client = APIClient() client = APIClient()
client.force_login(user) client.force_login(user)
response = client.get( response = client.get(f"/api/v1.0/documents/{document.id!s}/comments/")
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/"
)
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == { assert response.json() == {
"count": 2, "count": 2,
"next": None, "next": None,
"previous": None, "previous": None,
"results": [ "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), "id": str(comment2.id),
"body": comment2.body, "content": comment2.content,
"created_at": comment2.created_at.isoformat().replace("+00:00", "Z"), "created_at": comment2.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": comment2.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": comment2.updated_at.isoformat().replace("+00:00", "Z"),
"user": { "user": {
"full_name": comment2.user.full_name, "full_name": comment2.user.full_name,
"short_name": comment2.user.short_name, "short_name": comment2.user.short_name,
}, },
"document": str(comment2.document.id),
"abilities": comment2.get_abilities(user), "abilities": comment2.get_abilities(user),
"reactions": [], },
{
"id": str(comment1.id),
"content": comment1.content,
"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,
},
"document": str(comment1.document.id),
"abilities": comment1.get_abilities(user),
}, },
], ],
} }
@@ -134,17 +125,14 @@ def test_list_comments_authenticated_user_non_accessible_document():
"""Authenticated users should not be allowed to list comments on a non-accessible document.""" """Authenticated users should not be allowed to list comments on a non-accessible document."""
user = factories.UserFactory() user = factories.UserFactory()
document = factories.DocumentFactory(link_reach="restricted") document = factories.DocumentFactory(link_reach="restricted")
thread = factories.ThreadFactory(document=document) factories.CommentFactory(document=document)
factories.CommentFactory(thread=thread)
# other comments not linked to the document # other comments not linked to the document
factories.CommentFactory.create_batch(2) factories.CommentFactory.create_batch(2)
client = APIClient() client = APIClient()
client.force_login(user) client.force_login(user)
response = client.get( response = client.get(f"/api/v1.0/documents/{document.id!s}/comments/")
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/"
)
assert response.status_code == 403 assert response.status_code == 403
@@ -157,17 +145,14 @@ def test_list_comments_authenticated_user_not_enough_access():
document = factories.DocumentFactory( document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.READER)] link_reach="restricted", users=[(user, models.LinkRoleChoices.READER)]
) )
thread = factories.ThreadFactory(document=document) factories.CommentFactory(document=document)
factories.CommentFactory(thread=thread)
# other comments not linked to the document # other comments not linked to the document
factories.CommentFactory.create_batch(2) factories.CommentFactory.create_batch(2)
client = APIClient() client = APIClient()
client.force_login(user) client.force_login(user)
response = client.get( response = client.get(f"/api/v1.0/documents/{document.id!s}/comments/")
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/"
)
assert response.status_code == 403 assert response.status_code == 403
@@ -175,35 +160,30 @@ def test_list_comments_authenticated_user_not_enough_access():
def test_create_comment_anonymous_user_public_document(): def test_create_comment_anonymous_user_public_document():
""" """Anonymous users should not be allowed to create comments on a public document."""
Anonymous users should be allowed to create comments on a public document
with commenter link_role.
"""
document = factories.DocumentFactory( document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.COMMENTER link_reach="public", link_role=models.LinkRoleChoices.COMMENTATOR
) )
thread = factories.ThreadFactory(document=document)
client = APIClient() client = APIClient()
response = client.post( response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/", f"/api/v1.0/documents/{document.id!s}/comments/", {"content": "test"}
{"body": "test"},
) )
assert response.status_code == 201 assert response.status_code == 201
assert response.json() == { assert response.json() == {
"id": str(response.json()["id"]), "id": str(response.json()["id"]),
"body": "test", "content": "test",
"created_at": response.json()["created_at"], "created_at": response.json()["created_at"],
"updated_at": response.json()["updated_at"], "updated_at": response.json()["updated_at"],
"user": None, "user": None,
"document": str(document.id),
"abilities": { "abilities": {
"destroy": False, "destroy": False,
"update": False, "update": False,
"partial_update": False, "partial_update": False,
"reactions": False,
"retrieve": True, "retrieve": True,
}, },
"reactions": [],
} }
@@ -212,11 +192,9 @@ def test_create_comment_anonymous_user_non_accessible_document():
document = factories.DocumentFactory( document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.READER link_reach="public", link_role=models.LinkRoleChoices.READER
) )
thread = factories.ThreadFactory(document=document)
client = APIClient() client = APIClient()
response = client.post( response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/", f"/api/v1.0/documents/{document.id!s}/comments/", {"content": "test"}
{"body": "test"},
) )
assert response.status_code == 401 assert response.status_code == 401
@@ -226,34 +204,31 @@ def test_create_comment_authenticated_user_accessible_document():
"""Authenticated users should be allowed to create comments on an accessible document.""" """Authenticated users should be allowed to create comments on an accessible document."""
user = factories.UserFactory() user = factories.UserFactory()
document = factories.DocumentFactory( document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.COMMENTER)] link_reach="restricted", users=[(user, models.LinkRoleChoices.COMMENTATOR)]
) )
thread = factories.ThreadFactory(document=document)
client = APIClient() client = APIClient()
client.force_login(user) client.force_login(user)
response = client.post( response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/", f"/api/v1.0/documents/{document.id!s}/comments/", {"content": "test"}
{"body": "test"},
) )
assert response.status_code == 201 assert response.status_code == 201
assert response.json() == { assert response.json() == {
"id": str(response.json()["id"]), "id": str(response.json()["id"]),
"body": "test", "content": "test",
"created_at": response.json()["created_at"], "created_at": response.json()["created_at"],
"updated_at": response.json()["updated_at"], "updated_at": response.json()["updated_at"],
"user": { "user": {
"full_name": user.full_name, "full_name": user.full_name,
"short_name": user.short_name, "short_name": user.short_name,
}, },
"document": str(document.id),
"abilities": { "abilities": {
"destroy": True, "destroy": True,
"update": True, "update": True,
"partial_update": True, "partial_update": True,
"reactions": True,
"retrieve": True, "retrieve": True,
}, },
"reactions": [],
} }
@@ -266,12 +241,10 @@ def test_create_comment_authenticated_user_not_enough_access():
document = factories.DocumentFactory( document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.READER)] link_reach="restricted", users=[(user, models.LinkRoleChoices.READER)]
) )
thread = factories.ThreadFactory(document=document)
client = APIClient() client = APIClient()
client.force_login(user) client.force_login(user)
response = client.post( response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/", f"/api/v1.0/documents/{document.id!s}/comments/", {"content": "test"}
{"body": "test"},
) )
assert response.status_code == 403 assert response.status_code == 403
@@ -282,25 +255,24 @@ def test_create_comment_authenticated_user_not_enough_access():
def test_retrieve_comment_anonymous_user_public_document(): def test_retrieve_comment_anonymous_user_public_document():
"""Anonymous users should be allowed to retrieve comments on a public document.""" """Anonymous users should be allowed to retrieve comments on a public document."""
document = factories.DocumentFactory( document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.COMMENTER link_reach="public", link_role=models.LinkRoleChoices.COMMENTATOR
) )
thread = factories.ThreadFactory(document=document) comment = factories.CommentFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient() client = APIClient()
response = client.get( response = client.get(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/" f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/"
) )
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == { assert response.json() == {
"id": str(comment.id), "id": str(comment.id),
"body": comment.body, "content": comment.content,
"created_at": comment.created_at.isoformat().replace("+00:00", "Z"), "created_at": comment.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": comment.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": comment.updated_at.isoformat().replace("+00:00", "Z"),
"user": { "user": {
"full_name": comment.user.full_name, "full_name": comment.user.full_name,
"short_name": comment.user.short_name, "short_name": comment.user.short_name,
}, },
"reactions": [], "document": str(comment.document.id),
"abilities": comment.get_abilities(AnonymousUser()), "abilities": comment.get_abilities(AnonymousUser()),
} }
@@ -310,11 +282,10 @@ def test_retrieve_comment_anonymous_user_non_accessible_document():
document = factories.DocumentFactory( document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.READER link_reach="public", link_role=models.LinkRoleChoices.READER
) )
thread = factories.ThreadFactory(document=document) comment = factories.CommentFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient() client = APIClient()
response = client.get( response = client.get(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/" f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/"
) )
assert response.status_code == 401 assert response.status_code == 401
@@ -323,14 +294,13 @@ def test_retrieve_comment_authenticated_user_accessible_document():
"""Authenticated users should be allowed to retrieve comments on an accessible document.""" """Authenticated users should be allowed to retrieve comments on an accessible document."""
user = factories.UserFactory() user = factories.UserFactory()
document = factories.DocumentFactory( document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.COMMENTER)] link_reach="restricted", users=[(user, models.LinkRoleChoices.COMMENTATOR)]
) )
thread = factories.ThreadFactory(document=document) comment = factories.CommentFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient() client = APIClient()
client.force_login(user) client.force_login(user)
response = client.get( response = client.get(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/" f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/"
) )
assert response.status_code == 200 assert response.status_code == 200
@@ -344,12 +314,11 @@ def test_retrieve_comment_authenticated_user_not_enough_access():
document = factories.DocumentFactory( document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.READER)] link_reach="restricted", users=[(user, models.LinkRoleChoices.READER)]
) )
thread = factories.ThreadFactory(document=document) comment = factories.CommentFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient() client = APIClient()
client.force_login(user) client.force_login(user)
response = client.get( response = client.get(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/" f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/"
) )
assert response.status_code == 403 assert response.status_code == 403
@@ -360,14 +329,13 @@ def test_retrieve_comment_authenticated_user_not_enough_access():
def test_update_comment_anonymous_user_public_document(): def test_update_comment_anonymous_user_public_document():
"""Anonymous users should not be allowed to update comments on a public document.""" """Anonymous users should not be allowed to update comments on a public document."""
document = factories.DocumentFactory( document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.COMMENTER link_reach="public", link_role=models.LinkRoleChoices.COMMENTATOR
) )
thread = factories.ThreadFactory(document=document) comment = factories.CommentFactory(document=document, content="test")
comment = factories.CommentFactory(thread=thread, body="test")
client = APIClient() client = APIClient()
response = client.put( response = client.put(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/", f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/",
{"body": "other content"}, {"content": "other content"},
) )
assert response.status_code == 401 assert response.status_code == 401
@@ -377,12 +345,11 @@ def test_update_comment_anonymous_user_non_accessible_document():
document = factories.DocumentFactory( document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.READER link_reach="public", link_role=models.LinkRoleChoices.READER
) )
thread = factories.ThreadFactory(document=document) comment = factories.CommentFactory(document=document, content="test")
comment = factories.CommentFactory(thread=thread, body="test")
client = APIClient() client = APIClient()
response = client.put( response = client.put(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/", f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/",
{"body": "other content"}, {"content": "other content"},
) )
assert response.status_code == 401 assert response.status_code == 401
@@ -396,18 +363,17 @@ def test_update_comment_authenticated_user_accessible_document():
( (
user, user,
random.choice( random.choice(
[models.LinkRoleChoices.COMMENTER, models.LinkRoleChoices.EDITOR] [models.LinkRoleChoices.COMMENTATOR, models.LinkRoleChoices.EDITOR]
), ),
) )
], ],
) )
thread = factories.ThreadFactory(document=document) comment = factories.CommentFactory(document=document, content="test")
comment = factories.CommentFactory(thread=thread, body="test")
client = APIClient() client = APIClient()
client.force_login(user) client.force_login(user)
response = client.put( response = client.put(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/", f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/",
{"body": "other content"}, {"content": "other content"},
) )
assert response.status_code == 403 assert response.status_code == 403
@@ -421,23 +387,22 @@ def test_update_comment_authenticated_user_own_comment():
( (
user, user,
random.choice( random.choice(
[models.LinkRoleChoices.COMMENTER, models.LinkRoleChoices.EDITOR] [models.LinkRoleChoices.COMMENTATOR, models.LinkRoleChoices.EDITOR]
), ),
) )
], ],
) )
thread = factories.ThreadFactory(document=document) comment = factories.CommentFactory(document=document, content="test", user=user)
comment = factories.CommentFactory(thread=thread, body="test", user=user)
client = APIClient() client = APIClient()
client.force_login(user) client.force_login(user)
response = client.put( response = client.put(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/", f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/",
{"body": "other content"}, {"content": "other content"},
) )
assert response.status_code == 200 assert response.status_code == 200
comment.refresh_from_db() comment.refresh_from_db()
assert comment.body == "other content" assert comment.content == "other content"
def test_update_comment_authenticated_user_not_enough_access(): def test_update_comment_authenticated_user_not_enough_access():
@@ -449,13 +414,12 @@ def test_update_comment_authenticated_user_not_enough_access():
document = factories.DocumentFactory( document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.READER)] link_reach="restricted", users=[(user, models.LinkRoleChoices.READER)]
) )
thread = factories.ThreadFactory(document=document) comment = factories.CommentFactory(document=document, content="test")
comment = factories.CommentFactory(thread=thread, body="test")
client = APIClient() client = APIClient()
client.force_login(user) client.force_login(user)
response = client.put( response = client.put(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/", f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/",
{"body": "other content"}, {"content": "other content"},
) )
assert response.status_code == 403 assert response.status_code == 403
@@ -467,13 +431,12 @@ def test_update_comment_authenticated_no_access():
""" """
user = factories.UserFactory() user = factories.UserFactory()
document = factories.DocumentFactory(link_reach="restricted") document = factories.DocumentFactory(link_reach="restricted")
thread = factories.ThreadFactory(document=document) comment = factories.CommentFactory(document=document, content="test")
comment = factories.CommentFactory(thread=thread, body="test")
client = APIClient() client = APIClient()
client.force_login(user) client.force_login(user)
response = client.put( response = client.put(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/", f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/",
{"body": "other content"}, {"content": "other content"},
) )
assert response.status_code == 403 assert response.status_code == 403
@@ -485,19 +448,18 @@ def test_update_comment_authenticated_admin_or_owner_can_update_any_comment(role
""" """
user = factories.UserFactory() user = factories.UserFactory()
document = factories.DocumentFactory(users=[(user, role)]) document = factories.DocumentFactory(users=[(user, role)])
thread = factories.ThreadFactory(document=document) comment = factories.CommentFactory(document=document, content="test")
comment = factories.CommentFactory(thread=thread, body="test")
client = APIClient() client = APIClient()
client.force_login(user) client.force_login(user)
response = client.put( response = client.put(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/", f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/",
{"body": "other content"}, {"content": "other content"},
) )
assert response.status_code == 200 assert response.status_code == 200
comment.refresh_from_db() comment.refresh_from_db()
assert comment.body == "other content" assert comment.content == "other content"
@pytest.mark.parametrize("role", [models.RoleChoices.ADMIN, models.RoleChoices.OWNER]) @pytest.mark.parametrize("role", [models.RoleChoices.ADMIN, models.RoleChoices.OWNER])
@@ -507,19 +469,18 @@ def test_update_comment_authenticated_admin_or_owner_can_update_own_comment(role
""" """
user = factories.UserFactory() user = factories.UserFactory()
document = factories.DocumentFactory(users=[(user, role)]) document = factories.DocumentFactory(users=[(user, role)])
thread = factories.ThreadFactory(document=document) comment = factories.CommentFactory(document=document, content="test", user=user)
comment = factories.CommentFactory(thread=thread, body="test", user=user)
client = APIClient() client = APIClient()
client.force_login(user) client.force_login(user)
response = client.put( response = client.put(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/", f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/",
{"body": "other content"}, {"content": "other content"},
) )
assert response.status_code == 200 assert response.status_code == 200
comment.refresh_from_db() comment.refresh_from_db()
assert comment.body == "other content" assert comment.content == "other content"
# Delete comment # Delete comment
@@ -528,13 +489,12 @@ def test_update_comment_authenticated_admin_or_owner_can_update_own_comment(role
def test_delete_comment_anonymous_user_public_document(): def test_delete_comment_anonymous_user_public_document():
"""Anonymous users should not be allowed to delete comments on a public document.""" """Anonymous users should not be allowed to delete comments on a public document."""
document = factories.DocumentFactory( document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.COMMENTER link_reach="public", link_role=models.LinkRoleChoices.COMMENTATOR
) )
thread = factories.ThreadFactory(document=document) comment = factories.CommentFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient() client = APIClient()
response = client.delete( response = client.delete(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/" f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/"
) )
assert response.status_code == 401 assert response.status_code == 401
@@ -544,11 +504,10 @@ def test_delete_comment_anonymous_user_non_accessible_document():
document = factories.DocumentFactory( document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.READER link_reach="public", link_role=models.LinkRoleChoices.READER
) )
thread = factories.ThreadFactory(document=document) comment = factories.CommentFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient() client = APIClient()
response = client.delete( response = client.delete(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/" f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/"
) )
assert response.status_code == 401 assert response.status_code == 401
@@ -557,14 +516,13 @@ def test_delete_comment_authenticated_user_accessible_document_own_comment():
"""Authenticated users should be able to delete comments on an accessible document.""" """Authenticated users should be able to delete comments on an accessible document."""
user = factories.UserFactory() user = factories.UserFactory()
document = factories.DocumentFactory( document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.COMMENTER)] link_reach="restricted", users=[(user, models.LinkRoleChoices.COMMENTATOR)]
) )
thread = factories.ThreadFactory(document=document) comment = factories.CommentFactory(document=document, user=user)
comment = factories.CommentFactory(thread=thread, user=user)
client = APIClient() client = APIClient()
client.force_login(user) client.force_login(user)
response = client.delete( response = client.delete(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/" f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/"
) )
assert response.status_code == 204 assert response.status_code == 204
@@ -573,14 +531,13 @@ def test_delete_comment_authenticated_user_accessible_document_not_own_comment()
"""Authenticated users should not be able to delete comments on an accessible document.""" """Authenticated users should not be able to delete comments on an accessible document."""
user = factories.UserFactory() user = factories.UserFactory()
document = factories.DocumentFactory( document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.COMMENTER)] link_reach="restricted", users=[(user, models.LinkRoleChoices.COMMENTATOR)]
) )
thread = factories.ThreadFactory(document=document) comment = factories.CommentFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient() client = APIClient()
client.force_login(user) client.force_login(user)
response = client.delete( response = client.delete(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/" f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/"
) )
assert response.status_code == 403 assert response.status_code == 403
@@ -590,12 +547,11 @@ def test_delete_comment_authenticated_user_admin_or_owner_can_delete_any_comment
"""Authenticated users should be able to delete comments on a document they have access to.""" """Authenticated users should be able to delete comments on a document they have access to."""
user = factories.UserFactory() user = factories.UserFactory()
document = factories.DocumentFactory(users=[(user, role)]) document = factories.DocumentFactory(users=[(user, role)])
thread = factories.ThreadFactory(document=document) comment = factories.CommentFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient() client = APIClient()
client.force_login(user) client.force_login(user)
response = client.delete( response = client.delete(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/" f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/"
) )
assert response.status_code == 204 assert response.status_code == 204
@@ -605,12 +561,11 @@ def test_delete_comment_authenticated_user_admin_or_owner_can_delete_own_comment
"""Authenticated users should be able to delete comments on a document they have access to.""" """Authenticated users should be able to delete comments on a document they have access to."""
user = factories.UserFactory() user = factories.UserFactory()
document = factories.DocumentFactory(users=[(user, role)]) document = factories.DocumentFactory(users=[(user, role)])
thread = factories.ThreadFactory(document=document) comment = factories.CommentFactory(document=document, user=user)
comment = factories.CommentFactory(thread=thread, user=user)
client = APIClient() client = APIClient()
client.force_login(user) client.force_login(user)
response = client.delete( response = client.delete(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/" f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/"
) )
assert response.status_code == 204 assert response.status_code == 204
@@ -624,255 +579,10 @@ def test_delete_comment_authenticated_user_not_enough_access():
document = factories.DocumentFactory( document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.READER)] link_reach="restricted", users=[(user, models.LinkRoleChoices.READER)]
) )
thread = factories.ThreadFactory(document=document) comment = factories.CommentFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient() client = APIClient()
client.force_login(user) client.force_login(user)
response = client.delete( response = client.delete(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/" f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/"
) )
assert response.status_code == 403 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()
@@ -1,11 +1,7 @@
"""Test on the CORS proxy API for documents.""" """Test on the CORS proxy API for documents."""
import socket
import unittest.mock
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
@@ -13,17 +9,11 @@ from core import factories
pytestmark = pytest.mark.django_db pytestmark = pytest.mark.django_db
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
@responses.activate @responses.activate
def test_api_docs_cors_proxy_valid_url(mock_getaddrinfo): def test_api_docs_cors_proxy_valid_url():
"""Test the CORS proxy API for documents with a valid URL.""" """Test the CORS proxy API for documents with a valid URL."""
document = factories.DocumentFactory(link_reach="public") document = factories.DocumentFactory(link_reach="public")
# Mock DNS resolution to return a public IP address
mock_getaddrinfo.return_value = [
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("8.8.8.8", 0))
]
client = APIClient() client = APIClient()
url_to_fetch = "https://external-url.com/assets/logo-gouv.png" url_to_fetch = "https://external-url.com/assets/logo-gouv.png"
responses.get(url_to_fetch, body=b"", status=200, content_type="image/png") responses.get(url_to_fetch, body=b"", status=200, content_type="image/png")
@@ -65,17 +55,11 @@ def test_api_docs_cors_proxy_without_url_query_string():
assert response.json() == {"detail": "Missing 'url' query parameter"} assert response.json() == {"detail": "Missing 'url' query parameter"}
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
@responses.activate @responses.activate
def test_api_docs_cors_proxy_anonymous_document_not_public(mock_getaddrinfo): def test_api_docs_cors_proxy_anonymous_document_not_public():
"""Test the CORS proxy API for documents with an anonymous user and a non-public document.""" """Test the CORS proxy API for documents with an anonymous user and a non-public document."""
document = factories.DocumentFactory(link_reach="authenticated") document = factories.DocumentFactory(link_reach="authenticated")
# Mock DNS resolution to return a public IP address
mock_getaddrinfo.return_value = [
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("8.8.8.8", 0))
]
client = APIClient() client = APIClient()
url_to_fetch = "https://external-url.com/assets/logo-gouv.png" url_to_fetch = "https://external-url.com/assets/logo-gouv.png"
responses.get(url_to_fetch, body=b"", status=200, content_type="image/png") responses.get(url_to_fetch, body=b"", status=200, content_type="image/png")
@@ -88,22 +72,14 @@ def test_api_docs_cors_proxy_anonymous_document_not_public(mock_getaddrinfo):
} }
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
@responses.activate @responses.activate
def test_api_docs_cors_proxy_authenticated_user_accessing_protected_doc( def test_api_docs_cors_proxy_authenticated_user_accessing_protected_doc():
mock_getaddrinfo,
):
""" """
Test the CORS proxy API for documents with an authenticated user accessing a protected Test the CORS proxy API for documents with an authenticated user accessing a protected
document. document.
""" """
document = factories.DocumentFactory(link_reach="authenticated") document = factories.DocumentFactory(link_reach="authenticated")
# Mock DNS resolution to return a public IP address
mock_getaddrinfo.return_value = [
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("8.8.8.8", 0))
]
user = factories.UserFactory() user = factories.UserFactory()
client = APIClient() client = APIClient()
@@ -138,22 +114,14 @@ def test_api_docs_cors_proxy_authenticated_user_accessing_protected_doc(
assert response.streaming_content assert response.streaming_content
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
@responses.activate @responses.activate
def test_api_docs_cors_proxy_authenticated_not_accessing_restricted_doc( def test_api_docs_cors_proxy_authenticated_not_accessing_restricted_doc():
mock_getaddrinfo,
):
""" """
Test the CORS proxy API for documents with an authenticated user not accessing a restricted Test the CORS proxy API for documents with an authenticated user not accessing a restricted
document. document.
""" """
document = factories.DocumentFactory(link_reach="restricted") document = factories.DocumentFactory(link_reach="restricted")
# Mock DNS resolution to return a public IP address
mock_getaddrinfo.return_value = [
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("8.8.8.8", 0))
]
user = factories.UserFactory() user = factories.UserFactory()
client = APIClient() client = APIClient()
@@ -169,271 +137,15 @@ def test_api_docs_cors_proxy_authenticated_not_accessing_restricted_doc(
} }
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
@responses.activate @responses.activate
def test_api_docs_cors_proxy_unsupported_media_type(mock_getaddrinfo): def test_api_docs_cors_proxy_unsupported_media_type():
"""Test the CORS proxy API for documents with an unsupported media type.""" """Test the CORS proxy API for documents with an unsupported media type."""
document = factories.DocumentFactory(link_reach="public") document = factories.DocumentFactory(link_reach="public")
# Mock DNS resolution to return a public IP address
mock_getaddrinfo.return_value = [
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("8.8.8.8", 0))
]
client = APIClient() client = APIClient()
url_to_fetch = "https://external-url.com/assets/index.html" url_to_fetch = "https://external-url.com/assets/index.html"
responses.get(url_to_fetch, body=b"", status=200, content_type="text/html") responses.get(url_to_fetch, body=b"", status=200, content_type="text/html")
response = client.get( response = client.get(
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 == 400 assert response.status_code == 415
assert response.json() == {"detail": "Invalid URL used."}
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
@responses.activate
def test_api_docs_cors_proxy_redirect(mock_getaddrinfo):
"""Test the CORS proxy API for documents with a redirect."""
document = factories.DocumentFactory(link_reach="public")
# Mock DNS resolution to return a public IP address
mock_getaddrinfo.return_value = [
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("8.8.8.8", 0))
]
client = APIClient()
url_to_fetch = "https://external-url.com/assets/index.html"
responses.get(
url_to_fetch,
body=b"",
status=302,
headers={"Location": "https://external-url.com/other/assets/index.html"},
)
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() == {"detail": "Invalid URL used."}
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
@responses.activate
def test_api_docs_cors_proxy_url_not_returning_200(mock_getaddrinfo):
"""Test the CORS proxy API for documents with a URL that does not return 200."""
document = factories.DocumentFactory(link_reach="public")
# Mock DNS resolution to return a public IP address
mock_getaddrinfo.return_value = [
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("8.8.8.8", 0))
]
client = APIClient()
url_to_fetch = "https://external-url.com/assets/index.html"
responses.get(url_to_fetch, body=b"", status=404)
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() == {"detail": "Invalid URL used."}
@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."]
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
@responses.activate
def test_api_docs_cors_proxy_request_failed(mock_getaddrinfo):
"""Test the CORS proxy API for documents with a request failed."""
document = factories.DocumentFactory(link_reach="public")
# Mock DNS resolution to return a public IP address
mock_getaddrinfo.return_value = [
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("8.8.8.8", 0))
]
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() == {"detail": "Invalid URL used."}
@pytest.mark.parametrize(
"url_to_fetch",
[
"http://localhost/image.png",
"https://localhost/image.png",
"http://127.0.0.1/image.png",
"https://127.0.0.1/image.png",
"http://0.0.0.0/image.png",
"https://0.0.0.0/image.png",
"http://[::1]/image.png",
"https://[::1]/image.png",
"http://[0:0:0:0:0:0:0:1]/image.png",
"https://[0:0:0:0:0:0:0:1]/image.png",
],
)
def test_api_docs_cors_proxy_blocks_localhost(url_to_fetch):
"""Test that the CORS proxy API blocks localhost variations."""
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()["detail"] == "Invalid URL used."
@pytest.mark.parametrize(
"url_to_fetch",
[
"http://10.0.0.1/image.png",
"https://10.0.0.1/image.png",
"http://172.16.0.1/image.png",
"https://172.16.0.1/image.png",
"http://192.168.1.1/image.png",
"https://192.168.1.1/image.png",
"http://10.255.255.255/image.png",
"https://10.255.255.255/image.png",
"http://172.31.255.255/image.png",
"https://172.31.255.255/image.png",
"http://192.168.255.255/image.png",
"https://192.168.255.255/image.png",
],
)
def test_api_docs_cors_proxy_blocks_private_ips(url_to_fetch):
"""Test that the CORS proxy API blocks private IP addresses."""
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()["detail"] == "Invalid URL used."
@pytest.mark.parametrize(
"url_to_fetch",
[
"http://169.254.1.1/image.png",
"https://169.254.1.1/image.png",
"http://169.254.255.255/image.png",
"https://169.254.255.255/image.png",
],
)
def test_api_docs_cors_proxy_blocks_link_local(url_to_fetch):
"""Test that the CORS proxy API blocks link-local addresses."""
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()["detail"] == "Invalid URL used."
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
@responses.activate
def test_api_docs_cors_proxy_blocks_dns_rebinding_to_private_ip(mock_getaddrinfo):
"""Test that the CORS proxy API blocks DNS rebinding attacks to private IPs."""
document = factories.DocumentFactory(link_reach="public")
# Mock DNS resolution to return a private IP address
mock_getaddrinfo.return_value = [
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("192.168.1.1", 0))
]
client = APIClient()
url_to_fetch = "https://malicious-domain.com/image.png"
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()["detail"] == "Invalid URL used."
mock_getaddrinfo.assert_called_once()
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
@responses.activate
def test_api_docs_cors_proxy_blocks_dns_rebinding_to_localhost(mock_getaddrinfo):
"""Test that the CORS proxy API blocks DNS rebinding attacks to localhost."""
document = factories.DocumentFactory(link_reach="public")
# Mock DNS resolution to return localhost
mock_getaddrinfo.return_value = [
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("127.0.0.1", 0))
]
client = APIClient()
url_to_fetch = "https://malicious-domain.com/image.png"
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()["detail"] == "Invalid URL used."
mock_getaddrinfo.assert_called_once()
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
def test_api_docs_cors_proxy_handles_dns_resolution_failure(mock_getaddrinfo):
"""Test that the CORS proxy API handles DNS resolution failures gracefully."""
document = factories.DocumentFactory(link_reach="public")
# Mock DNS resolution to fail
mock_getaddrinfo.side_effect = socket.gaierror("Name or service not known")
client = APIClient()
url_to_fetch = "https://nonexistent-domain-12345.com/image.png"
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()["detail"] == "Invalid URL used."
mock_getaddrinfo.assert_called_once()
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
def test_api_docs_cors_proxy_blocks_multiple_resolved_ips_if_any_private(
mock_getaddrinfo,
):
"""Test that the CORS proxy API blocks if any resolved IP is private."""
document = factories.DocumentFactory(link_reach="public")
# Mock DNS resolution to return both public and private IPs
mock_getaddrinfo.return_value = [
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("8.8.8.8", 0)),
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("192.168.1.1", 0)),
]
client = APIClient()
url_to_fetch = "https://example.com/image.png"
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()["detail"] == "Invalid URL used."
mock_getaddrinfo.assert_called_once()
@@ -16,7 +16,6 @@ from rest_framework.test import APIClient
from core import factories from core import factories
from core.api.serializers import ServerCreateDocumentSerializer from core.api.serializers import ServerCreateDocumentSerializer
from core.models import Document, Invitation, User from core.models import Document, Invitation, User
from core.services import mime_types
from core.services.converter_services import ConversionError, YdocConverter from core.services.converter_services import ConversionError, YdocConverter
pytestmark = pytest.mark.django_db pytestmark = pytest.mark.django_db
@@ -149,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",
} }
@@ -164,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."
]
} }
@@ -192,9 +194,7 @@ def test_api_documents_create_for_owner_existing(mock_convert_md):
assert response.status_code == 201 assert response.status_code == 201
mock_convert_md.assert_called_once_with( mock_convert_md.assert_called_once_with("Document content")
"Document content", mime_types.MARKDOWN, mime_types.YJS
)
document = Document.objects.get() document = Document.objects.get()
assert response.json() == {"id": str(document.id)} assert response.json() == {"id": str(document.id)}
@@ -239,9 +239,7 @@ def test_api_documents_create_for_owner_new_user(mock_convert_md):
assert response.status_code == 201 assert response.status_code == 201
mock_convert_md.assert_called_once_with( mock_convert_md.assert_called_once_with("Document content")
"Document content", mime_types.MARKDOWN, mime_types.YJS
)
document = Document.objects.get() document = Document.objects.get()
assert response.json() == {"id": str(document.id)} assert response.json() == {"id": str(document.id)}
@@ -302,9 +300,7 @@ def test_api_documents_create_for_owner_existing_user_email_no_sub_with_fallback
assert response.status_code == 201 assert response.status_code == 201
mock_convert_md.assert_called_once_with( mock_convert_md.assert_called_once_with("Document content")
"Document content", mime_types.MARKDOWN, mime_types.YJS
)
document = Document.objects.get() document = Document.objects.get()
assert response.json() == {"id": str(document.id)} assert response.json() == {"id": str(document.id)}
@@ -400,9 +396,7 @@ def test_api_documents_create_for_owner_new_user_no_sub_no_fallback_allow_duplic
HTTP_AUTHORIZATION="Bearer DummyToken", HTTP_AUTHORIZATION="Bearer DummyToken",
) )
assert response.status_code == 201 assert response.status_code == 201
mock_convert_md.assert_called_once_with( mock_convert_md.assert_called_once_with("Document content")
"Document content", mime_types.MARKDOWN, mime_types.YJS
)
document = Document.objects.get() document = Document.objects.get()
assert response.json() == {"id": str(document.id)} assert response.json() == {"id": str(document.id)}
@@ -483,9 +477,7 @@ def test_api_documents_create_for_owner_with_default_language(
) )
assert response.status_code == 201 assert response.status_code == 201
mock_convert_md.assert_called_once_with( mock_convert_md.assert_called_once_with("Document content")
"Document content", mime_types.MARKDOWN, mime_types.YJS
)
assert mock_send.call_args[0][3] == "de-de" assert mock_send.call_args[0][3] == "de-de"
@@ -512,9 +504,7 @@ def test_api_documents_create_for_owner_with_custom_language(mock_convert_md):
assert response.status_code == 201 assert response.status_code == 201
mock_convert_md.assert_called_once_with( mock_convert_md.assert_called_once_with("Document content")
"Document content", mime_types.MARKDOWN, mime_types.YJS
)
assert len(mail.outbox) == 1 assert len(mail.outbox) == 1
email = mail.outbox[0] email = mail.outbox[0]
@@ -550,9 +540,7 @@ def test_api_documents_create_for_owner_with_custom_subject_and_message(
assert response.status_code == 201 assert response.status_code == 201
mock_convert_md.assert_called_once_with( mock_convert_md.assert_called_once_with("Document content")
"Document content", mime_types.MARKDOWN, mime_types.YJS
)
assert len(mail.outbox) == 1 assert len(mail.outbox) == 1
email = mail.outbox[0] email = mail.outbox[0]
@@ -586,9 +574,7 @@ def test_api_documents_create_for_owner_with_converter_exception(
format="json", format="json",
HTTP_AUTHORIZATION="Bearer DummyToken", HTTP_AUTHORIZATION="Bearer DummyToken",
) )
mock_convert_md.assert_called_once_with( mock_convert_md.assert_called_once_with("Document content")
"Document content", mime_types.MARKDOWN, mime_types.YJS
)
assert response.status_code == 400 assert response.status_code == 400
assert response.json() == {"content": ["Could not convert content"]} assert response.json() == {"content": ["Could not convert content"]}
@@ -1,413 +0,0 @@
"""
Tests for Documents API endpoint in impress's core app: create with file upload
"""
from base64 import b64decode, binascii
from io import BytesIO
from unittest.mock import patch
import pytest
from rest_framework.test import APIClient
from core import factories
from core.models import Document
from core.services import mime_types
from core.services.converter_services import (
ConversionError,
ServiceUnavailableError,
)
pytestmark = pytest.mark.django_db
def test_api_documents_create_with_file_anonymous():
"""Anonymous users should not be allowed to create documents with file upload."""
# Create a fake DOCX file
file_content = b"fake docx content"
file = BytesIO(file_content)
file.name = "test_document.docx"
response = APIClient().post(
"/api/v1.0/documents/",
{
"file": file,
},
format="multipart",
)
assert response.status_code == 401
assert not Document.objects.exists()
@patch("core.services.converter_services.Converter.convert")
def test_api_documents_create_with_docx_file_success(mock_convert):
"""
Authenticated users should be able to create documents by uploading a DOCX file.
The file should be converted to YJS format and the title should be set from filename.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Mock the conversion
converted_yjs = "base64encodedyjscontent"
mock_convert.return_value = converted_yjs
# Create a fake DOCX file
file_content = b"fake docx content"
file = BytesIO(file_content)
file.name = "My Important Document.docx"
response = client.post(
"/api/v1.0/documents/",
{
"file": file,
},
format="multipart",
)
assert response.status_code == 201
document = Document.objects.get()
assert document.title == "My Important Document.docx"
assert document.content == converted_yjs
assert document.accesses.filter(role="owner", user=user).exists()
# Verify the converter was called correctly
mock_convert.assert_called_once_with(
file_content,
content_type=mime_types.DOCX,
accept=mime_types.YJS,
)
@patch("core.services.converter_services.Converter.convert")
def test_api_documents_create_with_markdown_file_success(mock_convert):
"""
Authenticated users should be able to create documents by uploading a Markdown file.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Mock the conversion
converted_yjs = "base64encodedyjscontent"
mock_convert.return_value = converted_yjs
# Create a fake Markdown file
file_content = b"# Test Document\n\nThis is a test."
file = BytesIO(file_content)
file.name = "readme.md"
response = client.post(
"/api/v1.0/documents/",
{
"file": file,
},
format="multipart",
)
assert response.status_code == 201
document = Document.objects.get()
assert document.title == "readme.md"
assert document.content == converted_yjs
assert document.accesses.filter(role="owner", user=user).exists()
# Verify the converter was called correctly
mock_convert.assert_called_once_with(
file_content,
content_type=mime_types.MARKDOWN,
accept=mime_types.YJS,
)
@patch("core.services.converter_services.Converter.convert")
def test_api_documents_create_with_file_and_explicit_title(mock_convert):
"""
When both file and title are provided, the filename should override the title.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Mock the conversion
converted_yjs = "base64encodedyjscontent"
mock_convert.return_value = converted_yjs
# Create a fake DOCX file
file_content = b"fake docx content"
file = BytesIO(file_content)
file.name = "Uploaded Document.docx"
response = client.post(
"/api/v1.0/documents/",
{
"file": file,
"title": "This should be overridden",
},
format="multipart",
)
assert response.status_code == 201
document = Document.objects.get()
# The filename should take precedence
assert document.title == "Uploaded Document.docx"
def test_api_documents_create_with_empty_file():
"""
Creating a document with an empty file should fail with a validation error.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Create an empty file
file = BytesIO(b"")
file.name = "empty.docx"
response = client.post(
"/api/v1.0/documents/",
{
"file": file,
},
format="multipart",
)
assert response.status_code == 400
assert response.json() == {"file": ["The submitted file is empty."]}
assert not Document.objects.exists()
@patch("core.services.converter_services.Converter.convert")
def test_api_documents_create_with_file_conversion_error(mock_convert):
"""
When conversion fails, the API should return a 400 error with appropriate message.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Mock the conversion to raise an error
mock_convert.side_effect = ConversionError("Failed to convert document")
# Create a fake DOCX file
file_content = b"fake invalid docx content"
file = BytesIO(file_content)
file.name = "corrupted.docx"
response = client.post(
"/api/v1.0/documents/",
{
"file": file,
},
format="multipart",
)
assert response.status_code == 400
assert response.json() == {"file": ["Could not convert file content"]}
assert not Document.objects.exists()
@patch("core.services.converter_services.Converter.convert")
def test_api_documents_create_with_file_service_unavailable(mock_convert):
"""
When the conversion service is unavailable, appropriate error should be returned.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Mock the conversion to raise ServiceUnavailableError
mock_convert.side_effect = ServiceUnavailableError(
"Failed to connect to conversion service"
)
# Create a fake DOCX file
file_content = b"fake docx content"
file = BytesIO(file_content)
file.name = "document.docx"
response = client.post(
"/api/v1.0/documents/",
{
"file": file,
},
format="multipart",
)
assert response.status_code == 400
assert response.json() == {"file": ["Could not convert file content"]}
assert not Document.objects.exists()
def test_api_documents_create_without_file_still_works():
"""
Creating a document without a file should still work as before (backward compatibility).
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/documents/",
{
"title": "Regular document without file",
},
format="json",
)
assert response.status_code == 201
document = Document.objects.get()
assert document.title == "Regular document without file"
assert document.content is None
assert document.accesses.filter(role="owner", user=user).exists()
@patch("core.services.converter_services.Converter.convert")
def test_api_documents_create_with_file_null_value(mock_convert):
"""
Passing file=null should be treated as no file upload.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/documents/",
{
"title": "Document with null file",
"file": None,
},
format="json",
)
assert response.status_code == 201
document = Document.objects.get()
assert document.title == "Document with null file"
# Converter should not have been called
mock_convert.assert_not_called()
@patch("core.services.converter_services.Converter.convert")
def test_api_documents_create_with_file_preserves_content_format(mock_convert):
"""
Verify that the converted content is stored correctly in the document.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Mock the conversion with realistic base64-encoded YJS data
converted_yjs = "AQMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICA="
mock_convert.return_value = converted_yjs
# Create a fake DOCX file
file_content = b"fake docx with complex formatting"
file = BytesIO(file_content)
file.name = "complex_document.docx"
response = client.post(
"/api/v1.0/documents/",
{
"file": file,
},
format="multipart",
)
assert response.status_code == 201
document = Document.objects.get()
# Verify the content is stored as returned by the converter
assert document.content == converted_yjs
# Verify it's valid base64 (can be decoded)
try:
b64decode(converted_yjs)
except binascii.Error:
pytest.fail("Content should be valid base64-encoded data")
@patch("core.services.converter_services.Converter.convert")
def test_api_documents_create_with_file_unicode_filename(mock_convert):
"""
Test that Unicode characters in filenames are handled correctly.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Mock the conversion
converted_yjs = "base64encodedyjscontent"
mock_convert.return_value = converted_yjs
# Create a file with Unicode characters in the name
file_content = b"fake docx content"
file = BytesIO(file_content)
file.name = "文档-télécharger-документ.docx"
response = client.post(
"/api/v1.0/documents/",
{
"file": file,
},
format="multipart",
)
assert response.status_code == 201
document = Document.objects.get()
assert document.title == "文档-télécharger-документ.docx"
def test_api_documents_create_with_file_max_size_exceeded(settings):
"""
The uploaded file should not exceed the maximum size in settings.
"""
settings.CONVERSION_FILE_MAX_SIZE = 1 # 1 byte for test
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
file = BytesIO(b"a" * (10))
file.name = "test.docx"
response = client.post(
"/api/v1.0/documents/",
{
"file": file,
},
format="multipart",
)
assert response.status_code == 400
assert response.json() == {"file": ["File size exceeds the maximum limit of 0 MB."]}
def test_api_documents_create_with_file_extension_not_allowed(settings):
"""
The uploaded file should not have an allowed extension.
"""
settings.CONVERSION_FILE_EXTENSIONS_ALLOWED = [".docx"]
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
file = BytesIO(b"fake docx content")
file.name = "test.md"
response = client.post(
"/api/v1.0/documents/",
{
"file": file,
},
format="multipart",
)
assert response.status_code == 400
assert response.json() == {
"file": [
"File extension .md is not allowed. Allowed extensions are: ['.docx']."
]
}
@@ -38,7 +38,6 @@ def test_api_documents_descendants_list_anonymous_public_standalone():
"computed_link_role": child1.computed_link_role, "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),
@@ -63,7 +62,6 @@ def test_api_documents_descendants_list_anonymous_public_standalone():
"computed_link_role": grand_child.computed_link_role, "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),
@@ -86,7 +84,6 @@ def test_api_documents_descendants_list_anonymous_public_standalone():
"computed_link_role": child2.computed_link_role, "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),
@@ -138,7 +135,6 @@ def test_api_documents_descendants_list_anonymous_public_parent():
"computed_link_role": child1.computed_link_role, "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),
@@ -161,7 +157,6 @@ def test_api_documents_descendants_list_anonymous_public_parent():
"computed_link_role": grand_child.computed_link_role, "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),
@@ -184,7 +179,6 @@ def test_api_documents_descendants_list_anonymous_public_parent():
"computed_link_role": child2.computed_link_role, "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),
@@ -257,7 +251,6 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen
"computed_link_role": child1.computed_link_role, "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),
@@ -280,7 +273,6 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen
"computed_link_role": grand_child.computed_link_role, "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),
@@ -303,7 +295,6 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen
"computed_link_role": child2.computed_link_role, "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),
@@ -361,7 +352,6 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa
"computed_link_role": child1.computed_link_role, "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),
@@ -384,7 +374,6 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa
"computed_link_role": grand_child.computed_link_role, "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),
@@ -407,7 +396,6 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa
"computed_link_role": child2.computed_link_role, "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),
@@ -486,7 +474,6 @@ def test_api_documents_descendants_list_authenticated_related_direct():
"computed_link_role": child1.computed_link_role, "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),
@@ -509,7 +496,6 @@ def test_api_documents_descendants_list_authenticated_related_direct():
"computed_link_role": grand_child.computed_link_role, "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),
@@ -532,7 +518,6 @@ def test_api_documents_descendants_list_authenticated_related_direct():
"computed_link_role": child2.computed_link_role, "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),
@@ -591,7 +576,6 @@ def test_api_documents_descendants_list_authenticated_related_parent():
"computed_link_role": child1.computed_link_role, "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),
@@ -614,7 +598,6 @@ def test_api_documents_descendants_list_authenticated_related_parent():
"computed_link_role": grand_child.computed_link_role, "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),
@@ -637,7 +620,6 @@ def test_api_documents_descendants_list_authenticated_related_parent():
"computed_link_role": child2.computed_link_role, "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),
@@ -742,7 +724,6 @@ def test_api_documents_descendants_list_authenticated_related_team_members(
"computed_link_role": child1.computed_link_role, "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),
@@ -765,7 +746,6 @@ def test_api_documents_descendants_list_authenticated_related_team_members(
"computed_link_role": grand_child.computed_link_role, "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),
@@ -788,7 +768,6 @@ def test_api_documents_descendants_list_authenticated_related_team_members(
"computed_link_role": child2.computed_link_role, "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),
@@ -293,28 +293,3 @@ def test_api_documents_duplicate_non_root_document(role):
assert duplicated_accesses.count() == 0 assert duplicated_accesses.count() == 0
assert duplicated_document.is_sibling_of(child) assert duplicated_document.is_sibling_of(child)
assert duplicated_document.is_child_of(document) 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"
@@ -65,7 +65,6 @@ def test_api_document_favorite_list_authenticated_with_favorite():
"computed_link_role": document.computed_link_role, "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,
@@ -83,34 +82,3 @@ def test_api_document_favorite_list_authenticated_with_favorite():
} }
], ],
} }
def test_api_document_favorite_list_with_favorite_children():
"""Authenticated users should receive their favorite documents, including children."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
root = factories.DocumentFactory(creator=user, users=[user])
children = factories.DocumentFactory.create_batch(
2, parent=root, favorited_by=[user]
)
access = factories.UserDocumentAccessFactory(
user=user, role=models.RoleChoices.READER, document__favorited_by=[user]
)
other_root = factories.DocumentFactory(creator=user, users=[user])
factories.DocumentFactory.create_batch(2, parent=other_root)
response = client.get("/api/v1.0/documents/favorite_list/")
assert response.status_code == 200
assert response.json()["count"] == 3
content = response.json()["results"]
assert content[0]["id"] == str(children[0].id)
assert content[1]["id"] == str(children[1].id)
assert content[2]["id"] == str(access.document.id)
@@ -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
@@ -69,7 +69,6 @@ def test_api_documents_list_format():
"computed_link_role": document.computed_link_role, "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,
@@ -428,20 +427,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
@@ -36,9 +36,8 @@ def test_api_documents_retrieve_anonymous_public_standalone():
"children_create": False, "children_create": False,
"children_list": True, "children_list": True,
"collaboration_auth": True, "collaboration_auth": True,
"comment": document.link_role in ["commenter", "editor"], "comment": document.link_role in ["commentator", "editor"],
"cors_proxy": True, "cors_proxy": True,
"content": True,
"descendants": True, "descendants": True,
"destroy": False, "destroy": False,
"duplicate": False, "duplicate": False,
@@ -47,8 +46,8 @@ def test_api_documents_retrieve_anonymous_public_standalone():
"invite_owner": False, "invite_owner": False,
"link_configuration": False, "link_configuration": False,
"link_select_options": { "link_select_options": {
"authenticated": ["reader", "commenter", "editor"], "authenticated": ["reader", "commentator", "editor"],
"public": ["reader", "commenter", "editor"], "public": ["reader", "commentator", "editor"],
"restricted": None, "restricted": None,
}, },
"mask": False, "mask": False,
@@ -71,7 +70,6 @@ def test_api_documents_retrieve_anonymous_public_standalone():
"content": document.content, "content": document.content,
"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": False, "is_favorite": False,
@@ -114,10 +112,9 @@ def test_api_documents_retrieve_anonymous_public_parent():
"children_create": False, "children_create": False,
"children_list": True, "children_list": True,
"collaboration_auth": True, "collaboration_auth": True,
"comment": grand_parent.link_role in ["commenter", "editor"], "comment": grand_parent.link_role in ["commentator", "editor"],
"descendants": True, "descendants": True,
"cors_proxy": True, "cors_proxy": True,
"content": True,
"destroy": False, "destroy": False,
"duplicate": False, "duplicate": False,
# Anonymous user can't favorite a document even with read access # Anonymous user can't favorite a document even with read access
@@ -147,7 +144,6 @@ def test_api_documents_retrieve_anonymous_public_parent():
"content": document.content, "content": document.content,
"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": 3, "depth": 3,
"excerpt": document.excerpt, "excerpt": document.excerpt,
"is_favorite": False, "is_favorite": False,
@@ -222,18 +218,17 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
"children_create": document.link_role == "editor", "children_create": document.link_role == "editor",
"children_list": True, "children_list": True,
"collaboration_auth": True, "collaboration_auth": True,
"comment": document.link_role in ["commenter", "editor"], "comment": document.link_role in ["commentator", "editor"],
"descendants": True, "descendants": True,
"cors_proxy": True, "cors_proxy": True,
"content": True,
"destroy": False, "destroy": False,
"duplicate": True, "duplicate": True,
"favorite": True, "favorite": True,
"invite_owner": False, "invite_owner": False,
"link_configuration": False, "link_configuration": False,
"link_select_options": { "link_select_options": {
"authenticated": ["reader", "commenter", "editor"], "authenticated": ["reader", "commentator", "editor"],
"public": ["reader", "commenter", "editor"], "public": ["reader", "commentator", "editor"],
"restricted": None, "restricted": None,
}, },
"mask": True, "mask": True,
@@ -257,7 +252,6 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
"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),
"depth": 1, "depth": 1,
"deleted_at": None,
"excerpt": document.excerpt, "excerpt": document.excerpt,
"is_favorite": False, "is_favorite": False,
"link_reach": reach, "link_reach": reach,
@@ -307,10 +301,9 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
"children_create": grand_parent.link_role == "editor", "children_create": grand_parent.link_role == "editor",
"children_list": True, "children_list": True,
"collaboration_auth": True, "collaboration_auth": True,
"comment": grand_parent.link_role in ["commenter", "editor"], "comment": grand_parent.link_role in ["commentator", "editor"],
"descendants": True, "descendants": True,
"cors_proxy": True, "cors_proxy": True,
"content": True,
"destroy": False, "destroy": False,
"duplicate": True, "duplicate": True,
"favorite": True, "favorite": True,
@@ -340,7 +333,6 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
"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),
"depth": 3, "depth": 3,
"deleted_at": None,
"excerpt": document.excerpt, "excerpt": document.excerpt,
"is_favorite": False, "is_favorite": False,
"link_reach": document.link_reach, "link_reach": document.link_reach,
@@ -454,7 +446,6 @@ def test_api_documents_retrieve_authenticated_related_direct():
"content": document.content, "content": document.content,
"creator": str(document.creator.id), "creator": str(document.creator.id),
"created_at": document.created_at.isoformat().replace("+00:00", "Z"), "created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"deleted_at": None,
"depth": 1, "depth": 1,
"excerpt": document.excerpt, "excerpt": document.excerpt,
"is_favorite": False, "is_favorite": False,
@@ -498,18 +489,17 @@ def test_api_documents_retrieve_authenticated_related_parent():
"abilities": { "abilities": {
"accesses_manage": access.role in ["administrator", "owner"], "accesses_manage": access.role in ["administrator", "owner"],
"accesses_view": True, "accesses_view": True,
"ai_transform": access.role not in ["reader", "commenter"], "ai_transform": access.role != "reader",
"ai_translate": access.role not in ["reader", "commenter"], "ai_translate": access.role != "reader",
"attachment_upload": access.role not in ["reader", "commenter"], "attachment_upload": access.role != "reader",
"can_edit": access.role not in ["reader", "commenter"], "can_edit": access.role not in ["reader", "commentator"],
"children_create": access.role not in ["reader", "commenter"], "children_create": access.role != "reader",
"children_list": True, "children_list": True,
"collaboration_auth": True, "collaboration_auth": True,
"comment": access.role != "reader", "comment": access.role != "reader",
"descendants": True, "descendants": True,
"cors_proxy": True, "cors_proxy": True,
"content": True, "destroy": access.role == "owner",
"destroy": access.role in ["administrator", "owner"],
"duplicate": True, "duplicate": True,
"favorite": True, "favorite": True,
"invite_owner": access.role == "owner", "invite_owner": access.role == "owner",
@@ -521,11 +511,11 @@ def test_api_documents_retrieve_authenticated_related_parent():
"media_auth": True, "media_auth": True,
"media_check": True, "media_check": True,
"move": access.role in ["administrator", "owner"], "move": access.role in ["administrator", "owner"],
"partial_update": access.role not in ["reader", "commenter"], "partial_update": access.role != "reader",
"restore": access.role == "owner", "restore": access.role == "owner",
"retrieve": True, "retrieve": True,
"tree": True, "tree": True,
"update": access.role not in ["reader", "commenter"], "update": access.role != "reader",
"versions_destroy": access.role in ["administrator", "owner"], "versions_destroy": access.role in ["administrator", "owner"],
"versions_list": True, "versions_list": True,
"versions_retrieve": True, "versions_retrieve": True,
@@ -538,7 +528,6 @@ def test_api_documents_retrieve_authenticated_related_parent():
"creator": str(document.creator.id), "creator": str(document.creator.id),
"created_at": document.created_at.isoformat().replace("+00:00", "Z"), "created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"depth": 3, "depth": 3,
"deleted_at": None,
"excerpt": document.excerpt, "excerpt": document.excerpt,
"is_favorite": False, "is_favorite": False,
"link_reach": "restricted", "link_reach": "restricted",
@@ -694,7 +683,6 @@ def test_api_documents_retrieve_authenticated_related_team_members(
"content": document.content, "content": document.content,
"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": False, "is_favorite": False,
@@ -761,7 +749,6 @@ def test_api_documents_retrieve_authenticated_related_team_administrators(
"content": document.content, "content": document.content,
"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": False, "is_favorite": False,
@@ -828,7 +815,6 @@ def test_api_documents_retrieve_authenticated_related_team_owners(
"content": document.content, "content": document.content,
"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": False, "is_favorite": False,
@@ -1,425 +0,0 @@
"""
Tests for Documents API endpoint in impress's core app: list
"""
import random
from json import loads as json_loads
from django.test import RequestFactory
import pytest
import responses
from faker import Faker
from rest_framework.test import APIClient
from core import factories, models
from core.services.search_indexers import get_document_indexer
fake = Faker()
pytestmark = pytest.mark.django_db
def build_search_url(**kwargs):
"""Build absolute uri for search endpoint with ORDERED query arguments"""
return (
RequestFactory()
.get("/api/v1.0/documents/search/", dict(sorted(kwargs.items())))
.build_absolute_uri()
)
@pytest.mark.parametrize("role", models.LinkRoleChoices.values)
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
@responses.activate
def test_api_documents_search_anonymous(reach, role, indexer_settings):
"""
Anonymous users should not be allowed to search documents whatever the
link reach and link role
"""
indexer_settings.SEARCH_INDEXER_QUERY_URL = "http://find/api/v1.0/search"
factories.DocumentFactory(link_reach=reach, link_role=role)
# Find response
responses.add(
responses.POST,
"http://find/api/v1.0/search",
json=[],
status=200,
)
response = APIClient().get("/api/v1.0/documents/search/", data={"q": "alpha"})
assert response.status_code == 200
assert response.json() == {
"count": 0,
"next": None,
"previous": None,
"results": [],
}
def test_api_documents_search_endpoint_is_none(indexer_settings):
"""
Missing SEARCH_INDEXER_QUERY_URL, so the indexer is not properly configured.
Should fallback on title filter
"""
indexer_settings.SEARCH_INDEXER_QUERY_URL = None
assert get_document_indexer() is None
user = factories.UserFactory()
document = factories.DocumentFactory(title="alpha")
access = factories.UserDocumentAccessFactory(document=document, user=user)
client = APIClient()
client.force_login(user)
response = client.get("/api/v1.0/documents/search/", data={"q": "alpha"})
assert response.status_code == 200
content = response.json()
results = content.pop("results")
assert content == {
"count": 1,
"next": None,
"previous": None,
}
assert len(results) == 1
assert results[0] == {
"id": str(document.id),
"abilities": 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"),
"creator": str(document.creator.id),
"depth": 1,
"excerpt": document.excerpt,
"link_reach": document.link_reach,
"link_role": document.link_role,
"nb_accesses_ancestors": 1,
"nb_accesses_direct": 1,
"numchild": 0,
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"deleted_at": None,
"user_role": access.role,
}
@responses.activate
def test_api_documents_search_invalid_params(indexer_settings):
"""Validate the format of documents as returned by the search view."""
indexer_settings.SEARCH_INDEXER_QUERY_URL = "http://find/api/v1.0/search"
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.get("/api/v1.0/documents/search/")
assert response.status_code == 400
assert response.json() == {"q": ["This field is required."]}
response = client.get("/api/v1.0/documents/search/", data={"q": " "})
assert response.status_code == 400
assert response.json() == {"q": ["This field may not be blank."]}
response = client.get(
"/api/v1.0/documents/search/", data={"q": "any", "page": "NaN"}
)
assert response.status_code == 400
assert response.json() == {"page": ["A valid integer is required."]}
@responses.activate
def test_api_documents_search_format(indexer_settings):
"""Validate the format of documents as returned by the search view."""
indexer_settings.SEARCH_INDEXER_QUERY_URL = "http://find/api/v1.0/search"
assert get_document_indexer() is not None
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
user_a, user_b, user_c = factories.UserFactory.create_batch(3)
document = factories.DocumentFactory(
title="alpha",
users=(user_a, user_c),
link_traces=(user, user_b),
)
access = factories.UserDocumentAccessFactory(document=document, user=user)
# Find response
responses.add(
responses.POST,
"http://find/api/v1.0/search",
json=[
{"_id": str(document.pk)},
],
status=200,
)
response = client.get("/api/v1.0/documents/search/", data={"q": "alpha"})
assert response.status_code == 200
content = response.json()
results = content.pop("results")
assert content == {
"count": 1,
"next": None,
"previous": None,
}
assert len(results) == 1
assert results[0] == {
"id": str(document.id),
"abilities": 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"),
"creator": str(document.creator.id),
"depth": 1,
"excerpt": document.excerpt,
"link_reach": document.link_reach,
"link_role": document.link_role,
"nb_accesses_ancestors": 3,
"nb_accesses_direct": 3,
"numchild": 0,
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"deleted_at": None,
"user_role": access.role,
}
@responses.activate
@pytest.mark.parametrize(
"pagination, status, expected",
(
(
{"page": 1, "page_size": 10},
200,
{
"count": 10,
"previous": None,
"next": None,
"range": (0, None),
},
),
(
{},
200,
{
"count": 10,
"previous": None,
"next": None,
"range": (0, None),
"api_page_size": 21, # default page_size is 20
},
),
(
{"page": 2, "page_size": 10},
404,
{},
),
(
{"page": 1, "page_size": 5},
200,
{
"count": 10,
"previous": None,
"next": {"page": 2, "page_size": 5},
"range": (0, 5),
},
),
(
{"page": 2, "page_size": 5},
200,
{
"count": 10,
"previous": {"page_size": 5},
"next": None,
"range": (5, None),
},
),
({"page": 3, "page_size": 5}, 404, {}),
),
)
def test_api_documents_search_pagination(
indexer_settings, pagination, status, expected
):
"""Documents should be ordered by descending "score" by default"""
indexer_settings.SEARCH_INDEXER_QUERY_URL = "http://find/api/v1.0/search"
assert get_document_indexer() is not None
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
docs = factories.DocumentFactory.create_batch(10, title="alpha", users=[user])
docs_by_uuid = {str(doc.pk): doc for doc in docs}
api_results = [{"_id": id} for id in docs_by_uuid.keys()]
# reorder randomly to simulate score ordering
random.shuffle(api_results)
# Find response
# pylint: disable-next=assignment-from-none
api_search = responses.add(
responses.POST,
"http://find/api/v1.0/search",
json=api_results,
status=200,
)
response = client.get(
"/api/v1.0/documents/search/",
data={
"q": "alpha",
**pagination,
},
)
assert response.status_code == status
if response.status_code < 300:
previous_url = (
build_search_url(q="alpha", **expected["previous"])
if expected["previous"]
else None
)
next_url = (
build_search_url(q="alpha", **expected["next"])
if expected["next"]
else None
)
start, end = expected["range"]
content = response.json()
assert content["count"] == expected["count"]
assert content["previous"] == previous_url
assert content["next"] == next_url
results = content.pop("results")
# The find api results ordering by score is kept
assert [r["id"] for r in results] == [r["_id"] for r in api_results[start:end]]
# Check the query parameters.
assert api_search.call_count == 1
assert api_search.calls[0].response.status_code == 200
assert json_loads(api_search.calls[0].request.body) == {
"q": "alpha",
"visited": [],
"services": ["docs"],
"nb_results": 50,
"order_by": "updated_at",
"order_direction": "desc",
}
@responses.activate
@pytest.mark.parametrize(
"pagination, status, expected",
(
(
{"page": 1, "page_size": 10},
200,
{"count": 10, "previous": None, "next": None, "range": (0, None)},
),
(
{},
200,
{"count": 10, "previous": None, "next": None, "range": (0, None)},
),
(
{"page": 2, "page_size": 10},
404,
{},
),
(
{"page": 1, "page_size": 5},
200,
{
"count": 10,
"previous": None,
"next": {"page": 2, "page_size": 5},
"range": (0, 5),
},
),
(
{"page": 2, "page_size": 5},
200,
{
"count": 10,
"previous": {"page_size": 5},
"next": None,
"range": (5, None),
},
),
({"page": 3, "page_size": 5}, 404, {}),
),
)
def test_api_documents_search_pagination_endpoint_is_none(
indexer_settings, pagination, status, expected
):
"""Documents should be ordered by descending "-updated_at" by default"""
indexer_settings.SEARCH_INDEXER_QUERY_URL = None
assert get_document_indexer() is None
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(10, title="alpha", users=[user])
response = client.get(
"/api/v1.0/documents/search/",
data={
"q": "alpha",
**pagination,
},
)
assert response.status_code == status
if response.status_code < 300:
previous_url = (
build_search_url(q="alpha", **expected["previous"])
if expected["previous"]
else None
)
next_url = (
build_search_url(q="alpha", **expected["next"])
if expected["next"]
else None
)
queryset = models.Document.objects.order_by("-updated_at")
start, end = expected["range"]
expected_results = [str(d.pk) for d in queryset[start:end]]
content = response.json()
assert content["count"] == expected["count"]
assert content["previous"] == previous_url
assert content["next"] == next_url
results = content.pop("results")
assert [r["id"] for r in results] == expected_results
File diff suppressed because it is too large Load Diff
@@ -48,11 +48,11 @@ def test_api_documents_trashbin_format():
other_users = factories.UserFactory.create_batch(3) other_users = factories.UserFactory.create_batch(3)
document = factories.DocumentFactory( document = factories.DocumentFactory(
deleted_at=timezone.now(),
users=factories.UserFactory.create_batch(2), users=factories.UserFactory.create_batch(2),
favorited_by=[user, *other_users], favorited_by=[user, *other_users],
link_traces=other_users, link_traces=other_users,
) )
document.soft_delete()
factories.UserDocumentAccessFactory(document=document, user=user, role="owner") factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
response = client.get("/api/v1.0/documents/trashbin/") response = client.get("/api/v1.0/documents/trashbin/")
@@ -70,41 +70,40 @@ def test_api_documents_trashbin_format():
assert results[0] == { assert results[0] == {
"id": str(document.id), "id": str(document.id),
"abilities": { "abilities": {
"accesses_manage": False, "accesses_manage": True,
"accesses_view": False, "accesses_view": True,
"ai_transform": False, "ai_transform": True,
"ai_translate": False, "ai_translate": True,
"attachment_upload": False, "attachment_upload": True,
"can_edit": False, "can_edit": True,
"children_create": False, "children_create": True,
"children_list": False, "children_list": True,
"collaboration_auth": False, "collaboration_auth": True,
"descendants": False, "comment": True,
"cors_proxy": False, "cors_proxy": True,
"comment": False, "descendants": True,
"content": False, "destroy": True,
"destroy": False, "duplicate": True,
"duplicate": False, "favorite": True,
"favorite": False, "invite_owner": True,
"invite_owner": False, "link_configuration": True,
"link_configuration": False,
"link_select_options": { "link_select_options": {
"authenticated": ["reader", "commenter", "editor"], "authenticated": ["reader", "commentator", "editor"],
"public": ["reader", "commenter", "editor"], "public": ["reader", "commentator", "editor"],
"restricted": None, "restricted": None,
}, },
"mask": False, "mask": True,
"media_auth": False, "media_auth": True,
"media_check": False, "media_check": True,
"move": False, # Can't move a deleted document "move": False, # Can't move a deleted document
"partial_update": False, "partial_update": True,
"restore": True, "restore": True,
"retrieve": True, "retrieve": True,
"tree": True, "tree": True,
"update": False, "update": True,
"versions_destroy": False, "versions_destroy": True,
"versions_list": False, "versions_list": True,
"versions_retrieve": False, "versions_retrieve": True,
}, },
"ancestors_link_reach": None, "ancestors_link_reach": None,
"ancestors_link_role": None, "ancestors_link_role": None,
@@ -114,7 +113,6 @@ def test_api_documents_trashbin_format():
"creator": str(document.creator.id), "creator": str(document.creator.id),
"depth": 1, "depth": 1,
"excerpt": document.excerpt, "excerpt": document.excerpt,
"deleted_at": document.ancestors_deleted_at.isoformat().replace("+00:00", "Z"),
"link_reach": document.link_reach, "link_reach": document.link_reach,
"link_role": document.link_role, "link_role": document.link_role,
"nb_accesses_ancestors": 0, "nb_accesses_ancestors": 0,
@@ -167,10 +165,10 @@ def test_api_documents_trashbin_authenticated_direct(django_assert_num_queries):
expected_ids = {str(document1.id), str(document2.id), str(document3.id)} expected_ids = {str(document1.id), str(document2.id), str(document3.id)}
with django_assert_num_queries(11): with django_assert_num_queries(10):
response = client.get("/api/v1.0/documents/trashbin/") response = client.get("/api/v1.0/documents/trashbin/")
with django_assert_num_queries(5): with django_assert_num_queries(4):
response = client.get("/api/v1.0/documents/trashbin/") response = client.get("/api/v1.0/documents/trashbin/")
assert response.status_code == 200 assert response.status_code == 200
@@ -209,10 +207,10 @@ def test_api_documents_trashbin_authenticated_via_team(
expected_ids = {str(deleted_document_team1.id), str(deleted_document_team2.id)} expected_ids = {str(deleted_document_team1.id), str(deleted_document_team2.id)}
with django_assert_num_queries(8): with django_assert_num_queries(7):
response = client.get("/api/v1.0/documents/trashbin/") response = client.get("/api/v1.0/documents/trashbin/")
with django_assert_num_queries(4): with django_assert_num_queries(3):
response = client.get("/api/v1.0/documents/trashbin/") response = client.get("/api/v1.0/documents/trashbin/")
assert response.status_code == 200 assert response.status_code == 200
@@ -294,29 +292,3 @@ def test_api_documents_trashbin_distinct():
content = response.json() content = response.json()
assert len(content["results"]) == 1 assert len(content["results"]) == 1
assert content["results"][0]["id"] == str(document.id) assert content["results"][0]["id"] == str(document.id)
def test_api_documents_trashbin_empty_queryset_bug():
"""
Test that users with no owner role don't see documents.
"""
# Create a new user with no owner access to any document
new_user = factories.UserFactory()
client = APIClient()
client.force_login(new_user)
# Create some deleted documents owned by other users
other_user = factories.UserFactory()
item1 = factories.DocumentFactory(users=[(other_user, "owner")])
item1.soft_delete()
item2 = factories.DocumentFactory(users=[(other_user, "owner")])
item2.soft_delete()
item3 = factories.DocumentFactory(users=[(other_user, "owner")])
item3.soft_delete()
response = client.get("/api/v1.0/documents/trashbin/")
assert response.status_code == 200
content = response.json()
assert content["count"] == 0
assert len(content["results"]) == 0
@@ -50,7 +50,6 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q
), ),
"creator": str(child.creator.id), "creator": str(child.creator.id),
"depth": 3, "depth": 3,
"deleted_at": None,
"excerpt": child.excerpt, "excerpt": child.excerpt,
"id": str(child.id), "id": str(child.id),
"is_favorite": False, "is_favorite": False,
@@ -74,7 +73,6 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q
"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),
"depth": 2, "depth": 2,
"deleted_at": None,
"excerpt": document.excerpt, "excerpt": document.excerpt,
"id": str(document.id), "id": str(document.id),
"is_favorite": False, "is_favorite": False,
@@ -98,7 +96,6 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q
"created_at": sibling1.created_at.isoformat().replace("+00:00", "Z"), "created_at": sibling1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(sibling1.creator.id), "creator": str(sibling1.creator.id),
"depth": 2, "depth": 2,
"deleted_at": None,
"excerpt": sibling1.excerpt, "excerpt": sibling1.excerpt,
"id": str(sibling1.id), "id": str(sibling1.id),
"is_favorite": False, "is_favorite": False,
@@ -122,7 +119,6 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q
"created_at": sibling2.created_at.isoformat().replace("+00:00", "Z"), "created_at": sibling2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(sibling2.creator.id), "creator": str(sibling2.creator.id),
"depth": 2, "depth": 2,
"deleted_at": None,
"excerpt": sibling2.excerpt, "excerpt": sibling2.excerpt,
"id": str(sibling2.id), "id": str(sibling2.id),
"is_favorite": False, "is_favorite": False,
@@ -142,7 +138,6 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"), "created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(parent.creator.id), "creator": str(parent.creator.id),
"depth": 1, "depth": 1,
"deleted_at": None,
"excerpt": parent.excerpt, "excerpt": parent.excerpt,
"id": str(parent.id), "id": str(parent.id),
"is_favorite": False, "is_favorite": False,
@@ -215,7 +210,6 @@ def test_api_documents_tree_list_anonymous_public_parent():
), ),
"creator": str(child.creator.id), "creator": str(child.creator.id),
"depth": 5, "depth": 5,
"deleted_at": None,
"excerpt": child.excerpt, "excerpt": child.excerpt,
"id": str(child.id), "id": str(child.id),
"is_favorite": False, "is_favorite": False,
@@ -239,7 +233,6 @@ def test_api_documents_tree_list_anonymous_public_parent():
), ),
"creator": str(document.creator.id), "creator": str(document.creator.id),
"depth": 4, "depth": 4,
"deleted_at": None,
"excerpt": document.excerpt, "excerpt": document.excerpt,
"id": str(document.id), "id": str(document.id),
"is_favorite": False, "is_favorite": False,
@@ -267,7 +260,6 @@ def test_api_documents_tree_list_anonymous_public_parent():
), ),
"creator": str(document_sibling.creator.id), "creator": str(document_sibling.creator.id),
"depth": 4, "depth": 4,
"deleted_at": None,
"excerpt": document_sibling.excerpt, "excerpt": document_sibling.excerpt,
"id": str(document_sibling.id), "id": str(document_sibling.id),
"is_favorite": False, "is_favorite": False,
@@ -289,7 +281,6 @@ def test_api_documents_tree_list_anonymous_public_parent():
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"), "created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(parent.creator.id), "creator": str(parent.creator.id),
"depth": 3, "depth": 3,
"deleted_at": None,
"excerpt": parent.excerpt, "excerpt": parent.excerpt,
"id": str(parent.id), "id": str(parent.id),
"is_favorite": False, "is_favorite": False,
@@ -315,7 +306,6 @@ def test_api_documents_tree_list_anonymous_public_parent():
), ),
"creator": str(parent_sibling.creator.id), "creator": str(parent_sibling.creator.id),
"depth": 3, "depth": 3,
"deleted_at": None,
"excerpt": parent_sibling.excerpt, "excerpt": parent_sibling.excerpt,
"id": str(parent_sibling.id), "id": str(parent_sibling.id),
"is_favorite": False, "is_favorite": False,
@@ -337,7 +327,6 @@ def test_api_documents_tree_list_anonymous_public_parent():
"created_at": grand_parent.created_at.isoformat().replace("+00:00", "Z"), "created_at": grand_parent.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(grand_parent.creator.id), "creator": str(grand_parent.creator.id),
"depth": 2, "depth": 2,
"deleted_at": None,
"excerpt": grand_parent.excerpt, "excerpt": grand_parent.excerpt,
"id": str(grand_parent.id), "id": str(grand_parent.id),
"is_favorite": False, "is_favorite": False,
@@ -417,7 +406,6 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated
), ),
"creator": str(child.creator.id), "creator": str(child.creator.id),
"depth": 3, "depth": 3,
"deleted_at": None,
"excerpt": child.excerpt, "excerpt": child.excerpt,
"id": str(child.id), "id": str(child.id),
"is_favorite": False, "is_favorite": False,
@@ -439,7 +427,6 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated
"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),
"depth": 2, "depth": 2,
"deleted_at": None,
"excerpt": document.excerpt, "excerpt": document.excerpt,
"id": str(document.id), "id": str(document.id),
"is_favorite": False, "is_favorite": False,
@@ -463,7 +450,6 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated
"created_at": sibling.created_at.isoformat().replace("+00:00", "Z"), "created_at": sibling.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(sibling.creator.id), "creator": str(sibling.creator.id),
"depth": 2, "depth": 2,
"deleted_at": None,
"excerpt": sibling.excerpt, "excerpt": sibling.excerpt,
"id": str(sibling.id), "id": str(sibling.id),
"is_favorite": False, "is_favorite": False,
@@ -483,7 +469,6 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"), "created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(parent.creator.id), "creator": str(parent.creator.id),
"depth": 1, "depth": 1,
"deleted_at": None,
"excerpt": parent.excerpt, "excerpt": parent.excerpt,
"id": str(parent.id), "id": str(parent.id),
"is_favorite": False, "is_favorite": False,
@@ -561,7 +546,6 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
), ),
"creator": str(child.creator.id), "creator": str(child.creator.id),
"depth": 5, "depth": 5,
"deleted_at": None,
"excerpt": child.excerpt, "excerpt": child.excerpt,
"id": str(child.id), "id": str(child.id),
"is_favorite": False, "is_favorite": False,
@@ -585,7 +569,6 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
), ),
"creator": str(document.creator.id), "creator": str(document.creator.id),
"depth": 4, "depth": 4,
"deleted_at": None,
"excerpt": document.excerpt, "excerpt": document.excerpt,
"id": str(document.id), "id": str(document.id),
"is_favorite": False, "is_favorite": False,
@@ -613,7 +596,6 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
), ),
"creator": str(document_sibling.creator.id), "creator": str(document_sibling.creator.id),
"depth": 4, "depth": 4,
"deleted_at": None,
"excerpt": document_sibling.excerpt, "excerpt": document_sibling.excerpt,
"id": str(document_sibling.id), "id": str(document_sibling.id),
"is_favorite": False, "is_favorite": False,
@@ -635,7 +617,6 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"), "created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(parent.creator.id), "creator": str(parent.creator.id),
"depth": 3, "depth": 3,
"deleted_at": None,
"excerpt": parent.excerpt, "excerpt": parent.excerpt,
"id": str(parent.id), "id": str(parent.id),
"is_favorite": False, "is_favorite": False,
@@ -661,7 +642,6 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
), ),
"creator": str(parent_sibling.creator.id), "creator": str(parent_sibling.creator.id),
"depth": 3, "depth": 3,
"deleted_at": None,
"excerpt": parent_sibling.excerpt, "excerpt": parent_sibling.excerpt,
"id": str(parent_sibling.id), "id": str(parent_sibling.id),
"is_favorite": False, "is_favorite": False,
@@ -683,7 +663,6 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
"created_at": grand_parent.created_at.isoformat().replace("+00:00", "Z"), "created_at": grand_parent.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(grand_parent.creator.id), "creator": str(grand_parent.creator.id),
"depth": 2, "depth": 2,
"deleted_at": None,
"excerpt": grand_parent.excerpt, "excerpt": grand_parent.excerpt,
"id": str(grand_parent.id), "id": str(grand_parent.id),
"is_favorite": False, "is_favorite": False,
@@ -765,7 +744,6 @@ def test_api_documents_tree_list_authenticated_related_direct():
), ),
"creator": str(child.creator.id), "creator": str(child.creator.id),
"depth": 3, "depth": 3,
"deleted_at": None,
"excerpt": child.excerpt, "excerpt": child.excerpt,
"id": str(child.id), "id": str(child.id),
"is_favorite": False, "is_favorite": False,
@@ -787,7 +765,6 @@ def test_api_documents_tree_list_authenticated_related_direct():
"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),
"depth": 2, "depth": 2,
"deleted_at": None,
"excerpt": document.excerpt, "excerpt": document.excerpt,
"id": str(document.id), "id": str(document.id),
"is_favorite": False, "is_favorite": False,
@@ -811,7 +788,6 @@ def test_api_documents_tree_list_authenticated_related_direct():
"created_at": sibling.created_at.isoformat().replace("+00:00", "Z"), "created_at": sibling.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(sibling.creator.id), "creator": str(sibling.creator.id),
"depth": 2, "depth": 2,
"deleted_at": None,
"excerpt": sibling.excerpt, "excerpt": sibling.excerpt,
"id": str(sibling.id), "id": str(sibling.id),
"is_favorite": False, "is_favorite": False,
@@ -831,7 +807,6 @@ def test_api_documents_tree_list_authenticated_related_direct():
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"), "created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(parent.creator.id), "creator": str(parent.creator.id),
"depth": 1, "depth": 1,
"deleted_at": None,
"excerpt": parent.excerpt, "excerpt": parent.excerpt,
"id": str(parent.id), "id": str(parent.id),
"is_favorite": False, "is_favorite": False,
@@ -913,7 +888,6 @@ def test_api_documents_tree_list_authenticated_related_parent():
), ),
"creator": str(child.creator.id), "creator": str(child.creator.id),
"depth": 5, "depth": 5,
"deleted_at": None,
"excerpt": child.excerpt, "excerpt": child.excerpt,
"id": str(child.id), "id": str(child.id),
"is_favorite": False, "is_favorite": False,
@@ -937,7 +911,6 @@ def test_api_documents_tree_list_authenticated_related_parent():
), ),
"creator": str(document.creator.id), "creator": str(document.creator.id),
"depth": 4, "depth": 4,
"deleted_at": None,
"excerpt": document.excerpt, "excerpt": document.excerpt,
"id": str(document.id), "id": str(document.id),
"is_favorite": False, "is_favorite": False,
@@ -965,7 +938,6 @@ def test_api_documents_tree_list_authenticated_related_parent():
), ),
"creator": str(document_sibling.creator.id), "creator": str(document_sibling.creator.id),
"depth": 4, "depth": 4,
"deleted_at": None,
"excerpt": document_sibling.excerpt, "excerpt": document_sibling.excerpt,
"id": str(document_sibling.id), "id": str(document_sibling.id),
"is_favorite": False, "is_favorite": False,
@@ -987,7 +959,6 @@ def test_api_documents_tree_list_authenticated_related_parent():
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"), "created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(parent.creator.id), "creator": str(parent.creator.id),
"depth": 3, "depth": 3,
"deleted_at": None,
"excerpt": parent.excerpt, "excerpt": parent.excerpt,
"id": str(parent.id), "id": str(parent.id),
"is_favorite": False, "is_favorite": False,
@@ -1013,7 +984,6 @@ def test_api_documents_tree_list_authenticated_related_parent():
), ),
"creator": str(parent_sibling.creator.id), "creator": str(parent_sibling.creator.id),
"depth": 3, "depth": 3,
"deleted_at": None,
"excerpt": parent_sibling.excerpt, "excerpt": parent_sibling.excerpt,
"id": str(parent_sibling.id), "id": str(parent_sibling.id),
"is_favorite": False, "is_favorite": False,
@@ -1035,7 +1005,6 @@ def test_api_documents_tree_list_authenticated_related_parent():
"created_at": grand_parent.created_at.isoformat().replace("+00:00", "Z"), "created_at": grand_parent.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(grand_parent.creator.id), "creator": str(grand_parent.creator.id),
"depth": 2, "depth": 2,
"deleted_at": None,
"excerpt": grand_parent.excerpt, "excerpt": grand_parent.excerpt,
"id": str(grand_parent.id), "id": str(grand_parent.id),
"is_favorite": False, "is_favorite": False,
@@ -1125,7 +1094,6 @@ def test_api_documents_tree_list_authenticated_related_team_members(
), ),
"creator": str(child.creator.id), "creator": str(child.creator.id),
"depth": 3, "depth": 3,
"deleted_at": None,
"excerpt": child.excerpt, "excerpt": child.excerpt,
"id": str(child.id), "id": str(child.id),
"is_favorite": False, "is_favorite": False,
@@ -1147,7 +1115,6 @@ def test_api_documents_tree_list_authenticated_related_team_members(
"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),
"depth": 2, "depth": 2,
"deleted_at": None,
"excerpt": document.excerpt, "excerpt": document.excerpt,
"id": str(document.id), "id": str(document.id),
"is_favorite": False, "is_favorite": False,
@@ -1171,7 +1138,6 @@ def test_api_documents_tree_list_authenticated_related_team_members(
"created_at": sibling.created_at.isoformat().replace("+00:00", "Z"), "created_at": sibling.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(sibling.creator.id), "creator": str(sibling.creator.id),
"depth": 2, "depth": 2,
"deleted_at": None,
"excerpt": sibling.excerpt, "excerpt": sibling.excerpt,
"id": str(sibling.id), "id": str(sibling.id),
"is_favorite": False, "is_favorite": False,
@@ -1191,7 +1157,6 @@ def test_api_documents_tree_list_authenticated_related_team_members(
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"), "created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(parent.creator.id), "creator": str(parent.creator.id),
"depth": 1, "depth": 1,
"deleted_at": None,
"excerpt": parent.excerpt, "excerpt": parent.excerpt,
"id": str(parent.id), "id": str(parent.id),
"is_favorite": False, "is_favorite": False,
@@ -1205,56 +1170,3 @@ def test_api_documents_tree_list_authenticated_related_team_members(
"updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": access.role, "user_role": access.role,
} }
def test_api_documents_tree_list_deleted_document():
"""
Tree of a deleted document should only be accessible to the owner.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
parent = factories.DocumentFactory(link_reach="public")
document, _ = factories.DocumentFactory.create_batch(2, parent=parent)
factories.DocumentFactory(link_reach="public", parent=document)
document.soft_delete()
response = client.get(f"/api/v1.0/documents/{document.id!s}/tree/")
assert response.status_code == 403
def test_api_documents_tree_list_deleted_document_owner(django_assert_num_queries):
"""
Tree of a deleted document should only be accessible to the owner.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
parent = factories.DocumentFactory(link_reach="public", users=[(user, "owner")])
document, _ = factories.DocumentFactory.create_batch(2, parent=parent)
child = factories.DocumentFactory(parent=document)
document.soft_delete()
document.refresh_from_db()
child.refresh_from_db()
with django_assert_num_queries(9):
client.get(f"/api/v1.0/documents/{document.id!s}/tree/")
with django_assert_num_queries(5):
response = client.get(f"/api/v1.0/documents/{document.id!s}/tree/")
assert response.status_code == 200
content = response.json()
assert content["id"] == str(document.id)
assert content["deleted_at"] == document.deleted_at.isoformat().replace(
"+00:00", "Z"
)
assert len(content["children"]) == 1
assert content["children"][0]["id"] == str(child.id)
assert content["children"][0][
"deleted_at"
] == child.ancestors_deleted_at.isoformat().replace("+00:00", "Z")
@@ -1,44 +0,0 @@
"""Test user light serializer."""
import pytest
from core import factories
from core.api.serializers import UserLightSerializer
pytestmark = pytest.mark.django_db
def test_user_light_serializer():
"""Test user light serializer."""
user = factories.UserFactory(
email="test@test.com",
full_name="John Doe",
short_name="John",
)
serializer = UserLightSerializer(user)
assert serializer.data["full_name"] == "John Doe"
assert serializer.data["short_name"] == "John"
def test_user_light_serializer_no_full_name():
"""Test user light serializer without full name."""
user = factories.UserFactory(
email="test_foo@test.com",
full_name=None,
short_name="John",
)
serializer = UserLightSerializer(user)
assert serializer.data["full_name"] == "test_foo"
assert serializer.data["short_name"] == "John"
def test_user_light_serializer_no_short_name():
"""Test user light serializer without short name."""
user = factories.UserFactory(
email="test_foo@test.com",
full_name=None,
short_name=None,
)
serializer = UserLightSerializer(user)
assert serializer.data["full_name"] == "test_foo"
assert serializer.data["short_name"] == "test_foo"
@@ -0,0 +1,775 @@
"""
Test template accesses API endpoints for users in impress's core app.
"""
import random
from uuid import uuid4
import pytest
from rest_framework.test import APIClient
from core import factories, models
from core.api import serializers
from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
def test_api_template_accesses_list_anonymous():
"""Anonymous users should not be allowed to list template accesses."""
template = factories.TemplateFactory()
factories.UserTemplateAccessFactory.create_batch(2, template=template)
response = APIClient().get(f"/api/v1.0/templates/{template.id!s}/accesses/")
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_template_accesses_list_authenticated_unrelated():
"""
Authenticated users should not be allowed to list template accesses for a template
to which they are not related.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
factories.UserTemplateAccessFactory.create_batch(3, template=template)
# Accesses for other templates to which the user is related should not be listed either
other_access = factories.UserTemplateAccessFactory(user=user)
factories.UserTemplateAccessFactory(template=other_access.template)
response = client.get(
f"/api/v1.0/templates/{template.id!s}/accesses/",
)
assert response.status_code == 200
assert response.json() == []
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_list_authenticated_related(via, mock_user_teams):
"""
Authenticated users should be able to list template accesses for a template
to which they are directly related, whatever their role in the template.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
user_access = None
if via == USER:
user_access = models.TemplateAccess.objects.create(
template=template,
user=user,
role=random.choice(models.RoleChoices.values),
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
user_access = models.TemplateAccess.objects.create(
template=template,
team="lasuite",
role=random.choice(models.RoleChoices.values),
)
access1 = factories.TeamTemplateAccessFactory(template=template)
access2 = factories.UserTemplateAccessFactory(template=template)
# Accesses for other templates to which the user is related should not be listed either
other_access = factories.UserTemplateAccessFactory(user=user)
factories.UserTemplateAccessFactory(template=other_access.template)
response = client.get(
f"/api/v1.0/templates/{template.id!s}/accesses/",
)
assert response.status_code == 200
content = response.json()
assert len(content) == 3
assert sorted(content, key=lambda x: x["id"]) == sorted(
[
{
"id": str(user_access.id),
"user": str(user.id) if via == "user" else None,
"team": "lasuite" if via == "team" else "",
"role": user_access.role,
"abilities": user_access.get_abilities(user),
},
{
"id": str(access1.id),
"user": None,
"team": access1.team,
"role": access1.role,
"abilities": access1.get_abilities(user),
},
{
"id": str(access2.id),
"user": str(access2.user.id),
"team": "",
"role": access2.role,
"abilities": access2.get_abilities(user),
},
],
key=lambda x: x["id"],
)
def test_api_template_accesses_retrieve_anonymous():
"""
Anonymous users should not be allowed to retrieve a template access.
"""
access = factories.UserTemplateAccessFactory()
response = APIClient().get(
f"/api/v1.0/templates/{access.template_id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_template_accesses_retrieve_authenticated_unrelated():
"""
Authenticated users should not be allowed to retrieve a template access for
a template to which they are not related.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
access = factories.UserTemplateAccessFactory(template=template)
response = client.get(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
# Accesses related to another template should be excluded even if the user is related to it
for access in [
factories.UserTemplateAccessFactory(),
factories.UserTemplateAccessFactory(user=user),
]:
response = client.get(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 404
assert response.json() == {
"detail": "No TemplateAccess matches the given query."
}
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_retrieve_authenticated_related(via, mock_user_teams):
"""
A user who is related to a template should be allowed to retrieve the
associated template user accesses.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(template=template, team="lasuite")
access = factories.UserTemplateAccessFactory(template=template)
response = client.get(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 200
assert response.json() == {
"id": str(access.id),
"user": str(access.user.id),
"team": "",
"role": access.role,
"abilities": access.get_abilities(user),
}
def test_api_template_accesses_update_anonymous():
"""Anonymous users should not be allowed to update a template access."""
access = factories.UserTemplateAccessFactory()
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.values),
}
api_client = APIClient()
for field, value in new_values.items():
response = api_client.put(
f"/api/v1.0/templates/{access.template_id!s}/accesses/{access.id!s}/",
{**old_values, field: value},
format="json",
)
assert response.status_code == 401
access.refresh_from_db()
updated_values = serializers.TemplateAccessSerializer(instance=access).data
assert updated_values == old_values
def test_api_template_accesses_update_authenticated_unrelated():
"""
Authenticated users should not be allowed to update a template access for a template to which
they are not related.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
access = factories.UserTemplateAccessFactory()
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.values),
}
for field, value in new_values.items():
response = client.put(
f"/api/v1.0/templates/{access.template_id!s}/accesses/{access.id!s}/",
{**old_values, field: value},
format="json",
)
assert response.status_code == 403
access.refresh_from_db()
updated_values = serializers.TemplateAccessSerializer(instance=access).data
assert updated_values == old_values
@pytest.mark.parametrize("role", ["reader", "editor"])
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_update_authenticated_editor_or_reader(
via, role, mock_user_teams
):
"""Editors or readers of a template should not be allowed to update its accesses."""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role=role
)
access = factories.UserTemplateAccessFactory(template=template)
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.values),
}
for field, value in new_values.items():
response = client.put(
f"/api/v1.0/templates/{access.template_id!s}/accesses/{access.id!s}/",
{**old_values, field: value},
format="json",
)
assert response.status_code == 403
access.refresh_from_db()
updated_values = serializers.TemplateAccessSerializer(instance=access).data
assert updated_values == old_values
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_update_administrator_except_owner(via, mock_user_teams):
"""
A user who is a direct administrator in a template should be allowed to update a user
access for this template, as long as they don't try to set the role to owner.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(
template=template, user=user, role="administrator"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="administrator"
)
access = factories.UserTemplateAccessFactory(
template=template,
role=random.choice(["administrator", "editor", "reader"]),
)
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(["administrator", "editor", "reader"]),
}
for field, value in new_values.items():
new_data = {**old_values, field: value}
response = client.put(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
)
if (
new_data["role"] == old_values["role"]
): # we are not really updating the role
assert response.status_code == 403
else:
assert response.status_code == 200
access.refresh_from_db()
updated_values = serializers.TemplateAccessSerializer(instance=access).data
if field == "role":
assert updated_values == {**old_values, "role": new_values["role"]}
else:
assert updated_values == old_values
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_update_administrator_from_owner(via, mock_user_teams):
"""
A user who is an administrator in a template, should not be allowed to update
the user access of an "owner" for this template.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(
template=template, user=user, role="administrator"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="administrator"
)
other_user = factories.UserFactory()
access = factories.UserTemplateAccessFactory(
template=template, user=other_user, role="owner"
)
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.values),
}
for field, value in new_values.items():
response = client.put(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
data={**old_values, field: value},
format="json",
)
assert response.status_code == 403
access.refresh_from_db()
updated_values = serializers.TemplateAccessSerializer(instance=access).data
assert updated_values == old_values
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_update_administrator_to_owner(via, mock_user_teams):
"""
A user who is an administrator in a template, should not be allowed to update
the user access of another user to grant template ownership.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(
template=template, user=user, role="administrator"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="administrator"
)
other_user = factories.UserFactory()
access = factories.UserTemplateAccessFactory(
template=template,
user=other_user,
role=random.choice(["administrator", "editor", "reader"]),
)
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": "owner",
}
for field, value in new_values.items():
new_data = {**old_values, field: value}
response = client.put(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
)
# We are not allowed or not really updating the role
if field == "role" or new_data["role"] == old_values["role"]:
assert response.status_code == 403
else:
assert response.status_code == 200
access.refresh_from_db()
updated_values = serializers.TemplateAccessSerializer(instance=access).data
assert updated_values == old_values
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_update_owner(via, mock_user_teams):
"""
A user who is an owner in a template should be allowed to update
a user access for this template whatever the role.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role="owner")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="owner"
)
factories.UserFactory()
access = factories.UserTemplateAccessFactory(
template=template,
)
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.values),
}
for field, value in new_values.items():
new_data = {**old_values, field: value}
response = client.put(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
)
if (
new_data["role"] == old_values["role"]
): # we are not really updating the role
assert response.status_code == 403
else:
assert response.status_code == 200
access.refresh_from_db()
updated_values = serializers.TemplateAccessSerializer(instance=access).data
if field == "role":
assert updated_values == {**old_values, "role": new_values["role"]}
else:
assert updated_values == old_values
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_update_owner_self(via, mock_user_teams):
"""
A user who is owner of a template should be allowed to update
their own user access provided there are other owners in the template.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
access = factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="owner"
)
else:
access = factories.UserTemplateAccessFactory(
template=template, user=user, role="owner"
)
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_role = random.choice(["administrator", "editor", "reader"])
response = client.put(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
data={**old_values, "role": new_role},
format="json",
)
assert response.status_code == 403
access.refresh_from_db()
assert access.role == "owner"
# Add another owner and it should now work
factories.UserTemplateAccessFactory(template=template, role="owner")
response = client.put(
f"/api/v1.0/templates/{template.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
def test_api_template_accesses_delete_anonymous():
"""Anonymous users should not be allowed to destroy a template access."""
access = factories.UserTemplateAccessFactory()
response = APIClient().delete(
f"/api/v1.0/templates/{access.template_id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 401
assert models.TemplateAccess.objects.count() == 1
def test_api_template_accesses_delete_authenticated():
"""
Authenticated users should not be allowed to delete a template access for a
template to which they are not related.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
access = factories.UserTemplateAccessFactory()
response = client.delete(
f"/api/v1.0/templates/{access.template_id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 403
assert models.TemplateAccess.objects.count() == 2
@pytest.mark.parametrize("role", ["reader", "editor"])
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_delete_editor_or_reader(via, role, mock_user_teams):
"""
Authenticated users should not be allowed to delete a template access for a
template in which they are a simple editor or reader.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role=role
)
access = factories.UserTemplateAccessFactory(template=template)
assert models.TemplateAccess.objects.count() == 3
assert models.TemplateAccess.objects.filter(user=access.user).exists()
response = client.delete(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 403
assert models.TemplateAccess.objects.count() == 3
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_delete_administrators_except_owners(
via, mock_user_teams
):
"""
Users who are administrators in a template should be allowed to delete an access
from the template provided it is not ownership.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(
template=template, user=user, role="administrator"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="administrator"
)
access = factories.UserTemplateAccessFactory(
template=template, role=random.choice(["reader", "editor", "administrator"])
)
assert models.TemplateAccess.objects.count() == 2
assert models.TemplateAccess.objects.filter(user=access.user).exists()
response = client.delete(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 204
assert models.TemplateAccess.objects.count() == 1
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_delete_administrator_on_owners(via, mock_user_teams):
"""
Users who are administrators in a template should not be allowed to delete an ownership
access from the template.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(
template=template, user=user, role="administrator"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="administrator"
)
access = factories.UserTemplateAccessFactory(template=template, role="owner")
assert models.TemplateAccess.objects.count() == 3
assert models.TemplateAccess.objects.filter(user=access.user).exists()
response = client.delete(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 403
assert models.TemplateAccess.objects.count() == 3
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_delete_owners(via, mock_user_teams):
"""
Users should be able to delete the template access of another user
for a template of which they are owner.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role="owner")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="owner"
)
access = factories.UserTemplateAccessFactory(template=template)
assert models.TemplateAccess.objects.count() == 2
assert models.TemplateAccess.objects.filter(user=access.user).exists()
response = client.delete(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 204
assert models.TemplateAccess.objects.count() == 1
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_delete_owners_last_owner(via, mock_user_teams):
"""
It should not be possible to delete the last owner access from a template
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
access = None
if via == USER:
access = factories.UserTemplateAccessFactory(
template=template, user=user, role="owner"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
access = factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="owner"
)
assert models.TemplateAccess.objects.count() == 2
response = client.delete(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 403
assert models.TemplateAccess.objects.count() == 2
@@ -0,0 +1,206 @@
"""
Test template accesses create API endpoint for users in impress's core app.
"""
import random
import pytest
from rest_framework.test import APIClient
from core import factories, models
from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
def test_api_template_accesses_create_anonymous():
"""Anonymous users should not be allowed to create template accesses."""
template = factories.TemplateFactory()
other_user = factories.UserFactory()
response = APIClient().post(
f"/api/v1.0/templates/{template.id!s}/accesses/",
{
"user": str(other_user.id),
"template": str(template.id),
"role": random.choice(models.RoleChoices.values),
},
format="json",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
assert models.TemplateAccess.objects.exists() is False
def test_api_template_accesses_create_authenticated_unrelated():
"""
Authenticated users should not be allowed to create template accesses for a template to
which they are not related.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
other_user = factories.UserFactory()
template = factories.TemplateFactory()
response = client.post(
f"/api/v1.0/templates/{template.id!s}/accesses/",
{
"user": str(other_user.id),
},
format="json",
)
assert response.status_code == 403
assert not models.TemplateAccess.objects.filter(user=other_user).exists()
@pytest.mark.parametrize("role", ["reader", "editor"])
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_create_authenticated_editor_or_reader(
via, role, mock_user_teams
):
"""Editors or readers of a template should not be allowed to create template accesses."""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role=role
)
other_user = factories.UserFactory()
for new_role in [role[0] for role in models.RoleChoices.choices]:
response = client.post(
f"/api/v1.0/templates/{template.id!s}/accesses/",
{
"user": str(other_user.id),
"role": new_role,
},
format="json",
)
assert response.status_code == 403
assert not models.TemplateAccess.objects.filter(user=other_user).exists()
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_create_authenticated_administrator(via, mock_user_teams):
"""
Administrators of a template should be able to create template accesses
except for the "owner" role.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(
template=template, user=user, role="administrator"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="administrator"
)
other_user = factories.UserFactory()
# It should not be allowed to create an owner access
response = client.post(
f"/api/v1.0/templates/{template.id!s}/accesses/",
{
"user": str(other_user.id),
"role": "owner",
},
format="json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "Only owners of a template can assign other users as owners."
}
# It should be allowed to create a lower access
role = random.choice(
[role[0] for role in models.RoleChoices.choices if role[0] != "owner"]
)
response = client.post(
f"/api/v1.0/templates/{template.id!s}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == 201
assert models.TemplateAccess.objects.filter(user=other_user).count() == 1
new_template_access = models.TemplateAccess.objects.filter(user=other_user).get()
assert response.json() == {
"abilities": new_template_access.get_abilities(user),
"id": str(new_template_access.id),
"team": "",
"role": role,
"user": str(other_user.id),
}
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_create_authenticated_owner(via, mock_user_teams):
"""
Owners of a template should be able to create template accesses whatever the role.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role="owner")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="owner"
)
other_user = factories.UserFactory()
role = random.choice([role[0] for role in models.RoleChoices.choices])
response = client.post(
f"/api/v1.0/templates/{template.id!s}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == 201
assert models.TemplateAccess.objects.filter(user=other_user).count() == 1
new_template_access = models.TemplateAccess.objects.filter(user=other_user).get()
assert response.json() == {
"id": str(new_template_access.id),
"user": str(other_user.id),
"team": "",
"role": role,
"abilities": new_template_access.get_abilities(user),
}
@@ -0,0 +1,48 @@
"""
Tests for Templates API endpoint in impress's core app: create
"""
import pytest
from rest_framework.test import APIClient
from core import factories
from core.models import Template
pytestmark = pytest.mark.django_db
def test_api_templates_create_anonymous():
"""Anonymous users should not be allowed to create templates."""
response = APIClient().post(
"/api/v1.0/templates/",
{
"title": "my template",
},
)
assert response.status_code == 401
assert not Template.objects.exists()
def test_api_templates_create_authenticated():
"""
Authenticated users should be able to create templates and should automatically be declared
as the owner of the newly created template.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/templates/",
{
"title": "my template",
},
format="json",
)
assert response.status_code == 201
template = Template.objects.get()
assert template.title == "my template"
assert template.accesses.filter(role="owner", user=user).exists()

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