Compare commits

..

5 Commits

Author SHA1 Message Date
Manuel Raynaud
fed3ad6a81 fixup! 📝(doc) add documentation to install using compose 2025-01-28 16:31:49 +01:00
Manuel Raynaud
350643a4c8 📝(doc) add documentation to install using compose
Write the documentation related to the docker compose installation.
2025-01-28 12:35:30 +01:00
Manuel Raynaud
6f62d8ec2a (compose) a config to deploy docs in production using compose
To ease the deployment of the application on infrastructure not running
k8s we created a compose file for production.
2025-01-28 12:35:30 +01:00
Manuel Raynaud
24328b5d6b ♻️(compose) remove usage of dockerize
Compose is able to manage service status to be healthy in its
dependecies. Configuring a healthcheck on services then defining a
condition on the depends_on settings allow to remove dockerize
2025-01-28 10:41:20 +01:00
Manuel Raynaud
9179fdb2fa 🔧(compose) rename legacy docker-compose.yml in compose.yaml
The compose configuration preferred filename is now compose.yaml
docker-compose.yml is the legacy one used by docker-compose v1
2025-01-28 10:41:18 +01:00
442 changed files with 23078 additions and 37506 deletions

View File

@@ -7,11 +7,10 @@ on:
- 'release/**'
jobs:
install-dependencies:
uses: ./.github/workflows/dependencies.yml
install-front:
uses: ./.github/workflows/front-dependencies-installation.yml
with:
node_version: '20.x'
with-front-dependencies-installation: true
synchronize-with-crowdin:
runs-on: ubuntu-latest

View File

@@ -7,15 +7,13 @@ on:
- main
jobs:
install-dependencies:
uses: ./.github/workflows/dependencies.yml
install-front:
uses: ./.github/workflows/front-dependencies-installation.yml
with:
node_version: '20.x'
with-front-dependencies-installation: true
with-build_mails: true
synchronize-with-crowdin:
needs: install-dependencies
needs: install-front
runs-on: ubuntu-latest
steps:
@@ -31,13 +29,6 @@ jobs:
- name: Install development dependencies
run: pip install --user .
working-directory: src/backend
- name: Restore the mail templates
uses: actions/cache@v4
id: mail-templates
with:
path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
fail-on-cache-miss: true
- name: Install gettext
run: |
sudo apt-get update

View File

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

View File

@@ -11,7 +11,6 @@ on:
pull_request:
branches:
- 'main'
- 'ci/trivy-fails'
env:
DOCKER_USER: 1001:127
@@ -39,6 +38,7 @@ jobs:
with:
docker-build-args: '--target backend-production -f Dockerfile'
docker-image-name: 'docker.io/lasuite/impress-backend:${{ github.sha }}'
continue-on-error: true
-
name: Build and push
uses: docker/build-push-action@v6
@@ -72,6 +72,7 @@ jobs:
with:
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
docker-image-name: 'docker.io/lasuite/impress-frontend:${{ github.sha }}'
continue-on-error: true
-
name: Build and push
uses: docker/build-push-action@v6
@@ -105,7 +106,8 @@ jobs:
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '-f src/frontend/servers/y-provider/Dockerfile --target y-provider'
docker-image-name: 'docker.io/lasuite/impress-y-provider:${{ github.sha }}'
docker-image-name: 'docker.io/lasuite/impress-frontend:${{ github.sha }}'
continue-on-error: true
-
name: Build and push
uses: docker/build-push-action@v6
@@ -123,11 +125,15 @@ jobs:
- build-and-push-frontend
- build-and-push-backend
runs-on: ubuntu-latest
if: github.event_name != 'pull_request'
if: |
github.event_name != 'pull_request'
steps:
- uses: numerique-gouv/action-argocd-webhook-notification@main
id: notify
with:
deployment_repo_path: "${{ secrets.DEPLOYMENT_REPO_URL }}"
argocd_webhook_secret: "${{ secrets.ARGOCD_PREPROD_WEBHOOK_SECRET }}"
argocd_url: "${{ vars.ARGOCD_PREPROD_WEBHOOK_URL }}"
-
name: Checkout repository
uses: actions/checkout@v4
-
name: Call argocd github webhook
run: |
data='{"ref": "'$GITHUB_REF'","repository": {"html_url":"'$GITHUB_SERVER_URL'/'$GITHUB_REPOSITORY'"}}'
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${{ secrets.ARGOCD_PREPROD_WEBHOOK_SECRET}}'' | awk '{print "X-Hub-Signature: sha1="$2}')
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" ${{ vars.ARGOCD_PREPROD_WEBHOOK_URL }}

View File

@@ -0,0 +1,36 @@
name: Install frontend installation reusable workflow
on:
workflow_call:
inputs:
node_version:
required: false
default: '20.x'
type: string
jobs:
front-dependencies-installation:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Restore the frontend cache
uses: actions/cache@v4
id: front-node_modules
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
- name: Setup Node.js
if: steps.front-node_modules.outputs.cache-hit != 'true'
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node_version }}
- name: Install dependencies
if: steps.front-node_modules.outputs.cache-hit != 'true'
run: cd src/frontend/ && yarn install --frozen-lockfile
- name: Cache install frontend
if: steps.front-node_modules.outputs.cache-hit != 'true'
uses: actions/cache@v4
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}

View File

@@ -11,7 +11,7 @@ jobs:
helmfile-lint:
runs-on: ubuntu-latest
container:
image: ghcr.io/helmfile/helmfile:v0.171.0
image: ghcr.io/helmfile/helmfile:latest
steps:
-
name: Checkout repository
@@ -22,9 +22,9 @@ jobs:
run: |
set -e
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 '/environments:/ {flag=1; next} flag && NF {print} !NF {flag=0}' "$HELMFILE" | grep -E '^[[:space:]]{2}[a-zA-Z]+' | sed 's/^[[:space:]]*//;s/:.*//')
for env in $environments; do
echo "################### $env lint ###################"
helmfile -e $env -f $HELMFILE lint || exit 1
echo -e "\n"
done
done

View File

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

View File

@@ -9,11 +9,6 @@ on:
- "*"
jobs:
install-dependencies:
uses: ./.github/workflows/dependencies.yml
with:
with-build_mails: true
lint-git:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' # Makes sense only for pull requests
@@ -61,6 +56,46 @@ jobs:
exit 1
fi
build-mails:
runs-on: ubuntu-latest
defaults:
run:
working-directory: src/mail
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: "18"
- name: Restore the mail templates
uses: actions/cache@v4
id: mail-templates
with:
path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
- name: Install yarn
if: steps.mail-templates.outputs.cache-hit != 'true'
run: npm install -g yarn
- name: Install node dependencies
if: steps.mail-templates.outputs.cache-hit != 'true'
run: yarn install --frozen-lockfile
- name: Build mails
if: steps.mail-templates.outputs.cache-hit != 'true'
run: yarn build
- name: Cache mail templates
if: steps.mail-templates.outputs.cache-hit != 'true'
uses: actions/cache@v4
with:
path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
lint-back:
runs-on: ubuntu-latest
defaults:
@@ -86,7 +121,7 @@ jobs:
test-back:
runs-on: ubuntu-latest
needs: install-dependencies
needs: build-mails
defaults:
run:
@@ -134,7 +169,6 @@ jobs:
with:
path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
fail-on-cache-miss: true
- name: Start MinIO
run: |

1
.gitignore vendored
View File

@@ -41,6 +41,7 @@ ENV/
env.bak/
venv.bak/
env.d/development/*
env.d/production/*
!env.d/development/*.dist
env.d/terraform

View File

@@ -6,188 +6,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0),
and this project adheres to
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## Added
- 🚩 add homepage feature flag #861
## [3.1.0] - 2025-04-07
## Added
- 🚩(backend) add feature flag for the footer #841
- 🔧(backend) add view to manage footer json #841
- ✨(frontend) add custom css style #771
- 🚩(frontend) conditionally render AI button only when feature is enabled #814
## Changed
- 🚨(frontend) block button when creating doc #749
## Fixed
- 🐛(back) validate document content in serializer #822
- 🐛(frontend) fix selection click past end of content #840
## [3.0.0] - 2025-03-28
## Added
- 📄(legal) Require contributors to sign a DCO #779
## Changed
- ♻️(frontend) Integrate UI kit #783
- 🏗️(y-provider) manage auth in y-provider app #804
## Fixed
- 🐛(backend) compute ancestor_links in get_abilities if needed #725
- 🔒️(back) restrict access to document accesses #801
## [2.6.0] - 2025-03-21
## Added
- 📝(doc) add publiccode.yml #770
## Changed
- 🚸(frontend) ctrl+k modal not when editor is focused #712
## Fixed
- 🐛(back) allow only images to be used with the cors-proxy #781
- 🐛(backend) stop returning inactive users on the list endpoint #636
- 🔒️(backend) require at least 5 characters to search for users #636
- 🔒️(back) throttle user list endpoint #636
- 🔒️(back) remove pagination and limit to 5 for user list endpoint #636
## [2.5.0] - 2025-03-18
## Added
- 📝(doc) Added GNU Make link to README #750
- ✨(frontend) add pinning on doc detail #711
- 🚩(frontend) feature flag analytic on copy as html #649
- ✨(frontend) Custom block divider with export #698
- 🌐(i18n) activate dutch language #742
- ✨(frontend) add Beautify action to AI transform #478
- ✨(frontend) add Emojify action to AI transform #478
## Changed
- 🧑‍💻(frontend) change literal section open source #702
- ♻️(frontend) replace cors proxy for export #695
- 🚨(gitlint) Allow uppercase in commit messages #756
- ♻️(frontend) Improve AI translations #478
## Fixed
- 🐛(frontend) SVG export #706
- 🐛(frontend) remove scroll listener table content #688
- 🔒️(back) restrict access to favorite_list endpoint #690
- 🐛(backend) refactor to fix filtering on children
and descendants views #695
- 🐛(action) fix notify-argocd workflow #713
- 🚨(helm) fix helmfile lint #736
- 🚚(frontend) redirect to 401 page when 401 error #759
## [2.4.0] - 2025-03-06
## Added
- ✨(frontend) synchronize language-choice #401
## Changed
- Use sentry tags instead of extra scope
## Fixed
- 🐛(frontend) fix collaboration error #684
## [2.3.0] - 2025-03-03
## Added
- ✨(backend) limit link reach/role select options depending on ancestors #645
- ✨(backend) add new "descendants" action to document API endpoint #645
- ✨(backend) new "tree" action on document detail endpoint #645
- ✨(backend) allow forcing page size within limits #645
- 💄(frontend) add error pages #643
- 🔒️ Manage unsafe attachments #663
- ✨(frontend) Custom block quote with export #646
- ✨(frontend) add open source section homepage #666
- ✨(frontend) synchronize language-choice #401
## Changed
- 🛂(frontend) Restore version visibility #629
- 📝(doc) minor README.md formatting and wording enhancements
-Stop setting a default title on doc creation #634
- ♻️(frontend) misc ui improvements #644
## Fixed
- 🐛(backend) allow any type of extensions for media download #671
- ♻️(frontend) improve table pdf rendering
- 🐛(email) invitation emails in receivers language
## [2.2.0] - 2025-02-10
## Added
- 📝(doc) Add security.md and codeofconduct.md #604
- ✨(frontend) add home page #608
- ✨(frontend) cursor display on activity #609
- ✨(frontend) Add export page break #623
## Changed
- 🔧(backend) make AI feature reach configurable #628
## Fixed
- 🌐(CI) Fix email partially translated #616
- 🐛(frontend) fix cursor breakline #609
- 🐛(frontend) fix style pdf export #609
## [2.1.0] - 2025-01-29
## Added
- ✨(backend) add duplicate action to the document API endpoint
- ⚗️(backend) add util to extract text from base64 yjs document
- ✨(backend) add soft delete and restore API endpoints to documents #516
- ✨(backend) allow organizing documents in a tree structure #516
- ✨(backend) add "excerpt" field to document list serializer #516
- ✨(backend) add github actions to manage Crowdin workflow #559 & #563
- github actions to managed Crowdin workflow
- 📈Integrate Posthog #540
- 🏷️(backend) add content-type to uploaded files #552
- ✨(frontend) export pdf docx front side #537
## Changed
- 💄(frontend) add abilities on doc row #581
- 💄(frontend) improve DocsGridItem responsive padding #582
- 🔧(backend) Bump maximum page size to 200 #516
- 📝(doc) Improve Read me #558
## Fixed
- 🐛Fix invitations #575
## Removed
- 🔥(backend) remove "content" field from list serializer # 516
## [2.0.1] - 2025-01-17
@@ -206,8 +37,6 @@ and this project adheres to
- 💄(frontend) add filtering to left panel #475
- ✨(frontend) new share modal ui #489
- ✨(frontend) add favorite feature #515
- 📝(documentation) Documentation about self-hosted installation #530
- ✨(helm) helm versioning #530
## Changed
@@ -219,7 +48,7 @@ and this project adheres to
- 💄(frontend) update DocHeader ui #448
- 💄(frontend) update doc versioning ui #463
- 💄(frontend) update doc summary ui #473
- 📝(doc) update readme.md to match V2 changes #558 & #572
- 📝(doc) update readme.md to match V2 changes #558
## Fixed
@@ -243,11 +72,12 @@ and this project adheres to
- ⚡️(e2e) reduce flakiness on e2e tests #511
## Fixed
## Fixed
- 🐛(frontend) update doc editor height #481
- 💄(frontend) add doc search #485
## [1.9.0] - 2024-12-11
## Added
@@ -269,18 +99,21 @@ and this project adheres to
- 🐛(frontend) Fix hidden menu on Firefox #468
- 🐛(backend) fix sanitize problem IA #490
## [1.8.2] - 2024-11-28
## Changed
- ♻️(SW) change strategy html caching #460
## [1.8.1] - 2024-11-27
## Fixed
- 🐛(frontend) link not clickable and flickering firefox #457
## [1.8.0] - 2024-11-25
## Added
@@ -309,6 +142,7 @@ and this project adheres to
- 🐛(frontend) users have view access when revoked #387
- 🐛(frontend) fix placeholder editable when double clicks #454
## [1.7.0] - 2024-10-24
## Added
@@ -336,6 +170,7 @@ and this project adheres to
- 🔥(helm) remove infra related codes #366
## [1.6.0] - 2024-10-17
## Added
@@ -358,6 +193,7 @@ and this project adheres to
- 🐛(backend) fix nginx docker container #340
- 🐛(frontend) fix copy paste firefox #353
## [1.5.1] - 2024-10-10
## Fixed
@@ -392,6 +228,7 @@ and this project adheres to
- 🔧(backend) fix configuration to avoid different ssl warning #297
- 🐛(frontend) fix editor break line not working #302
## [1.4.0] - 2024-09-17
## Added
@@ -412,6 +249,7 @@ and this project adheres to
- 🐛(backend) Fix forcing ID when creating a document via API endpoint #234
- 🐛 Rebuild frontend dev container from makefile #248
## [1.3.0] - 2024-09-05
## Added
@@ -436,6 +274,7 @@ and this project adheres to
- 🔥(frontend) remove saving modal #213
## [1.2.1] - 2024-08-23
## Changed
@@ -443,6 +282,7 @@ and this project adheres to
- ♻️ Change ordering docs datagrid #195
- 🔥(helm) use scaleway email #194
## [1.2.0] - 2024-08-22
## Added
@@ -466,14 +306,14 @@ and this project adheres to
- ⚡️(CI) only e2e chrome mandatory #177
## Removed
- 🔥(helm) remove htaccess #181
## [1.1.0] - 2024-07-15
## Added
- 🤡(demo) generate dummy documents on dev users #120
- 🤡(demo) generate dummy documents on dev users #120
- ✨(frontend) create side modal component #134
- ✨(frontend) Doc grid actions (update / delete) #136
- ✨(frontend) Doc editor header information #137
@@ -484,11 +324,12 @@ and this project adheres to
- ♻️(frontend) create a doc from a modal #132
- ♻️(frontend) manage members from the share modal #140
## [1.0.0] - 2024-07-02
## Added
- 🛂(frontend) Manage the document's right (#75)
- 🛂(frontend) Manage the document's right (#75)
- ✨(frontend) Update document (#68)
- ✨(frontend) Remove document (#68)
- 🐳(docker) dockerize dev frontend (#63)
@@ -522,6 +363,7 @@ and this project adheres to
- 💚(CI) Remove trigger workflow on push tags on CI (#68)
- 🔥(frontend) Remove coming soon page (#121)
## [0.1.0] - 2024-05-24
## Added
@@ -529,15 +371,8 @@ and this project adheres to
- ✨(frontend) Coming Soon page (#67)
- 🚀 Impress, project to manage your documents easily and collaboratively.
[unreleased]: https://github.com/numerique-gouv/impress/compare/v3.1.0...main
[v3.1.0]: https://github.com/numerique-gouv/impress/releases/v3.1.0
[v3.0.0]: https://github.com/numerique-gouv/impress/releases/v3.0.0
[v2.6.0]: https://github.com/numerique-gouv/impress/releases/v2.6.0
[v2.5.0]: https://github.com/numerique-gouv/impress/releases/v2.5.0
[v2.4.0]: https://github.com/numerique-gouv/impress/releases/v2.4.0
[v2.3.0]: https://github.com/numerique-gouv/impress/releases/v2.3.0
[v2.2.0]: https://github.com/numerique-gouv/impress/releases/v2.2.0
[v2.1.0]: https://github.com/numerique-gouv/impress/releases/v2.1.0
[unreleased]: https://github.com/numerique-gouv/impress/compare/v2.0.1...main
[v2.0.1]: https://github.com/numerique-gouv/impress/releases/v2.0.1
[v2.0.0]: https://github.com/numerique-gouv/impress/releases/v2.0.0
[v1.10.0]: https://github.com/numerique-gouv/impress/releases/v1.10.0

View File

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

View File

@@ -2,16 +2,7 @@
Thank you for taking the time to contribute! Please follow these guidelines to ensure a smooth and productive workflow. 🚀🚀🚀
To get started with the project, please refer to the [README.md](https://github.com/suitenumerique/docs/blob/main/README.md) for detailed instructions.
Contributors are required to sign off their commits with `git commit --sign-off`: this confirms that they have read and accepted the [Developer's Certificate of Origin 1.1](https://developercertificate.org/).
Please also check out our [dev handbook](https://suitenumerique.gitbook.io/handbook) to learn our best practices.
## Help us with translations
You can help us with translations on [Crowdin](https://crowdin.com/project/lasuite-docs).
Your language is not there? Request it on our Crowdin page 😊.
To get started with the project, please refer to the [README.md](https://github.com/numerique-gouv/impress/blob/main/README.md) for detailed instructions.
## Creating an Issue
@@ -37,7 +28,7 @@ All commit messages must adhere to the following format:
* <**gitmoji**>: Use a gitmoji to represent the purpose of the commit. For example, ✨ for adding a new feature or 🔥 for removing something, see the list here: <https://gitmoji.dev/>.
* **(type)**: Describe the type of change. Common types include `backend`, `frontend`, `CI`, `docker` etc...
* **title**: A short, descriptive title for the change.
* **title**: A short, descriptive title for the change, starting with a lowercase character.
* **description**: Include additional details about what was changed and why.
### Example Commit Message
@@ -48,10 +39,6 @@ All commit messages must adhere to the following format:
Implemented login and signup features, and integrated OAuth2 for social login.
```
## Signing commits
Only signed commits are accepted. They can be signed using a SSH or GPG key. Github documentation about signing commits contains all the information you need : https://docs.github.com/en/authentication/managing-commit-signature-verification/about-commit-signature-verification#about-commit-signature-verification
## Changelog Update
Please add a line to the changelog describing your development. The changelog entry should include a brief summary of the changes, this helps in tracking changes effectively and keeping everyone informed. We usually include the title of the pull request, followed by the pull request ID to finish the log entry. The changelog line should be less than 80 characters in total.

View File

@@ -15,13 +15,6 @@ FROM base AS back-builder
WORKDIR /builder
# Install Rust and Cargo using Alpine's package manager
RUN apk add --no-cache \
build-base \
libffi-dev \
rust \
cargo
# Copy required python dependencies
COPY ./src/backend /builder
@@ -79,6 +72,7 @@ RUN apk add \
gettext \
gdk-pixbuf \
libffi-dev \
pandoc \
pango \
shared-mime-info

View File

@@ -38,7 +38,8 @@ DB_PORT = 5432
DOCKER_UID = $(shell id -u)
DOCKER_GID = $(shell id -g)
DOCKER_USER = $(DOCKER_UID):$(DOCKER_GID)
COMPOSE = DOCKER_USER=$(DOCKER_USER) docker compose
COMPOSE = DOCKER_USER=$(DOCKER_USER) ./bin/compose
COMPOSE_PRODUCTION = DOCKER_USER=$(DOCKER_USER) COMPOSE_FILE=compose.production.yaml ./bin/compose
COMPOSE_EXEC = $(COMPOSE) exec
COMPOSE_EXEC_APP = $(COMPOSE_EXEC) app-dev
COMPOSE_RUN = $(COMPOSE) run --rm
@@ -64,6 +65,19 @@ data/media:
data/static:
@mkdir -p data/static
# -- production volumes
data/production/media:
@mkdir -p data/production/media
data/production/certs:
@mkdir -p data/production/certs
data/production/databases/backend:
@mkdir -p data/production/databases/backend
data/production/databases/keycloak:
@mkdir -p data/production/databases/keycloak
# -- Project
create-env-files: ## Copy the dist env files to env files
@@ -80,14 +94,35 @@ bootstrap: \
data/static \
create-env-files \
build \
run-with-frontend \
migrate \
demo \
back-i18n-compile \
mails-install \
mails-build \
run
mails-build
.PHONY: bootstrap
bootstrap-production: ## Prepare project to run in production mode using docker compose
bootstrap-production: \
env.d/production \
data/production/media \
data/production/certs \
data/production/databases/backend \
data/production/databases/keycloak
bootstrap-production:
@echo 'Environment files created in env.d/production'
@echo 'Edit them to set good value for your production environment'
.PHONY: bootstrap-production
run-production: ## Run compose project in production mode
@$(COMPOSE_PRODUCTION) up -d ingress
.PHONY: run-production
stop-production: ## Stop compose project in production mode
@$(COMPOSE_PRODUCTION) stop
.PHONY: stop-production
# -- Docker/compose
build: cache ?= --no-cache
build: ## build the project containers
@@ -108,7 +143,7 @@ build-yjs-provider: ## build the y-provider container
build-frontend: cache ?=
build-frontend: ## build the frontend container
@$(COMPOSE) build frontend $(cache)
@$(COMPOSE) build frontend-dev $(cache)
.PHONY: build-frontend
down: ## stop and remove containers, networks, images, and volumes
@@ -119,18 +154,17 @@ logs: ## display app-dev logs (follow mode)
@$(COMPOSE) logs -f app-dev
.PHONY: logs
run-backend: ## Start only the backend application and all needed services
run: ## start the wsgi (production) and development server
@$(COMPOSE) up --force-recreate -d celery-dev
@$(COMPOSE) up --force-recreate -d y-provider
@$(COMPOSE) up --force-recreate -d nginx
.PHONY: run-backend
run: ## start the wsgi (production) and development server
run:
@$(MAKE) run-backend
@$(COMPOSE) up --force-recreate -d frontend
.PHONY: run
run-with-frontend: ## Start all the containers needed (backend to frontend)
@$(MAKE) run
@$(COMPOSE) up --force-recreate -d frontend-dev
.PHONY: run-with-frontend
status: ## an alias for "docker compose ps"
@$(COMPOSE) ps
.PHONY: status
@@ -225,6 +259,8 @@ resetdb: ## flush database and create a superuser "admin"
@${MAKE} superuser
.PHONY: resetdb
# -- Environment variable files
env.d/development/common:
cp -n env.d/development/common.dist env.d/development/common
@@ -234,6 +270,9 @@ env.d/development/postgresql:
env.d/development/kc_postgresql:
cp -n env.d/development/kc_postgresql.dist env.d/development/kc_postgresql
env.d/production:
cp -rnf env.d/production.dist env.d/production
# -- Internationalization
env.d/development/crowdin:
@@ -306,16 +345,16 @@ help:
.PHONY: help
# Front
frontend-development-install: ## install the frontend locally
frontend-install: ## install the frontend locally
cd $(PATH_FRONT_IMPRESS) && yarn
.PHONY: frontend-development-install
.PHONY: frontend-install
frontend-lint: ## run the frontend linter
cd $(PATH_FRONT) && yarn lint
.PHONY: frontend-lint
run-frontend-development: ## Run the frontend in development mode
@$(COMPOSE) stop frontend
@$(COMPOSE) stop frontend-dev
cd $(PATH_FRONT_IMPRESS) && yarn dev
.PHONY: run-frontend-development

View File

@@ -1,6 +1,6 @@
<p align="center">
<a href="https://github.com/suitenumerique/docs">
<img alt="Docs" src="/docs/assets/docs-logo.png" width="300" />
<img alt="Docs" src="/docs/assets/logo-docs.png" width="300" />
</a>
</p>
@@ -13,72 +13,63 @@ Welcome to Docs! The open source document editor where your notes can become kno
Chat on Matrix
</a> - <a href="/docs/">
Documentation
</a> - <a href="#getting-started-">
</a> - <a href="#getting-started">
Getting started
</a> - <a href="mailto:docs@numerique.gouv.fr">
Reach out
</a>
</p>
<img src="/docs/assets/docs_live_collaboration_light.gif" width="100%" align="center"/>
## Why use Docs ❓
⚠️ **Note that Docs provides docs/pdf exporters by loading [two BlockNote packages](https://github.com/suitenumerique/docs/blob/main/src/frontend/apps/impress/package.json#L22C7-L23C53), which we use under the AGPL-3.0 licence. Until we comply with the terms of this license, we recommend that you don't run Docs as a commercial product, unless you are willing to sponsor [BlockNote](https://github.com/TypeCellOS/BlockNote).**
Docs is a collaborative text editor designed to address common challenges in knowledge building and sharing.
### Write
* 😌 Simple collaborative editing without the formatting complexity of markdown
* 🔌 Offline? No problem, keep writing, your edits will get synced when back online
* 💅 Create clean documents with limited but beautiful formatting options and focus on content
* 🧱 Built for productivity (markdown support, many block types, slash commands, keyboard shortcuts).
* 🧱 Built for productivity (markdown support, many block types, slash commands, markdown support, keyboard shortcuts) (page in french sorry 😅).
* ✨ Save time thanks to our AI actions (generate, sum up, correct, translate)
### Collaborate
* 🤝 Collaborate with your team in real time
* 🔒 Granular access control to ensure your information is secure and only shared with the right people
* 🤝 Collaborate in realtime with your team mates
* 🔒 Granular access control to keep your information secure and shared with the right people
* 📑 Professional document exports in multiple formats (.odt, .doc, .pdf) with customizable templates
* 📚 Built-in wiki functionality to turn your team's collaborative work into organized knowledge `ETA 02/2025`
* 📚 Built-in wiki functionality to transform your team's collaborative work into organized knowledge `ETA 02/2025`
### Self-host
* 🚀 Easy to install, scalable and secure alternative to Notion, Outline or Confluence
## Getting started 🔧
### Test it
Test Docs on your browser by visiting this [demo document](https://impress-preprod.beta.numerique.gouv.fr/docs/6ee5aac4-4fb9-457d-95bf-bb56c2467713/)
Test Docs on your browser by logging in on this [environment](https://impress-preprod.beta.numerique.gouv.fr/docs/0aa856e9-da41-4d59-b73d-a61cb2c1245f/)
```
email: test.docs@yopmail.com
password: I'd<3ToTestDocs
```
### Run it locally
> ⚠️ Running Docs locally using the methods described below is for testing purposes only. It is based on building Docs using Minio as the S3 storage solution but you can choose any S3 compatible object storage of your choice.
**Prerequisite**
Make sure you have a recent version of Docker and [Docker Compose](https://docs.docker.com/compose/install) installed on your laptop:
```shellscript
$ docker -v
Docker version 20.10.2, build 2291f61
Docker version 27.4.1, build b9d17ea
$ docker compose version
Docker Compose version v2.32.4
Docker Compose version v2.32.1
```
> ⚠️ You may need to run the following commands with sudo but this can be avoided by adding your user to the `docker` group.
> ⚠️ You may need to run the following commands with sudo but this can be avoided by assigning your user to the `docker` group.
**Project bootstrap**
The easiest way to start working on the project is to use [GNU Make](https://www.gnu.org/software/make/):
The easiest way to start working on the project is to use GNU Make:
```shellscript
$ make bootstrap FLUSH_ARGS='--no-input'
```
This command builds the `app` container, installs dependencies, performs database migrations and compile translations. It's a good idea to use this command each time you are pulling code from the project repository to avoid dependency-related or migration-related issues.
This command builds the `app` container, installs dependencies, performs database migrations and compile translations. It's a good idea to use this
command each time you are pulling code from the project repository to avoid dependency-releated or migration-releated issues.
Your Docker services should now be up and running 🎉
@@ -86,23 +77,24 @@ You can access to the project by going to <http://localhost:3000>.
You will be prompted to log in, the default credentials are:
```
```shellscript
username: impress
password: impress
```
📝 Note that if you need to run them afterwards, you can use the eponym Make rule:
```shellscript
$ make run
$ make run-with-frontend
```
⚠️ For the frontend developer, it is often better to run the frontend in development mode locally.
⚠️ For the frontend developper, it is often better to run the frontend in development mode locally.
To do so, install the frontend dependencies with the following command:
```shellscript
$ make frontend-development-install
$ make frontend-install
```
And run the frontend locally in development mode with the following command:
@@ -114,7 +106,7 @@ $ make run-frontend-development
To start all the services, except the frontend container, you can use the following command:
```shellscript
$ make run-backend
$ make run
```
**Adding content**
@@ -131,7 +123,6 @@ $ make help
```
**Django admin**
You can access the Django admin site at
<http://localhost:8071/admin>.
@@ -143,26 +134,20 @@ $ make superuser
```
## Feedback 🙋‍♂️🙋‍♀️
We'd love to hear your thoughts and hear about your experiments, so come and say hi on [Matrix](https://matrix.to/#/#docs-official:matrix.org).
## Roadmap
Want to know where the project is headed? [🗺️ Checkout our roadmap](https://github.com/orgs/numerique-gouv/projects/13/views/11)
## Licence 📝
This work is released under the MIT License (see [LICENSE](https://github.com/suitenumerique/docs/blob/main/LICENSE)).
While Docs is a public driven initiative our licence choice is an invitation for private sector actors to use, sell and contribute to the project.
While Docs is public driven initiative our licence choice is an invitation for private sector actors to use, sell and contribute to the project.
## Contributing 🙌
This project is intended to be community-driven, so please, do not hesitate to get in touch if you have any question related to our implementation or design decisions.
This project is intended to be community-driven, so please, do not hesitate to [get in touch](https://matrix.to/#/#docs-official:matrix.org) if you have any question related to our implementation or design decisions.
You can help us with translations on [Crowdin](https://crowdin.com/project/lasuite-docs).
If you intend to make pull requests see [CONTRIBUTING](https://github.com/suitenumerique/docs/blob/main/CONTRIBUTING.md) for guidelines.
If you intend to make pull requests see CONTRIBUTING for guidelines.
Directory structure:
@@ -179,19 +164,8 @@ docs
```
## Credits ❤️
### Stack
Impress is built on top of [Django Rest Framework](https://www.django-rest-framework.org/), [Next.js](https://nextjs.org/), [MinIO](https://min.io/) and [BlocNote.js](https://www.blocknotejs.org/)
Docs is built on top of [Django Rest Framework](https://www.django-rest-framework.org/), [Next.js](https://nextjs.org/), [BlockNote.js](https://www.blocknotejs.org/), [HocusPocus](https://tiptap.dev/docs/hocuspocus/introduction) and [Yjs](https://yjs.dev/).
### Gov ❤️ open source
Docs is the result of a joint effort led by the French 🇫🇷🥖 ([DINUM](https://www.numerique.gouv.fr/dinum/)) and German 🇩🇪🥨 governments ([ZenDiS](https://zendis.de/)).
We are proud sponsors of [BlockNotejs](https://www.blocknotejs.org/) and [Yjs](https://yjs.dev/).
We are always looking for new public partners (we are currently onboarding the Netherlands 🇳🇱🧀), feel free to [reach out](mailto:docs@numerique.gouv.fr) if you are interested in using or contributing to Docs.
<p align="center">
<img src="/docs/assets/europe_opensource.png" width="50%"/>
</p>
### States ❤️ open source
Docs is the result of a joint effort lead by the French 🇫🇷🥖 ([DINUM](https://www.numerique.gouv.fr/dinum/)) and German 🇩🇪🥨 government ([ZenDiS](https://zendis.de/)). We are always looking for new public partners feel free to reach out if you are interested in using or contributing to docs.

View File

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

View File

@@ -15,20 +15,3 @@ the following command inside your docker container:
(Note : in your development environment, you can `make migrate`.)
## [Unreleased]
## [3.0.0] - 2025-03-28
We are not using the nginx auth request anymore to access the collaboration server (`yProvider`)
The authentication is now managed directly from the yProvider server.
You must remove the annotation `nginx.ingress.kubernetes.io/auth-url` from the `ingressCollaborationWS`.
This means as well that the yProvider server must be able to access the Django server.
To do so, you must set the `COLLABORATION_BACKEND_BASE_URL` environment variable to the `yProvider`
service.
## [2.2.0] - 2025-02-10
- AI features are now limited to users who are authenticated. Before this release, even anonymous
users who gained editor access on a document with link reach used to get AI feature.
IF you want anonymous users to keep access on AI features, you must now define the
`AI_ALLOW_REACH_FROM` setting to "public".

View File

@@ -39,9 +39,6 @@ docker_build(
]
)
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', resource_deps=['impress-docs-backend-migrate'])
k8s_yaml(local('cd ../src/helm && helmfile -n impress -e dev template .'))
migration = '''

View File

@@ -6,8 +6,9 @@ REPO_DIR="$(cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd)"
UNSET_USER=0
TERRAFORM_DIRECTORY="./env.d/terraform"
COMPOSE_FILE="${REPO_DIR}/docker-compose.yml"
if [ -z ${COMPOSE_FILE+x} ]; then
COMPOSE_FILE="${REPO_DIR}/compose.yaml"
fi
# _set_user: set (or unset) default user id used to run docker commands
#
@@ -39,7 +40,7 @@ function _set_user() {
# ARGS : docker compose command arguments
function _docker_compose() {
echo "🐳(compose) file: '${COMPOSE_FILE}'"
echo "🐳(compose) project, file: '${COMPOSE_FILE}'"
docker compose \
-f "${COMPOSE_FILE}" \
--project-directory "${REPO_DIR}" \

17
bin/update_app_cacert.sh Executable file
View File

@@ -0,0 +1,17 @@
#!/bin/sh
set -o errexit
# The script is pretty simple. It downloads the latest cacert.pem file from the certifi package and appends the root certificate from mkcert to it. Then it copies the updated cacert.pem file to the container.
# The script is executed with the following command:
# $ bin/update_app_cacert.sh docs-production-backend-1
CONTAINER_NAME=${1:-"docs-production-backend-1"}
echo "updating cacert.pem for certifi package in ${CONTAINER_NAME}"
curl --create-dirs https://raw.githubusercontent.com/certifi/python-certifi/refs/heads/master/certifi/cacert.pem -o /tmp/certifi/cacert.pem
cat "$(mkcert -CAROOT)/rootCA.pem" >> /tmp/certifi/cacert.pem
docker cp /tmp/certifi/cacert.pem ${CONTAINER_NAME}:/usr/local/lib/python3.12/site-packages/certifi/cacert.pem
echo "end patching cacert.pem in ${CONTAINER_NAME}"

167
compose.production.yaml Normal file
View File

@@ -0,0 +1,167 @@
name: docs-production
services:
postgresql:
image: postgres:16
healthcheck:
test: ["CMD", "pg_isready", "-q", "-U", "docs", "-d", "docs"]
interval: 1s
timeout: 2s
retries: 300
env_file:
- env.d/production/postgresql
environment:
- PGDATA=/var/lib/postgresql/data/pgdata
volumes:
- ./data/production/databases/backend:/var/lib/postgresql/data/pgdata
redis:
image: redis:5
backend-migration:
image: lasuite/impress-backend:latest
user: ${DOCKER_USER:-1000}
command: ["python", "manage.py", "migrate", "--noinput"]
environment:
- DJANGO_CONFIGURATION=Production
env_file:
- env.d/production/backend
- env.d/production/postgresql
- env.d/production/yprovider
depends_on:
postgresql:
condition: service_healthy
restart: true
redis:
condition: service_started
minio:
condition: service_started
backend:
image: lasuite/impress-backend:latest
user: ${DOCKER_USER:-1000}
restart: always
environment:
- DJANGO_CONFIGURATION=Production
env_file:
- env.d/production/backend
- env.d/production/postgresql
- env.d/production/yprovider
healthcheck:
test: ["CMD", "python", "manage.py", "check"]
interval: 15s
timeout: 30s
retries: 20
start_period: 10s
depends_on:
postgresql:
condition: service_healthy
restart: true
backend-migration:
condition: service_completed_successfully
redis:
condition: service_started
minio:
condition: service_started
minio-bootstrap:
condition: service_completed_successfully
celery:
user: ${DOCKER_USER:-1000}
image: lasuite/impress-backend:latest
command: ["celery", "-A", "impress.celery_app", "worker", "-l", "INFO"]
environment:
- DJANGO_CONFIGURATION=Production
env_file:
- env.d/production/backend
- env.d/production/postgresql
- env.d/production/yprovider
depends_on:
- backend
frontend:
image: lasuite/impress-frontend:latest
user: ${DOCKER_USER:-1000}
y-provider:
image: lasuite/impress-y-provider:latest
user: ${DOCKER_USER:-1000}
env_file:
- env.d/production/yprovider
kc_postgresql:
image: postgres:16
healthcheck:
test: ["CMD", "pg_isready", "-q", "-U", "keycloak", "-d", "keycloak"]
interval: 1s
timeout: 2s
retries: 300
env_file:
- env.d/production/kc_postgresql
environment:
- PGDATA=/var/lib/postgresql/data/pgdata
volumes:
- ./data/production/databases/keycloak:/var/lib/postgresql/data/pgdata
keycloak:
image: quay.io/keycloak/keycloak:26.1.0
command: ["start"]
env_file:
- env.d/production/keycloak
- env.d/production/kc_postgresql
ports:
- "8443:8443"
volumes:
- ${DOCS_PROD_KEYCLOAK_CERT_FOLDER:-./data/production/certs}:/etc/ssl/certs:ro
depends_on:
kc_postgresql:
condition: service_healthy
restart: true
minio-bootstrap:
image: minio/mc
env_file:
- env.d/production/minio
depends_on:
minio:
condition: service_healthy
restart: true
entrypoint: >
sh -c "
/usr/bin/mc alias set docs http://minio:9000 $${MINIO_ROOT_USER} $${MINIO_ROOT_PASSWORD} && \
/usr/bin/mc mb --ignore-existing docs/docs-media-storage && \
/usr/bin/mc version enable docs/docs-media-storage && \
exit 0;"
minio:
user: ${DOCKER_USER:-1000}
image: minio/minio
env_file:
- env.d/production/minio
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 1s
timeout: 20s
retries: 300
entrypoint: ""
command: minio server /data
volumes:
- ./data/production/media:/data
ingress:
image: nginx:1.27
ports:
- "${DOCS_PROD_NGING_PORT:-443}:8083"
volumes:
- ./docker/files/production/etc/nginx/conf.d:/etc/nginx/conf.d:ro
- ${DOCS_PROD_NGINX_CERT_FOLDER:-./data/production/certs}:/etc/nginx/ssl:ro
depends_on:
frontend:
condition: service_started
y-provider:
condition: service_started
keycloak:
condition: service_started
backend:
condition: service_healthy
restart: true

View File

@@ -4,7 +4,7 @@ services:
postgresql:
image: postgres:16
healthcheck:
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
test: ["CMD-SHELL", "pg_isready"]
interval: 1s
timeout: 2s
retries: 300
@@ -139,15 +139,11 @@ services:
volumes:
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
depends_on:
app-dev:
condition: service_started
y-provider:
condition: service_started
keycloak:
condition: service_healthy
restart: true
- keycloak
- app-dev
- y-provider
frontend:
frontend-dev:
user: "${DOCKER_USER:-1000}"
build:
context: .
@@ -185,23 +181,19 @@ services:
context: .
dockerfile: ./src/frontend/servers/y-provider/Dockerfile
target: y-provider
command: ["yarn", "workspace", "server-y-provider", "run", "dev"]
working_dir: /app/frontend
restart: unless-stopped
env_file:
- env.d/development/common
ports:
- "4444:4444"
volumes:
- ./src/frontend/:/app/frontend
kc_postgresql:
image: postgres:14.3
healthcheck:
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
test: ["CMD-SHELL", "pg_isready"]
interval: 1s
timeout: 2s
retries: 300
retries: 300
ports:
- "5433:5432"
env_file:
@@ -220,13 +212,6 @@ services:
- --hostname-admin-url=http://localhost:8083/
- --hostname-strict=false
- --hostname-strict-https=false
- --health-enabled=true
- --metrics-enabled=true
healthcheck:
test: ["CMD", "curl", "--head", "-fsS", "http://localhost:8080/health/ready"]
interval: 1s
timeout: 2s
retries: 300
environment:
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin

View File

@@ -4,6 +4,54 @@ server {
server_name localhost;
charset utf-8;
# Proxy auth for collaboration server
location /collaboration/ws/ {
# Collaboration Auth request configuration
auth_request /collaboration-auth;
auth_request_set $authHeader $upstream_http_authorization;
auth_request_set $canEdit $upstream_http_x_can_edit;
auth_request_set $userId $upstream_http_x_user_id;
# Pass specific headers from the auth response
proxy_set_header Authorization $authHeader;
proxy_set_header X-Can-Edit $canEdit;
proxy_set_header X-User-Id $userId;
# Ensure WebSocket upgrade
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
# Collaboration server
proxy_pass http://y-provider:4444;
# Set appropriate timeout for WebSocket
proxy_read_timeout 86400;
proxy_send_timeout 86400;
# Preserve original host and additional headers
proxy_set_header Host $host;
}
location /collaboration-auth {
proxy_pass http://app-dev:8000/api/v1.0/documents/collaboration-auth/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Original-URL $request_uri;
# Prevent the body from being passed
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Original-Method $request_method;
}
location /collaboration/api/ {
# Collaboration server
proxy_pass http://y-provider:4444;
proxy_set_header Host $host;
}
# Proxy auth for media
location /media/ {
# Auth request configuration
@@ -20,8 +68,6 @@ server {
# Get resource from Minio
proxy_pass http://minio:9000/impress-media-storage/;
proxy_set_header Host minio:9000;
add_header Content-Security-Policy "default-src 'none'" always;
}
location /media-auth {
@@ -42,11 +88,5 @@ server {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# Increase proxy buffer size to allow keycloak to send large
# header responses when a user is created.
proxy_buffer_size 128k;
proxy_buffers 4 256k;
proxy_busy_buffers_size 256k;
}
}

View File

@@ -0,0 +1,132 @@
upstream docs_backend {
server backend:8000 fail_timeout=0;
}
upstream docs_frontend {
server frontend:8080 fail_timeout=0;
}
server {
listen 8083 ssl;
server_name localhost;
# Disables server version feedback on pages and in headers
server_tokens off;
ssl_certificate /etc/nginx/ssl/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/privkey.pem;
location @proxy_to_docs_backend {
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_redirect off;
proxy_pass http://docs_backend;
}
location @proxy_to_docs_frontend {
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_redirect off;
proxy_pass http://docs_frontend;
}
location / {
try_files $uri @proxy_to_docs_frontend;
}
location /api {
try_files $uri @proxy_to_docs_backend;
}
location /admin {
try_files $uri @proxy_to_docs_backend;
}
# Proxy auth for collaboration server
location /collaboration/ws/ {
# Collaboration Auth request configuration
auth_request /collaboration-auth;
proxy_set_header X-Forwarded-Proto https;
auth_request_set $authHeader $upstream_http_authorization;
auth_request_set $canEdit $upstream_http_x_can_edit;
auth_request_set $userId $upstream_http_x_user_id;
# Pass specific headers from the auth response
proxy_set_header Authorization $authHeader;
proxy_set_header X-Can-Edit $canEdit;
proxy_set_header X-User-Id $userId;
# Ensure WebSocket upgrade
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
# Collaboration server
proxy_pass http://y-provider:4444;
# Set appropriate timeout for WebSocket
proxy_read_timeout 86400;
proxy_send_timeout 86400;
# Preserve original host and additional headers
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Origin $http_origin;
proxy_set_header Host $host;
}
location /collaboration-auth {
proxy_pass http://docs_backend/api/v1.0/documents/collaboration-auth/;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Original-URL $request_uri;
# Prevent the body from being passed
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Original-Method $request_method;
}
location /collaboration/api/ {
# Collaboration server
proxy_pass http://y-provider:4444;
proxy_set_header Host $host;
}
# Proxy auth for media
location /media/ {
# Auth request configuration
auth_request /media-auth;
auth_request_set $authHeader $upstream_http_authorization;
auth_request_set $authDate $upstream_http_x_amz_date;
auth_request_set $authContentSha256 $upstream_http_x_amz_content_sha256;
# Pass specific headers from the auth response
proxy_set_header Authorization $authHeader;
proxy_set_header X-Amz-Date $authDate;
proxy_set_header X-Amz-Content-SHA256 $authContentSha256;
# Get resource from Minio
proxy_pass http://minio:9000/docs-media-storage/;
proxy_set_header Host minio:9000;
}
location /media-auth {
proxy_pass http://docs_backend/api/v1.0/documents/media-auth/;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Original-URL $request_uri;
# Prevent the body from being passed
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Original-Method $request_method;
}
}

View File

@@ -1,193 +0,0 @@
## Decision TLDR;
We will use Yjs a CRDT-based library for the collaborative editing of the documents.
## Status
Accepted
## Context
We need to implement a collaborative editing feature for the documents that supports real-time collaboration, offline capabilities, and seamless integration with our Django backend.
## Considered alternatives
### ProseMirror
A robust toolkit for building rich-text editors with collaboration capabilities.
| Pros | Cons |
| --- | --- |
| Mature ecosystem | Complex integration with Django |
| Rich text editing features | Steeper learning curve |
| Used by major companies | More complex to implement offline support |
| Large community | |
### ShareDB
Real-time database backend based on Operational Transformation.
| Pros | Cons |
| --- | --- |
| Battle-tested in production | Complex setup required |
| Strong consistency model | Requires specific backend architecture |
| Good documentation | Less flexible with different backends |
| | Higher latency compared to CRDTs |
### Convergence
Complete enterprise solution for real-time collaboration.
| Pros | Cons |
| --- | --- |
| Full-featured solution | Commercial licensing |
| Built-in presence features | Less community support |
| Enterprise support | More expensive |
| Good offline support | Overkill for basic needs |
### CRDT-based Solutions Comparison
A CRDT-based library specifically designed for real-time collaboration.
| Category | Pros | Cons |
|----------|------|------|
| Technical Implementation | • Native real-time collaboration<br>• No central conflict resolution needed<br>• Works well with Django backend<br>• Automatic state synchronization | • Learning curve for CRDT concepts<br>• More complex initial setup<br>• Additional metadata overhead |
| User Experience | • Instant local updates<br>• Works offline by default<br>• Low latency<br>• Smooth concurrent editing | • Eventual consistency might cause brief inconsistencies<br>• UI must handle temporary conflicts |
| Performance | • Excellent scaling with multiple users<br>• Reduced server load<br>• Efficient network usage<br>• Good memory optimization (especially Yjs) | • Slightly higher memory usage<br>• Initial state sync can be larger |
| Development | • No need to build conflict resolution<br>• Simple integration with text editors<br>• Future-proof architecture | • Team needs to learn new concepts<br>• Fewer ready-made solutions<br>• May need to build some features from scratch |
| Maintenance | • Less server infrastructure<br>• Simpler deployment<br>• Fewer points of failure | • Debugging can be more complex<br>• State management requires careful handling |
| Business Impact | • Better offline support for users<br>• Scales well as user base grows<br>• No licensing costs (with Yjs) | • Initial development time might be longer<br>• Team training required |
#### Yjs
- **Type**: State-based CRDT
- **Implementation**: JavaScript/TypeScript
- **Features**:
- Rich text collaboration
- Shared types (Array, Map, XML)
- Binary encoding
- P2P support
- **Performance**: Excellent for text editing
- **Memory Usage**: Optimized
- **License**: MIT
#### Automerge
- **Type**: Operation-based CRDT
- **Implementation**: JavaScript/Rust
- **Features**:
- JSON-like data structures
- Change history
- Undo/Redo
- Binary format
- **Performance**: Good, with Rust backend
- **Memory Usage**: Higher than Yjs
- **License**: MIT
#### Legion
- **Type**: State-based CRDT
- **Implementation**: Rust with JS bindings
- **Features**:
- High performance
- Memory efficient
- Binary protocol
- **Performance**: Excellent
- **Memory Usage**: Very efficient
- **License**: Apache 2.0
#### Diamond Types
- **Type**: Operation-based CRDT
- **Implementation**: TypeScript
- **Features**:
- Specialized for text
- Small memory footprint
- Simple API
- **Performance**: Good for text
- **Memory Usage**: Efficient
- **License**: MIT
Comparison Table:
| Feature | Yjs | Automerge | Legion | Diamond Types |
|---------|-----|-----------|--------|---------------|
| Text Editing | ✅ Excellent | ✅ Good | ⚠️ Basic | ✅ Excellent |
| Structured Data | ✅ | ✅ | ✅ | ⚠️ |
| Memory Efficiency | ✅ High | ⚠️ Medium | ✅ Very High | ✅ High |
| Network Efficiency | ✅ | ⚠️ | ✅ | ✅ |
| Maturity | ✅ | ✅ | ⚠️ | ⚠️ |
| Community Size | ✅ Large | ✅ Large | ⚠️ Small | ⚠️ Small |
| Documentation | ✅ | ✅ | ⚠️ | ⚠️ |
| Backend Options | ✅ Many | ✅ Many | ⚠️ Limited | ⚠️ Limited |
Key Differences:
1. **Implementation Approach**:
- Yjs: Optimized for text and rich-text editing
- Automerge: General-purpose JSON CRDT
- Legion: Performance-focused with Rust
- Diamond Types: Specialized for text collaboration
2. **Performance Characteristics**:
- Yjs: Best for text editing scenarios
- Automerge: Good all-around performance
- Legion: Excellent raw performance
- Diamond Types: Optimized for text
3. **Ecosystem Integration**:
- Yjs: Wide range of integrations
- Automerge: Good JavaScript ecosystem
- Legion: Limited but growing
- Diamond Types: Focused on text editors
This analysis reinforces our choice of Yjs for the CRDT-based option as it provides:
- Best-in-class text editing performance
- Mature ecosystem
- Active community
- Excellent documentation
- Wide range of backend options
## Decision
After evaluating the alternatives, we choose Yjs for the following reasons:
1. **Technical Fit:**
- Native CRDT support ensures reliable collaboration
- Excellent offline capabilities
- Good performance characteristics
- Flexible backend integration options
2. **Project Requirements Match:**
- Easy integration with our Django backend
- Supports our core collaborative features
- Manageable learning curve for the team
3. **Community & Support:**
- Active development
- Growing community
- Good documentation
- Open source with MIT license
### Comparison of Key Features:
| Feature | Yjs (CRDT) | ProseMirror | ShareDB | Convergence |
|---------|-----|-------------|----------|-------------|
| Real-time Collaboration | ✅ | ✅ | ✅ | ✅ |
| Offline Support | ✅ | ⚠️ | ⚠️ | ✅ |
| Django Integration | Easy | Complex | Complex | Moderate |
| Learning Curve | Medium | High | High | Medium |
| Cost | Free | Free | Free | Paid |
| Community Size | Growing | Large | Medium | Small |
## Consequences
### Positive
- Simplified implementation of real-time collaboration
- Good developer experience
- Future-proof technology choice
- No licensing costs
### Negative
- Team needs to learn CRDT concepts
- Newer technology compared to alternatives
- May need to build some features available out-of-the-box in other solutions
### Risks
- Community support might not grow as expected
- May discover limitations as we scale

View File

@@ -1,19 +0,0 @@
## Architecture
### Global system architecture
```mermaid
flowchart TD
User -- HTTP --> Front("Frontend (NextJS SPA)")
Front -- REST API --> Back("Backend (Django)")
Front -- WebSocket --> Yserver("Microservice Yjs (Express)") -- WebSocket --> CollaborationServer("Collaboration server (Hocuspocus)") -- REST API <--> Back
Front -- OIDC --> Back -- OIDC ---> OIDC("Keycloak / ProConnect")
Back -- REST API --> Yserver
Back --> DB("Database (PostgreSQL)")
Back <--> Celery --> DB
Back ----> S3("Minio (S3)")
```
### Architecture decision records
- [ADR-0001-20250106-use-yjs-for-docs-editing](./adr/ADR-0001-20250106-use-yjs-for-docs-editing.md)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

BIN
docs/assets/logo-docs.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

View File

@@ -1,99 +0,0 @@
# Docs variables
Here we describe all environment variables that can be set for the docs application.
## impress-backend container
These are the environmental variables you can set for the impress-backend container.
| Option | Description | default |
| ----------------------------------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
| DJANGO_ALLOWED_HOSTS | allowed hosts | [] |
| DJANGO_SECRET_KEY | secret key | |
| DJANGO_SERVER_TO_SERVER_API_TOKENS | | [] |
| DB_ENGINE | engine to use for database connections | django.db.backends.postgresql_psycopg2 |
| DB_NAME | name of the database | impress |
| DB_USER | user to authenticate with | dinum |
| DB_PASSWORD | password to authenticate with | pass |
| DB_HOST | host of the database | localhost |
| DB_PORT | port of the database | 5432 |
| MEDIA_BASE_URL | | |
| STORAGES_STATICFILES_BACKEND | | whitenoise.storage.CompressedManifestStaticFilesStorage |
| AWS_S3_ENDPOINT_URL | S3 endpoint | |
| AWS_S3_ACCESS_KEY_ID | access id for s3 endpoint | |
| AWS_S3_SECRET_ACCESS_KEY | access key for s3 endpoint | |
| AWS_S3_REGION_NAME | region name for s3 endpoint | |
| AWS_STORAGE_BUCKET_NAME | bucket name for s3 endpoint | impress-media-storage |
| DOCUMENT_IMAGE_MAX_SIZE | maximum size of document in bytes | 10485760 |
| LANGUAGE_CODE | default language | en-us |
| API_USERS_LIST_THROTTLE_RATE_SUSTAINED | throttle rate for api | 180/hour |
| API_USERS_LIST_THROTTLE_RATE_BURST | throttle rate for api on burst | 30/minute |
| SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | | false |
| TRASHBIN_CUTOFF_DAYS | trashbin cutoff | 30 |
| DJANGO_EMAIL_BACKEND | email backend library | django.core.mail.backends.smtp.EmailBackend |
| DJANGO_EMAIL_BRAND_NAME | brand name for email | |
| DJANGO_EMAIL_HOST | host name of email | |
| DJANGO_EMAIL_HOST_USER | user to authenticate with on the email host | |
| DJANGO_EMAIL_HOST_PASSWORD | password to authenticate with on the email host | |
| DJANGO_EMAIL_LOGO_IMG | logo for the email | |
| DJANGO_EMAIL_PORT | port used to connect to email host | |
| DJANGO_EMAIL_USE_TLS | use tls for email host connection | false |
| DJANGO_EMAIL_USE_SSL | use sstl for email host connection | false |
| DJANGO_EMAIL_FROM | email adress used as sender | from@example.com |
| DJANGO_CORS_ALLOW_ALL_ORIGINS | allow all CORS origins | true |
| DJANGO_CORS_ALLOWED_ORIGINS | list of origins allowed for CORS | [] |
| DJANGO_CORS_ALLOWED_ORIGIN_REGEXES | list of origins allowed for CORS using regulair expressions | [] |
| SENTRY_DSN | sentry host | |
| COLLABORATION_API_URL | collaboration api host | |
| COLLABORATION_SERVER_SECRET | collaboration api secret | |
| COLLABORATION_WS_URL | collaboration websocket url | |
| FRONTEND_CSS_URL | To add a external css file to the app | |
| FRONTEND_HOMEPAGE_FEATURE_ENABLED | frontend feature flag to display the homepage | false |
| FRONTEND_FOOTER_FEATURE_ENABLED | frontend feature flag to display the footer | false |
| FRONTEND_FOOTER_VIEW_CACHE_TIMEOUT | Cache duration of the json footer | 86400 |
| FRONTEND_URL_JSON_FOOTER | Url with a json to configure the footer | |
| FRONTEND_THEME | frontend theme to use | |
| POSTHOG_KEY | posthog key for analytics | |
| CRISP_WEBSITE_ID | crisp website id for support | |
| DJANGO_CELERY_BROKER_URL | celery broker url | redis://redis:6379/0 |
| DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | celery broker transport options | {} |
| OIDC_CREATE_USER | create used on OIDC | false |
| OIDC_RP_SIGN_ALGO | verification algorithm used OIDC tokens | RS256 |
| OIDC_RP_CLIENT_ID | client id used for OIDC | impress |
| OIDC_RP_CLIENT_SECRET | client secret used for OIDC | |
| OIDC_OP_JWKS_ENDPOINT | JWKS endpoint for OIDC | |
| OIDC_OP_AUTHORIZATION_ENDPOINT | Autorization endpoint for OIDC | |
| OIDC_OP_TOKEN_ENDPOINT | Token endpoint for OIDC | |
| OIDC_OP_USER_ENDPOINT | User endpoint for OIDC | |
| OIDC_OP_LOGOUT_ENDPOINT | Logout endpoint for OIDC | |
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | OIDC extra auth paramaters | {} |
| OIDC_RP_SCOPES | scopes requested for OIDC | openid email |
| LOGIN_REDIRECT_URL | login redirect url | |
| LOGIN_REDIRECT_URL_FAILURE | login redirect url on failure | |
| LOGOUT_REDIRECT_URL | logout redirect url | |
| OIDC_USE_NONCE | use nonce for OIDC | true |
| OIDC_REDIRECT_REQUIRE_HTTPS | Require https for OIDC redirect url | false |
| OIDC_REDIRECT_ALLOWED_HOSTS | Allowed hosts for OIDC redirect url | [] |
| OIDC_STORE_ID_TOKEN | Store OIDC token | true |
| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | faillback to email for identification | true |
| OIDC_ALLOW_DUPLICATE_EMAILS | Allow dupplicate emails | false |
| USER_OIDC_ESSENTIAL_CLAIMS | essential claims in OIDC token | [] |
| USER_OIDC_FIELDS_TO_FULLNAME | OIDC token claims to create full name | ["first_name", "last_name"] |
| USER_OIDC_FIELD_TO_SHORTNAME | OIDC token claims to create shortname | first_name |
| ALLOW_LOGOUT_GET_METHOD | Allow get logout method | true |
| AI_API_KEY | AI key to be used for AI Base url | |
| AI_BASE_URL | OpenAI compatible AI base url | |
| AI_MODEL | AI Model to use | |
| AI_ALLOW_REACH_FROM | Users that can use AI must be this level. options are "public", "authenticated", "restricted" | authenticated |
| Y_PROVIDER_API_KEY | Y provider API key | |
| Y_PROVIDER_API_BASE_URL | Y Provider url | |
| CONVERSION_API_ENDPOINT | Conversion API endpoint | convert-markdown |
| CONVERSION_API_CONTENT_FIELD | Conversion api content field | content |
| CONVERSION_API_TIMEOUT | Conversion api timeout | 30 |
| CONVERSION_API_SECURE | Require secure conversion api | false |
| LOGGING_LEVEL_LOGGERS_ROOT | default logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO |
| LOGGING_LEVEL_LOGGERS_APP | application logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO |
| API_USERS_LIST_LIMIT | Limit on API users | 5 |
| DJANGO_CSRF_TRUSTED_ORIGINS | CSRF trusted origins | [] |
| REDIS_URL | cache url | redis://redis:6379/1 |
| CACHES_DEFAULT_TIMEOUT | cache default timeout | 30 |

View File

@@ -0,0 +1,66 @@
# Installation with docker compose
We provide a configuration for running Docs in production using docker compose. This configuration is experimental, the official way to deploy Docs in production is to use [k8s](docs/installation/k8s.md)
## Requirements
- A modern version of Docker and its Compose plugin.
- SSL certificates for Docs domain and Keycloak.
- Two domain name. One for the Docs application and an other one for Keycloak. Both can be a subdomain of a common domain. (example: docs.domain.tld and keycloak.domain.tld)
## Installation
- Clone this repository: `git clone https://github.com/suitenumerique/docs.git`
- Then in the clone directory you can run the following command: `make bootsrap-production`
## Configure your ssl certificates
You have to provide the ssl certificates. The easiest way is to use [certbot](https://certbot.eff.org/), generate the certificates with it (both for Docs and Keycloak) and then mount them in ingress and keycloak containers. Two environment variables can be used for that:
- `DOCS_PROD_NGINX_CERT_FOLDER` path to the folder containing the certificates for Docs. This folder will be mounted in `/etc/nginx/ssl` in the container. You have to adapt the certificates name in the file `docker/files/production/etc/nginx/conf.d/default.conf` accordingly with the certificates name you have (see `ssl_certificate` and `ssl_certificate_key` directives).
- `DOCS_PROD_KEYCLOAK_CERT_FOLDER` path to the folder containing the certificates for Keycloak. This folder will be mounted in `/etc/ssl/certs` in the container. You have to adapt the certificates name in the configuration file in `env.d/production/keycloak` to add the correct path for environment variables `KC_HTTPS_CERTIFICATE_FILE` and `KC_HTTPS_CERTIFICATE_KEY_FILE`.
### Configuration
All the configuration files are in the directory `env.d/production`. You have to edit all the files to complete them. For the OIDC information you will have them once Keycloak will be running and you have configured your own realm on it.
#### env.d/production/minio
All the settings related to Minio. You have to set a username and a password to manage the minio cluster. You will need them later in the `env.d/production/backend` file.
#### env.d/production/postgresql
All the settings related to the Postgresql database used by the Django application.
#### env.d/production/yprovider
All the settings related to the collaboration server. All the secret and api key must be generated.
#### env.d/production/kc_postgresql
All the settings related to the Postgresql database used by keycloak.
#### env.d/production/keycloak
All the settings related to the Keycloak application.
#### env.d/production/backend
All the settings related to the Django application. Only the settings you don't have for now are all the one related to OIDC. You will have them once the compose started and you can access to Keycloak.
## Run the compose configuration
The compose configuration can be run with the following command: `make run-production`. The first start can be a little bit long, lots of things are created. Once started you can check that everything is running with the following command: `COMPOSE_FILE=compose.production.yaml ./bin/compose ps`
## Configure keycloak
You have to create a new realm in your Keycloak and once created you have to create a new OIDC client in it. You will use this client to configure the OIDC part in `env.d/production/backend`. This is the last missing part to complete the Django application configuration.
Once the client information set in `env.d/production/backend` you have to start the containers again by running the commande `make run-production`. The command will recreate the containers with the good configuration.
### Helpers
there is a helper script to control the `docker compose` command. You can export the variable `COMPOSE_FILE` with the compose filename (`export COMPOSE_FILE=compose.production.yaml`). After you can run `./bin/compose` to run the docker compose command line.
Makefile commands available:
- `make bootstrap-production`: create the configuration files in `env.d/production`, create the directories : `data/production`. Both directories must be backup, if you loose them you loose all the data related to the application.
- `make run-production`: up the ingress containers. Will start all the containers needed in cascade.
- `make stop-production`: stop all the containers.

View File

@@ -1,20 +1,21 @@
# Installation on a k8s cluster
This document is a step-by-step guide that describes how to install Docs on a k8s cluster without AI features. It's a teaching document to learn how it works. It needs to be adapted for a production environment.
This document is a step-by-step guide that describes how to install Docs on a k8s cluster without AI features. It's a teaching document to learn how it's work. It needs to be adapt for production environment.
## Prerequisites
- k8s cluster with an nginx-ingress controller
- an OIDC provider (if you don't have one, we provide an example)
- a PostgreSQL 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)
- an OIDC provider (if you don't have one, we will provide an example)
- a PostgreSQL server (if you don't have one, we will provide an example)
- a Memcached server (if you don't have one, we will provide an example)
- a S3 bucket (if you don't have one, we will provide an example)
### Test cluster
If you do not have a test cluster, you can install everything on a local Kind cluster. In this case, the simplest way is to use our script **bin/start-kind.sh**.
If you do not have a test cluster, you can install everything on a local kind cluster. In this case, the simplest way is to use our script **bin/start-kind.sh**.
To be able to use the script, you need to install:
To be able to use the script, you will need to install:
- Docker (https://docs.docker.com/desktop/)
- Kind (https://kind.sigs.k8s.io/docs/user/quick-start/#installation)
@@ -22,7 +23,7 @@ To be able to use the script, you need to install:
- Helm (https://helm.sh/docs/intro/quickstart/#install-helm)
```
./bin/start-kind.sh
./bin/start-kind.sh
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 4700 100 4700 0 0 92867 0 --:--:-- --:--:-- --:--:-- 94000
@@ -45,11 +46,11 @@ It will expire on 24 March 2027 🗓
2. Create kind cluster with containerd registry config dir enabled
Creating cluster "suite" ...
✓ Ensuring node image (kindest/node:v1.27.3) 🖼
✓ Preparing nodes 📦
✓ Writing configuration 📜
✓ Starting control-plane 🕹️
✓ Installing CNI 🔌
✓ Installing StorageClass 💾
✓ Preparing nodes 📦
✓ Writing configuration 📜
✓ Starting control-plane 🕹️
✓ Installing CNI 🔌
✓ Installing StorageClass 💾
Set kubectl context to "kind-suite"
You can now use your cluster with:
@@ -95,14 +96,13 @@ ingress-nginx-admission-create-t55ph 0/1 Completed 0 2m56s
ingress-nginx-admission-patch-94dvt 0/1 Completed 1 2m56s
ingress-nginx-controller-57c548c4cd-2rx47 1/1 Running 0 2m56s
```
When your k8s cluster is ready (the ingress nginx controller is up), you can start the deployment. This cluster is special because it uses the *.127.0.0.1.nip.io domain and mkcert certificates to have full HTTPS support and easy domain name management.
When your k8s cluster is ready (the ingress nginx controller is up), you can start the deployment. This cluster is special because it uses the `*.127.0.0.1.nip.io` domain and mkcert certificates to have full HTTPS support and easy domain name management.
Please remember that `*.127.0.0.1.nip.io` will always resolve to `127.0.0.1`, except in the k8s cluster where we configure CoreDNS to answer with the ingress-nginx service IP.
Please remember that *.127.0.0.1.nip.io will always resolve to 127.0.0.1, except in the k8s cluster where we configure CoreDNS to answer with the ingress-nginx service IP.
## Preparation
### What do you use to authenticate your users?
### What will you use to authenticate your users ?
Docs uses OIDC, so if you already have an OIDC provider, obtain the necessary information to use it. In the next step, we will see how to configure Django (and thus Docs) to use it. If you do not have a provider, we will show you how to deploy a local Keycloak instance (this is not a production deployment, just a demo).
@@ -117,9 +117,9 @@ keycloak-0 1/1 Running 0 6m48s
keycloak-postgresql-0 1/1 Running 0 6m48s
```
From here the important information you will need are:
From here the important informations you will need are :
```yaml
```
OIDC_OP_JWKS_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/certs
OIDC_OP_AUTHORIZATION_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/auth
OIDC_OP_TOKEN_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/token
@@ -135,7 +135,7 @@ You can find these values in **examples/keycloak.values.yaml**
### Find redis server connexion values
Docs needs a redis so we start by deploying one:
Impress need a redis so we will start by deploying a redis :
```
$ helm install redis oci://registry-1.docker.io/bitnamicharts/redis -f examples/redis.values.yaml
@@ -148,7 +148,7 @@ redis-master-0 1/1 Running 0 35s
### Find postgresql connexion values
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:
Impress uses a postgresql db as backend so if you have a provider, obtain the necessary information to use it. If you do not have, you can install a postgresql testing environment as follow:
```
$ helm install postgresql oci://registry-1.docker.io/bitnamicharts/postgresql -f examples/postgresql.values.yaml
@@ -160,9 +160,9 @@ postgresql-0 1/1 Running 0 14m
redis-master-0 1/1 Running 0 42s
```
From here the important information you will need are:
From here important informations you will need are :
```yaml
```
DB_HOST: postgres-postgresql
DB_NAME: impress
DB_USER: dinum
@@ -175,7 +175,7 @@ POSTGRES_PASSWORD: pass
### Find s3 bucket connexion values
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:
Impress uses a s3 bucket to store documents so if you have a provider obtain the necessary information to use it. If you do not have, you can install a local minio testing environment as follow:
```
$ helm install minio oci://registry-1.docker.io/bitnamicharts/minio -f examples/minio.values.yaml
@@ -191,7 +191,7 @@ redis-master-0 1/1 Running 0 10m
## Deployment
Now you are ready to deploy Docs without AI. AI requires more dependencies (OpenAI API). To deploy Docs you need to provide all previous informations to the helm chart.
Now you are ready to deploy Impress without AI. AI requiered more dependancies (openai API). To deploy impress you need to provide all previous informations to the helm chart.
```
$ helm repo add impress https://suitenumerique.github.io/docs/
@@ -214,7 +214,7 @@ redis-master-0 1/1 Running 0 20m
## Test your deployment
In order to test your deployment you have to log into your instance. If you exclusively use our examples you can do:
In order to test your deployment you have to login to your instance. If you use exclusively our examples you can do :
```
$ kubectl get ingress
@@ -227,4 +227,5 @@ impress-docs-ws <none> impress.127.0.0.1.nip.io localhost
keycloak <none> keycloak.127.0.0.1.nip.io localhost 80 49m
```
You can use Docs at https://impress.127.0.0.1.nip.io. The provisionning user in keycloak is impress/impress.
You can use impress on https://impress.127.0.0.1.nip.io. The provisionning user in keycloak is impress/impress.

View File

@@ -1,33 +0,0 @@
# 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.

View File

@@ -50,20 +50,15 @@ OIDC_REDIRECT_ALLOWED_HOSTS=["http://localhost:8083", "http://localhost:3000"]
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
# AI
AI_FEATURE_ENABLED=true
AI_BASE_URL=https://openaiendpoint.com
AI_API_KEY=password
AI_MODEL=llama
# Collaboration
COLLABORATION_API_URL=http://y-provider:4444/collaboration/api/
COLLABORATION_BACKEND_BASE_URL=http://app-dev:8000
COLLABORATION_API_URL=http://nginx:8083/collaboration/api/
COLLABORATION_SERVER_ORIGIN=http://localhost:3000
COLLABORATION_SERVER_SECRET=my-secret
COLLABORATION_WS_URL=ws://localhost:4444/collaboration/ws/
COLLABORATION_WS_URL=ws://localhost:8083/collaboration/ws/
# Frontend
FRONTEND_THEME=default
FRONTEND_HOMEPAGE_FEATURE_ENABLED=True
FRONTEND_FOOTER_FEATURE_ENABLED=True
FRONTEND_URL_JSON_FOOTER=http://frontend:3000/contents/footer-demo.json
FRONTEND_THEME=dsfr

View File

@@ -0,0 +1,58 @@
## Django
DJANGO_ALLOWED_HOSTS=impress.127.0.0.1.nip.io,keycloack.127.0.0.1.nip.io
DJANGO_SECRET_KEY=ThisIsAnExampleKeyForDevPurposeOnly
DJANGO_SETTINGS_MODULE=impress.settings
DJANGO_SUPERUSER_PASSWORD=ThisIsAnExamplePassword
# Logging
# Set to DEBUG level for dev only
LOGGING_LEVEL_HANDLERS_CONSOLE=ERROR
LOGGING_LEVEL_LOGGERS_ROOT=INFO
LOGGING_LEVEL_LOGGERS_APP=INFO
# Python
PYTHONPATH=/app
# impress settings
# Mail
DJANGO_EMAIL_BRAND_NAME="La Suite Numérique"
DJANGO_EMAIL_HOST="mailcatcher"
DJANGO_EMAIL_LOGO_IMG="https://impress.127.0.0.1.nip.io/assets/logo-suite-numerique.png"
DJANGO_EMAIL_PORT=1025
# Media
STORAGES_STATICFILES_BACKEND=django.contrib.staticfiles.storage.StaticFilesStorage
AWS_S3_ENDPOINT_URL=http://minio:9000
AWS_S3_ACCESS_KEY_ID=<minio root user>
AWS_S3_SECRET_ACCESS_KEY=<minio root password>
AWS_STORAGE_BUCKET_NAME=docs-media-storage
MEDIA_BASE_URL=impress.127.0.0.1.nip.io
# OIDC
USER_OIDC_FIELD_TO_SHORTNAME="given_name"
USER_OIDC_FIELDS_TO_FULLNAME="given_name,usual_name"
OIDC_OP_JWKS_ENDPOINT=https://impress.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/certs
OIDC_OP_AUTHORIZATION_ENDPOINT=https://impress.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/auth
OIDC_OP_TOKEN_ENDPOINT=https://impress.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/token
OIDC_OP_USER_ENDPOINT=https://impress.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/userinfo
OIDC_OP_LOGOUT_ENDPOINT=https://impress.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/logout
OIDC_RP_CLIENT_ID=impress
OIDC_RP_CLIENT_SECRETThisIsAnExampleKeyForDevPurposeOnly
OIDC_RP_SIGN_ALGO=RS256
OIDC_RP_SCOPES="openid email"
LOGIN_REDIRECT_URL=https://impress.127.0.0.1.nip.io
LOGIN_REDIRECT_URL_FAILURE=https://impress.127.0.0.1.nip.io
LOGOUT_REDIRECT_URL=https://impress.127.0.0.1.nip.io
OIDC_REDIRECT_ALLOWED_HOSTS=["https://impress.127.0.0.1.nip.io"]
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
# AI
AI_BASE_URL=https://openaiendpoint.com
AI_API_KEY=password
AI_MODEL=llama
# Frontend
FRONTEND_THEME=dsfr

View File

@@ -0,0 +1,9 @@
# Postgresql db container configuration
POSTGRES_DB=keycloak
POSTGRES_USER=keycloak
POSTGRES_PASSWORD=<Set postgresql password>
# Keycloak database configuration
KC_DB_URL_DATABASE=keycloak
KC_DB_USERNAME=keycloak
KC_DB_PASSWORD=<Same password as above>

View File

@@ -0,0 +1,9 @@
KC_BOOTSTRAP_ADMIN_USERNAME=<Change this admin user>
KC_BOOTSTRAP_ADMIN_PASSWORD=<Change this admin password>
KC_DB=postgres
KC_DB_URL_HOST=kc_postgresql
KC_DB_SCHEMA=public
PROXY_ADDRESS_FORWARDING='true'
KC_HOSTNAME=http://localhost:8083
KC_HTTPS_CERTIFICATE_FILE=/etc/ssl/certs/docs.crt
KC_HTTPS_CERTIFICATE_KEY_FILE=/etc/ssl/private/docs.key

View File

@@ -0,0 +1,2 @@
MINIO_ROOT_USER=<Set minio root username>
MINIO_ROOT_PASSWORD=<Set minio root password>

View File

@@ -0,0 +1,11 @@
# Postgresql db container configuration
POSTGRES_DB=docs
POSTGRES_USER=docs
POSTGRES_PASSWORD=<Set postgresql password>
# App database configuration
DB_HOST=postgresql
DB_NAME=docs
DB_USER=docs
DB_PASSWORD=<Same password as above>
DB_PORT=5432

View File

@@ -0,0 +1,5 @@
COLLABORATION_LOGGING=true
Y_PROVIDER_API_KEY=<Set y provider api key>
COLLABORATION_API_URL=https://impress.127.0.0.1.nip.io/collaboration/api/
COLLABORATION_SERVER_ORIGIN=https://impress.127.0.0.1.nip.io
COLLABORATION_SERVER_SECRET=<Set collaboration secret>

View File

@@ -31,7 +31,7 @@ class GitmojiTitle(LineRule):
"https://raw.githubusercontent.com/carloscuesta/gitmoji/master/packages/gitmojis/src/gitmojis.json"
).json()["gitmojis"]
emojis = [item["emoji"] for item in gitmojis]
pattern = r"^({:s})\(.*\)\s[a-zA-Z].*$".format("|".join(emojis))
pattern = r"^({:s})\(.*\)\s[a-z].*$".format("|".join(emojis))
if not re.search(pattern, title):
violation_msg = 'Title does not match regex "<gitmoji>(<scope>) <subject>"'
return [RuleViolation(self.id, violation_msg, title)]

View File

@@ -1,27 +0,0 @@
publiccodeYmlVersion: "2.4.0"
name: Docs
url: https://github.com/suitenumerique/docs
landingURL: https://github.com/suitenumerique/docs
creationDate: 2023-12-10
logo: https://raw.githubusercontent.com/suitenumerique/docs/main/docs/assets/docs-logo.png
usedBy:
- Direction interministériel du numérique (DINUM)
fundedBy:
- name: Direction interministériel du numérique (DINUM)
url: https://www.numerique.gouv.fr
roadmap: "https://github.com/orgs/suitenumerique/projects/2/views/1"
softwareType: "standalone/other"
description:
en:
shortDescription: "The open source document editor where your notes can become knowledge through live collaboration"
fr:
shortDescription: "L'éditeur de documents open source où vos notes peuvent devenir des connaissances grâce à la collaboration en direct."
legal:
license: MIT
maintenance:
type: internal
contacts:
- name: "Virgile Deville"
email: "virgile.deville@numerique.gouv.fr"
- name: "samuel.paccoud"
email: "samuel.paccoud@numerique.gouv.fr"

View File

@@ -14,10 +14,10 @@
"groupName": "ignored js dependencies",
"matchManagers": ["npm"],
"matchPackageNames": [
"eslint",
"fetch-mock",
"node",
"node-fetch",
"eslint",
"workbox-webpack-plugin"
]
}

View File

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

View File

@@ -17,10 +17,9 @@ def exception_handler(exc, context):
https://gist.github.com/twidi/9d55486c36b6a51bdcb05ce3a763e79f
"""
if isinstance(exc, ValidationError):
detail = None
if hasattr(exc, "message_dict"):
detail = exc.message_dict
elif hasattr(exc, "message"):
detail = exc.message_dict
if hasattr(exc, "message"):
detail = exc.message
elif hasattr(exc, "messages"):
detail = exc.messages

View File

@@ -12,30 +12,19 @@ class DocumentFilter(django_filters.FilterSet):
Custom filter for filtering documents.
"""
title = django_filters.CharFilter(
field_name="title", lookup_expr="icontains", label=_("Title")
)
class Meta:
model = models.Document
fields = ["title"]
class ListDocumentFilter(DocumentFilter):
"""
Custom filter for filtering documents.
"""
is_creator_me = django_filters.BooleanFilter(
method="filter_is_creator_me", label=_("Creator is me")
)
is_favorite = django_filters.BooleanFilter(
method="filter_is_favorite", label=_("Favorite")
)
title = django_filters.CharFilter(
field_name="title", lookup_expr="icontains", label=_("Title")
)
class Meta:
model = models.Document
fields = ["is_creator_me", "is_favorite", "title"]
fields = ["is_creator_me", "is_favorite", "link_reach", "title"]
# pylint: disable=unused-argument
def filter_is_creator_me(self, queryset, name, value):
@@ -74,4 +63,7 @@ class ListDocumentFilter(DocumentFilter):
if not user.is_authenticated:
return queryset
return queryset.filter(is_favorite=bool(value))
if value:
return queryset.filter(favorited_by_users__user=user)
return queryset.exclude(favorited_by_users__user=user)

View File

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

View File

@@ -1,8 +1,6 @@
"""Client serializers for the impress core app."""
import binascii
import mimetypes
from base64 import b64decode
from django.conf import settings
from django.db.models import Q
@@ -12,7 +10,7 @@ from django.utils.translation import gettext_lazy as _
import magic
from rest_framework import exceptions, serializers
from core import enums, models, utils
from core import enums, models
from core.services.ai_services import AI_ACTIONS
from core.services.converter_services import (
ConversionError,
@@ -23,26 +21,6 @@ from core.services.converter_services import (
class UserSerializer(serializers.ModelSerializer):
"""Serialize users."""
class Meta:
model = models.User
fields = ["id", "email", "full_name", "short_name", "language"]
read_only_fields = ["id", "email", "full_name", "short_name"]
class UserLightSerializer(UserSerializer):
"""Serialize users with limited fields."""
id = serializers.SerializerMethodField(read_only=True)
email = serializers.SerializerMethodField(read_only=True)
def get_id(self, _user):
"""Return always None. Here to have the same fields than in UserSerializer."""
return None
def get_email(self, _user):
"""Return always None. Here to have the same fields than in UserSerializer."""
return None
class Meta:
model = models.User
fields = ["id", "email", "full_name", "short_name"]
@@ -140,17 +118,6 @@ class DocumentAccessSerializer(BaseAccessSerializer):
read_only_fields = ["id", "abilities"]
class DocumentAccessLightSerializer(DocumentAccessSerializer):
"""Serialize document accesses with limited fields."""
user = UserLightSerializer(read_only=True)
class Meta:
model = models.DocumentAccess
fields = ["id", "user", "team", "role", "abilities"]
read_only_fields = ["id", "team", "role", "abilities"]
class TemplateAccessSerializer(BaseAccessSerializer):
"""Serialize template accesses."""
@@ -161,79 +128,53 @@ class TemplateAccessSerializer(BaseAccessSerializer):
read_only_fields = ["id", "abilities"]
class ListDocumentSerializer(serializers.ModelSerializer):
class BaseResourceSerializer(serializers.ModelSerializer):
"""Serialize documents."""
abilities = serializers.SerializerMethodField(read_only=True)
accesses = TemplateAccessSerializer(many=True, read_only=True)
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 {}
class ListDocumentSerializer(BaseResourceSerializer):
"""Serialize documents with limited fields for display in lists."""
is_favorite = serializers.BooleanField(read_only=True)
nb_accesses_ancestors = serializers.IntegerField(read_only=True)
nb_accesses_direct = serializers.IntegerField(read_only=True)
user_roles = serializers.SerializerMethodField(read_only=True)
abilities = serializers.SerializerMethodField(read_only=True)
nb_accesses = serializers.IntegerField(read_only=True)
class Meta:
model = models.Document
fields = [
"id",
"abilities",
"content",
"created_at",
"creator",
"depth",
"excerpt",
"is_favorite",
"link_role",
"link_reach",
"nb_accesses_ancestors",
"nb_accesses_direct",
"numchild",
"path",
"nb_accesses",
"title",
"updated_at",
"user_roles",
]
read_only_fields = [
"id",
"abilities",
"created_at",
"creator",
"depth",
"excerpt",
"is_favorite",
"link_role",
"link_reach",
"nb_accesses_ancestors",
"nb_accesses_direct",
"numchild",
"path",
"nb_accesses",
"updated_at",
"user_roles",
]
def get_abilities(self, document) -> dict:
"""Return abilities of the logged-in user on the instance."""
request = self.context.get("request")
if request:
paths_links_mapping = self.context.get("paths_links_mapping", None)
# Retrieve ancestor links from paths_links_mapping (if provided)
ancestors_links = (
paths_links_mapping.get(document.path[: -document.steplen])
if paths_links_mapping
else None
)
return document.get_abilities(request.user, ancestors_links=ancestors_links)
return {}
def get_user_roles(self, document):
"""
Return roles of the logged-in user for the current document,
taking into account ancestors.
"""
request = self.context.get("request")
if request:
return document.get_roles(request.user)
return []
class DocumentSerializer(ListDocumentSerializer):
"""Serialize documents with all fields for display in detail views."""
@@ -248,34 +189,23 @@ class DocumentSerializer(ListDocumentSerializer):
"content",
"created_at",
"creator",
"depth",
"excerpt",
"is_favorite",
"link_role",
"link_reach",
"nb_accesses_ancestors",
"nb_accesses_direct",
"numchild",
"path",
"nb_accesses",
"title",
"updated_at",
"user_roles",
]
read_only_fields = [
"id",
"abilities",
"created_at",
"creator",
"depth",
"is_favorite",
"link_role",
"link_reach",
"nb_accesses_ancestors",
"nb_accesses_direct",
"numchild",
"path",
"nb_accesses",
"updated_at",
"user_roles",
]
def get_fields(self):
@@ -301,65 +231,6 @@ class DocumentSerializer(ListDocumentSerializer):
return value
def validate_content(self, value):
"""Validate the content field."""
if not value:
return None
try:
b64decode(value, validate=True)
except binascii.Error as err:
raise serializers.ValidationError("Invalid base64 content.") from err
return value
def save(self, **kwargs):
"""
Process the content field to extract attachment keys and update the document's
"attachments" field for access control.
"""
content = self.validated_data.get("content", "")
extracted_attachments = set(utils.extract_attachments(content))
existing_attachments = (
set(self.instance.attachments or []) if self.instance else set()
)
new_attachments = extracted_attachments - existing_attachments
if new_attachments:
attachments_documents = (
models.Document.objects.filter(
attachments__overlap=list(new_attachments)
)
.only("path", "attachments")
.order_by("path")
)
user = self.context["request"].user
readable_per_se_paths = (
models.Document.objects.readable_per_se(user)
.order_by("path")
.values_list("path", flat=True)
)
readable_attachments_paths = utils.filter_descendants(
[doc.path for doc in attachments_documents],
readable_per_se_paths,
skip_sorting=True,
)
readable_attachments = set()
for document in attachments_documents:
if document.path not in readable_attachments_paths:
continue
readable_attachments.update(set(document.attachments) & new_attachments)
# Update attachments with readable keys
self.validated_data["attachments"] = list(
existing_attachments | readable_attachments
)
return super().save(**kwargs)
class ServerCreateDocumentSerializer(serializers.Serializer):
"""
@@ -416,7 +287,7 @@ class ServerCreateDocumentSerializer(serializers.Serializer):
{"content": ["Could not convert content"]}
) from err
document = models.Document.add_root(
document = models.Document.objects.create(
title=validated_data["title"],
content=document_content,
creator=user,
@@ -459,7 +330,7 @@ class ServerCreateDocumentSerializer(serializers.Serializer):
raise NotImplementedError("Update is not supported for this serializer.")
class LinkDocumentSerializer(serializers.ModelSerializer):
class LinkDocumentSerializer(BaseResourceSerializer):
"""
Serialize link configuration for documents.
We expose it separately from document in order to simplify and secure access control.
@@ -473,27 +344,6 @@ class LinkDocumentSerializer(serializers.ModelSerializer):
]
class DocumentDuplicationSerializer(serializers.Serializer):
"""
Serializer for duplicating a document.
Allows specifying whether to keep access permissions.
"""
with_accesses = serializers.BooleanField(default=False)
def create(self, validated_data):
"""
This serializer is not intended to create objects.
"""
raise NotImplementedError("This serializer does not support creation.")
def update(self, instance, validated_data):
"""
This serializer is not intended to update objects.
"""
raise NotImplementedError("This serializer does not support updating.")
# Suppress the warning about not implementing `create` and `update` methods
# since we don't use a model and only rely on the serializer for validation
# pylint: disable=abstract-method
@@ -539,7 +389,6 @@ class FileUploadSerializer(serializers.Serializer):
self.context["expected_extension"] = extension
self.context["content_type"] = magic_mime_type
self.context["file_name"] = file.name
return file
@@ -548,16 +397,12 @@ class FileUploadSerializer(serializers.Serializer):
attrs["expected_extension"] = self.context["expected_extension"]
attrs["is_unsafe"] = self.context["is_unsafe"]
attrs["content_type"] = self.context["content_type"]
attrs["file_name"] = self.context["file_name"]
return attrs
class TemplateSerializer(serializers.ModelSerializer):
class TemplateSerializer(BaseResourceSerializer):
"""Serialize templates."""
abilities = serializers.SerializerMethodField(read_only=True)
accesses = TemplateAccessSerializer(many=True, read_only=True)
class Meta:
model = models.Template
fields = [
@@ -571,13 +416,6 @@ class TemplateSerializer(serializers.ModelSerializer):
]
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):
@@ -701,37 +539,3 @@ class AITranslateSerializer(serializers.Serializer):
if len(value.strip()) == 0:
raise serializers.ValidationError("Text field cannot be empty.")
return value
class MoveDocumentSerializer(serializers.Serializer):
"""
Serializer for validating input data to move a document within the tree structure.
Fields:
- target_document_id (UUIDField): The ID of the target parent document where the
document should be moved. This field is required and must be a valid UUID.
- position (ChoiceField): Specifies the position of the document in relation to
the target parent's children.
Choices:
- "first-child": Place the document as the first child of the target parent.
- "last-child": Place the document as the last child of the target parent (default).
- "left": Place the document as the left sibling of the target parent.
- "right": Place the document as the right sibling of the target parent.
Example:
Input payload for moving a document:
{
"target_document_id": "123e4567-e89b-12d3-a456-426614174000",
"position": "first-child"
}
Notes:
- The `target_document_id` is mandatory.
- The `position` defaults to "last-child" if not provided.
"""
target_document_id = serializers.UUIDField(required=True)
position = serializers.ChoiceField(
choices=enums.MoveNodePositionChoices.choices,
default=enums.MoveNodePositionChoices.LAST_CHILD,
)

View File

@@ -11,58 +11,6 @@ import botocore
from rest_framework.throttling import BaseThrottle
def nest_tree(flat_list, steplen):
"""
Convert a flat list of serialized documents into a nested tree making advantage
of the`path` field and its step length.
"""
node_dict = {}
roots = []
# Sort the flat list by path to ensure parent nodes are processed first
flat_list.sort(key=lambda x: x["path"])
for node in flat_list:
node["children"] = [] # Initialize children list
node_dict[node["path"]] = node
# Determine parent path
parent_path = node["path"][:-steplen]
if parent_path in node_dict:
node_dict[parent_path]["children"].append(node)
else:
roots.append(node) # Collect root nodes
if len(roots) > 1:
raise ValueError("More than one root element detected.")
return roots[0] if roots else None
def filter_root_paths(paths, skip_sorting=False):
"""
Filters root paths from a list of paths representing a tree structure.
A root path is defined as a path that is not a prefix of any other path.
Args:
paths (list of str): The list of paths.
Returns:
list of str: The filtered list of root paths.
"""
if not skip_sorting:
paths.sort()
root_paths = []
for path in paths:
# If the current path is not a prefix of the last added root path, add it
if not root_paths or not path.startswith(root_paths[-1]):
root_paths.append(path)
return root_paths
def generate_s3_authorization_headers(key):
"""
Generate authorization headers for an s3 object.

File diff suppressed because it is too large Load Diff

View File

@@ -2,39 +2,11 @@
Core application enums declaration
"""
import re
from django.conf import global_settings, settings
from django.db import models
from django.conf import global_settings
from django.utils.translation import gettext_lazy as _
ATTACHMENTS_FOLDER = "attachments"
UUID_REGEX = (
r"[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}"
)
FILE_EXT_REGEX = r"\.[a-zA-Z0-9]{1,10}"
MEDIA_STORAGE_URL_PATTERN = re.compile(
f"{settings.MEDIA_URL:s}(?P<pk>{UUID_REGEX:s})/"
f"(?P<attachment>{ATTACHMENTS_FOLDER:s}/{UUID_REGEX:s}(?:-unsafe)?{FILE_EXT_REGEX:s})$"
)
MEDIA_STORAGE_URL_EXTRACT = re.compile(
f"{settings.MEDIA_URL:s}({UUID_REGEX}/{ATTACHMENTS_FOLDER}/{UUID_REGEX}{FILE_EXT_REGEX})"
)
# In Django's code base, `LANGUAGES` is set by default with all supported languages.
# We can use it for the choice of languages which should not be limited to the few languages
# active in the app.
# pylint: disable=no-member
ALL_LANGUAGES = {language: _(name) for language, name in global_settings.LANGUAGES}
class MoveNodePositionChoices(models.TextChoices):
"""Defines the possible positions when moving a django-treebeard node."""
FIRST_CHILD = "first-child", _("First child")
LAST_CHILD = "last-child", _("Last child")
FIRST_SIBLING = "first-sibling", _("First sibling")
LAST_SIBLING = "last-sibling", _("Last sibling")
LEFT = "left", _("Left")
RIGHT = "right", _("Right")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,36 +0,0 @@
# Generated by Django 5.1.5 on 2025-03-04 12:23
from django.db import migrations, models
import core.models
class Migration(migrations.Migration):
dependencies = [
("core", "0018_update_blank_title"),
]
operations = [
migrations.AlterModelManagers(
name="user",
managers=[
("objects", core.models.UserManager()),
],
),
migrations.AlterField(
model_name="user",
name="language",
field=models.CharField(
blank=True,
choices=[
("en-us", "English"),
("fr-fr", "Français"),
("de-de", "Deutsch"),
],
default=None,
help_text="The language in which the user wants to see the interface.",
max_length=10,
null=True,
verbose_name="language",
),
),
]

View File

@@ -1,77 +0,0 @@
# Generated by Django 5.1.4 on 2025-01-18 11:53
import re
import django.contrib.postgres.fields
import django.db.models.deletion
from django.core.files.storage import default_storage
from django.db import migrations, models
from botocore.exceptions import ClientError
import core.models
from core.utils import extract_attachments
def populate_attachments_on_all_documents(apps, schema_editor):
"""Populate "attachments" field on all existing documents in the database."""
Document = apps.get_model("core", "Document")
for document in Document.objects.all():
try:
response = default_storage.connection.meta.client.get_object(
Bucket=default_storage.bucket_name, Key=f"{document.pk!s}/file"
)
except (FileNotFoundError, ClientError):
pass
else:
content = response["Body"].read().decode("utf-8")
document.attachments = extract_attachments(content)
document.save(update_fields=["attachments"])
class Migration(migrations.Migration):
dependencies = [
("core", "0019_alter_user_language_default_to_null"),
]
operations = [
# v2.0.0 was released so we can now remove BC field "is_public"
migrations.RemoveField(
model_name="document",
name="is_public",
),
migrations.AlterModelManagers(
name="user",
managers=[
("objects", core.models.UserManager()),
],
),
migrations.AddField(
model_name="document",
name="attachments",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.CharField(max_length=255),
blank=True,
default=list,
editable=False,
null=True,
size=None,
),
),
migrations.AddField(
model_name="document",
name="duplicated_from",
field=models.ForeignKey(
blank=True,
editable=False,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="duplicates",
to="core.document",
),
),
migrations.RunPython(
populate_attachments_on_all_documents,
reverse_code=migrations.RunPython.noop,
),
]

View File

@@ -5,49 +5,56 @@ Declare and configure the models for the impress core application
import hashlib
import smtplib
import tempfile
import textwrap
import uuid
from collections import defaultdict
from datetime import timedelta
from io import BytesIO
from logging import getLogger
from django.conf import settings
from django.contrib.auth import models as auth_models
from django.contrib.auth.base_user import AbstractBaseUser
from django.contrib.postgres.fields import ArrayField
from django.contrib.sites.models import Site
from django.core import mail, validators
from django.core.cache import cache
from django.core import exceptions, mail, validators
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
from django.core.mail import send_mail
from django.db import models, transaction
from django.db.models.functions import Left, Length
from django.db import models
from django.http import FileResponse
from django.template.base import Template as DjangoTemplate
from django.template.context import Context
from django.template.loader import render_to_string
from django.utils import timezone
from django.utils.functional import cached_property
from django.utils import html, timezone
from django.utils.functional import cached_property, lazy
from django.utils.translation import get_language, override
from django.utils.translation import gettext_lazy as _
import frontmatter
import markdown
import pypandoc
import weasyprint
from botocore.exceptions import ClientError
from rest_framework.exceptions import ValidationError
from timezone_field import TimeZoneField
from treebeard.mp_tree import MP_Node, MP_NodeManager, MP_NodeQuerySet
logger = getLogger(__name__)
def get_trashbin_cutoff():
"""
Calculate the cutoff datetime for soft-deleted items based on the retention policy.
def get_resource_roles(resource, user):
"""Compute the roles a user has on a resource."""
if not user.is_authenticated:
return []
The function returns the current datetime minus the number of days specified in
the TRASHBIN_CUTOFF_DAYS setting, indicating the oldest date for items that can
remain in the trash bin.
Returns:
datetime: The cutoff datetime for soft-deleted items.
"""
return timezone.now() - timedelta(days=settings.TRASHBIN_CUTOFF_DAYS)
try:
roles = resource.user_roles or []
except AttributeError:
try:
roles = resource.accesses.filter(
models.Q(user=user) | models.Q(team__in=user.teams),
).values_list("role", flat=True)
except (models.ObjectDoesNotExist, IndexError):
roles = []
return roles
class LinkRoleChoices(models.TextChoices):
@@ -82,55 +89,6 @@ class LinkReachChoices(models.TextChoices):
) # Any authenticated user can access the document
PUBLIC = "public", _("Public") # Even anonymous users can access the document
@classmethod
def get_select_options(cls, ancestors_links):
"""
Determines the valid select options for link reach and link role depending on the
list of ancestors' link reach/role.
Args:
ancestors_links: List of dictionaries, each with 'link_reach' and 'link_role' keys
representing the reach and role of ancestors links.
Returns:
Dictionary mapping possible reach levels to their corresponding possible roles.
"""
# If no ancestors, return all options
if not ancestors_links:
return dict.fromkeys(cls.values, LinkRoleChoices.values)
# Initialize result with all possible reaches and role options as sets
result = {reach: set(LinkRoleChoices.values) for reach in cls.values}
# Group roles by reach level
reach_roles = defaultdict(set)
for link in ancestors_links:
reach_roles[link["link_reach"]].add(link["link_role"])
# Apply constraints based on ancestor links
if LinkRoleChoices.EDITOR in reach_roles[cls.RESTRICTED]:
result[cls.RESTRICTED].discard(LinkRoleChoices.READER)
if LinkRoleChoices.EDITOR in reach_roles[cls.AUTHENTICATED]:
result[cls.AUTHENTICATED].discard(LinkRoleChoices.READER)
result.pop(cls.RESTRICTED, None)
elif LinkRoleChoices.READER in reach_roles[cls.AUTHENTICATED]:
result[cls.RESTRICTED].discard(LinkRoleChoices.READER)
if LinkRoleChoices.EDITOR in reach_roles[cls.PUBLIC]:
result[cls.PUBLIC].discard(LinkRoleChoices.READER)
result.pop(cls.AUTHENTICATED, None)
result.pop(cls.RESTRICTED, None)
elif LinkRoleChoices.READER in reach_roles[cls.PUBLIC]:
result[cls.AUTHENTICATED].discard(LinkRoleChoices.READER)
result.get(cls.RESTRICTED, set()).discard(LinkRoleChoices.READER)
# Convert roles sets to lists while maintaining the order from LinkRoleChoices
for reach, roles in result.items():
result[reach] = [role for role in LinkRoleChoices.values if role in roles]
return result
class DuplicateEmailError(Exception):
"""Raised when an email is already associated with a pre-existing user."""
@@ -244,12 +202,10 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
language = models.CharField(
max_length=10,
choices=settings.LANGUAGES,
default=None,
choices=lazy(lambda: settings.LANGUAGES, tuple)(),
default=settings.LANGUAGE_CODE,
verbose_name=_("language"),
help_text=_("The language in which the user wants to see the interface."),
null=True,
blank=True,
)
timezone = TimeZoneField(
choices_display="WITH_GMT_OFFSET",
@@ -364,9 +320,10 @@ class BaseAccess(BaseModel):
class Meta:
abstract = True
def _get_roles(self, resource, user):
def _get_abilities(self, resource, user):
"""
Get the roles a user has on a resource.
Compute and return abilities for a given user taking into account
the current state of the object.
"""
roles = []
if user.is_authenticated:
@@ -381,15 +338,6 @@ class BaseAccess(BaseModel):
except (self._meta.model.DoesNotExist, IndexError):
roles = []
return roles
def _get_abilities(self, resource, user):
"""
Compute and return abilities for a given user taking into account
the current state of the object.
"""
roles = self._get_roles(resource, user)
is_owner_or_admin = bool(
set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
)
@@ -428,47 +376,10 @@ class BaseAccess(BaseModel):
}
class DocumentQuerySet(MP_NodeQuerySet):
"""
Custom queryset for the Document model, providing additional methods
to filter documents based on user permissions.
"""
def readable_per_se(self, user):
"""
Filters the queryset to return documents on which the given user has
direct access, team access or link access. This will not return all the
documents that a user can read because it can be obtained via an ancestor.
:param user: The user for whom readable documents are to be fetched.
:return: A queryset of documents for which the user has direct access,
team access or link access.
"""
if user.is_authenticated:
return self.filter(
models.Q(accesses__user=user)
| models.Q(accesses__team__in=user.teams)
| ~models.Q(link_reach=LinkReachChoices.RESTRICTED)
)
return self.filter(link_reach=LinkReachChoices.PUBLIC)
class DocumentManager(MP_NodeManager.from_queryset(DocumentQuerySet)):
"""
Custom manager for the Document model, enabling the use of the custom
queryset methods directly from the model manager.
"""
def get_queryset(self):
"""Sets the custom queryset as the default."""
return self._queryset_class(self.model).order_by("path")
class Document(MP_Node, BaseModel):
class Document(BaseModel):
"""Pad document carrying the content."""
title = models.CharField(_("title"), max_length=255, null=True, blank=True)
excerpt = models.TextField(_("excerpt"), max_length=300, null=True, blank=True)
link_reach = models.CharField(
max_length=20,
choices=LinkReachChoices.choices,
@@ -484,49 +395,14 @@ class Document(MP_Node, BaseModel):
blank=True,
null=True,
)
deleted_at = models.DateTimeField(null=True, blank=True)
ancestors_deleted_at = models.DateTimeField(null=True, blank=True)
duplicated_from = models.ForeignKey(
"self",
on_delete=models.SET_NULL,
related_name="duplicates",
editable=False,
blank=True,
null=True,
)
attachments = ArrayField(
models.CharField(max_length=255),
default=list,
editable=False,
blank=True,
null=True,
)
_content = None
# Tree structure
alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
steplen = 7 # nb siblings max: 3,521,614,606,208
node_order_by = [] # Manual ordering
path = models.CharField(max_length=7 * 36, unique=True, db_collation="C")
objects = DocumentManager()
class Meta:
db_table = "impress_document"
ordering = ("path",)
ordering = ("title",)
verbose_name = _("Document")
verbose_name_plural = _("Documents")
constraints = [
models.CheckConstraint(
check=(
models.Q(deleted_at__isnull=True)
| models.Q(deleted_at=models.F("ancestors_deleted_at"))
),
name="check_deleted_at_matches_ancestors_deleted_at_when_set",
),
]
def __str__(self):
return str(self.title) if self.title else str(_("Untitled Document"))
@@ -597,13 +473,9 @@ class Document(MP_Node, BaseModel):
def get_content_response(self, version_id=""):
"""Get the content in a specific version of the document"""
params = {
"Bucket": default_storage.bucket_name,
"Key": self.file_key,
}
if version_id:
params["VersionId"] = version_id
return default_storage.connection.meta.client.get_object(**params)
return default_storage.connection.meta.client.get_object(
Bucket=default_storage.bucket_name, Key=self.file_key, VersionId=version_id
)
def get_versions_slice(self, from_version_id="", min_datetime=None, page_size=None):
"""Get document versions from object storage with pagination and starting conditions"""
@@ -669,193 +541,48 @@ class Document(MP_Node, BaseModel):
Bucket=default_storage.bucket_name, Key=self.file_key, VersionId=version_id
)
def get_nb_accesses_cache_key(self):
"""Generate a unique cache key for each document."""
return f"document_{self.id!s}_nb_accesses"
def get_nb_accesses(self):
"""
Calculate the number of accesses:
- directly attached to the document
- attached to any of the document's ancestors
"""
cache_key = self.get_nb_accesses_cache_key()
nb_accesses = cache.get(cache_key)
if nb_accesses is None:
nb_accesses = (
DocumentAccess.objects.filter(document=self).count(),
DocumentAccess.objects.filter(
document__path=Left(
models.Value(self.path), Length("document__path")
),
document__ancestors_deleted_at__isnull=True,
).count(),
)
cache.set(cache_key, nb_accesses)
return nb_accesses
@property
def nb_accesses_direct(self):
"""Returns the number of accesses related to the document or one of its ancestors."""
return self.get_nb_accesses()[0]
@property
def nb_accesses_ancestors(self):
"""Returns the number of accesses related to the document or one of its ancestors."""
return self.get_nb_accesses()[1]
def invalidate_nb_accesses_cache(self):
"""
Invalidate the cache for number of accesses, including on affected descendants.
Args:
path: can optionally be passed as argument (useful when invalidating cache for a
document we just deleted)
"""
for document in Document.objects.filter(path__startswith=self.path).only("id"):
cache_key = document.get_nb_accesses_cache_key()
cache.delete(cache_key)
def get_roles(self, user):
"""Return the roles a user has on a document."""
if not user.is_authenticated:
return []
try:
roles = self.user_roles or []
except AttributeError:
try:
roles = DocumentAccess.objects.filter(
models.Q(user=user) | models.Q(team__in=user.teams),
document__path=Left(
models.Value(self.path), Length("document__path")
),
).values_list("role", flat=True)
except (models.ObjectDoesNotExist, IndexError):
roles = []
return roles
def get_links_definitions(self, ancestors_links):
"""Get links reach/role definitions for the current document and its ancestors."""
links_definitions = defaultdict(set)
links_definitions[self.link_reach].add(self.link_role)
# Merge ancestor link definitions
for ancestor in ancestors_links:
links_definitions[ancestor["link_reach"]].add(ancestor["link_role"])
return dict(links_definitions) # Convert defaultdict back to a normal dict
def compute_ancestors_links(self, user):
"""
Compute the ancestors links for the current document up to the highest readable ancestor.
"""
ancestors = (
(self.get_ancestors() | self._meta.model.objects.filter(pk=self.pk))
.filter(ancestors_deleted_at__isnull=True)
.order_by("path")
)
highest_readable = ancestors.readable_per_se(user).only("depth").first()
if highest_readable is None:
return []
ancestors_links = []
paths_links_mapping = {}
for ancestor in ancestors.filter(depth__gte=highest_readable.depth):
ancestors_links.append(
{"link_reach": ancestor.link_reach, "link_role": ancestor.link_role}
)
paths_links_mapping[ancestor.path] = ancestors_links.copy()
ancestors_links = paths_links_mapping.get(self.path[: -self.steplen], [])
return ancestors_links
def get_abilities(self, user, ancestors_links=None):
def get_abilities(self, user):
"""
Compute and return abilities for a given user on the document.
"""
if self.depth <= 1 or getattr(self, "is_highest_ancestor_for_user", False):
ancestors_links = []
elif ancestors_links is None:
ancestors_links = self.compute_ancestors_links(user=user)
roles = set(get_resource_roles(self, user))
roles = set(
self.get_roles(user)
) # at this point only roles based on specific access
# Characteristics that are based only on specific access
is_owner = RoleChoices.OWNER in roles
is_deleted = self.ancestors_deleted_at and not is_owner
is_owner_or_admin = (is_owner or RoleChoices.ADMIN in roles) and not is_deleted
# Compute access roles before adding link roles because we don't
# Compute version roles before adding link roles because we don't
# want anonymous users to access versions (we wouldn't know from
# which date to allow them anyway)
# Anonymous users should also not see document accesses
has_access_role = bool(roles) and not is_deleted
can_update_from_access = (
is_owner_or_admin or RoleChoices.EDITOR in roles
) and not is_deleted
has_role = bool(roles)
# Add roles provided by the document link, taking into account its ancestors
links_definitions = self.get_links_definitions(ancestors_links)
public_roles = links_definitions.get(LinkReachChoices.PUBLIC, set())
authenticated_roles = (
links_definitions.get(LinkReachChoices.AUTHENTICATED, set())
if user.is_authenticated
else set()
)
roles = roles | public_roles | authenticated_roles
can_get = bool(roles) and not is_deleted
can_update = (
is_owner_or_admin or RoleChoices.EDITOR in roles
) and not is_deleted
ai_allow_reach_from = settings.AI_ALLOW_REACH_FROM
ai_access = any(
[
ai_allow_reach_from == LinkReachChoices.PUBLIC and can_update,
ai_allow_reach_from == LinkReachChoices.AUTHENTICATED
and user.is_authenticated
and can_update,
ai_allow_reach_from == LinkReachChoices.RESTRICTED
and can_update_from_access,
]
# Add role provided by the document link
if self.link_reach == LinkReachChoices.PUBLIC or (
self.link_reach == LinkReachChoices.AUTHENTICATED and user.is_authenticated
):
roles.add(self.link_role)
is_owner_or_admin = bool(
roles.intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
)
can_get = bool(roles)
can_update = is_owner_or_admin or RoleChoices.EDITOR in roles
return {
"accesses_manage": is_owner_or_admin,
"accesses_view": has_access_role,
"ai_transform": ai_access,
"ai_translate": ai_access,
"accesses_view": has_role,
"ai_transform": can_update,
"ai_translate": can_update,
"attachment_upload": can_update,
"children_list": can_get,
"children_create": can_update and user.is_authenticated,
"collaboration_auth": can_get,
"cors_proxy": can_get,
"descendants": can_get,
"destroy": is_owner,
"duplicate": can_get,
"destroy": RoleChoices.OWNER in roles,
"favorite": can_get and user.is_authenticated,
"link_configuration": is_owner_or_admin,
"invite_owner": is_owner,
"move": is_owner_or_admin and not self.ancestors_deleted_at,
"invite_owner": RoleChoices.OWNER in roles,
"partial_update": can_update,
"restore": is_owner,
"retrieve": can_get,
"media_auth": can_get,
"link_select_options": LinkReachChoices.get_select_options(ancestors_links),
"tree": can_get,
"update": can_update,
"versions_destroy": is_owner_or_admin,
"versions_list": has_access_role,
"versions_retrieve": has_access_role,
"versions_list": has_role,
"versions_retrieve": has_role,
}
def send_email(self, subject, emails, context=None, language=None):
@@ -869,7 +596,6 @@ class Document(MP_Node, BaseModel):
"document": self,
"domain": domain,
"link": f"{domain}/docs/{self.id}/",
"document_title": self.title or str(_("Untitled Document")),
"logo_img": settings.EMAIL_LOGO_IMG,
}
)
@@ -911,90 +637,12 @@ class Document(MP_Node, BaseModel):
'{name} invited you with the role "{role}" on the following document:'
).format(name=sender_name_email, role=role.lower()),
}
subject = (
context["title"]
if not self.title
else _("{name} shared a document with you: {title}").format(
name=sender_name, title=self.title
)
subject = _("{name} shared a document with you: {title}").format(
name=sender_name, title=self.title
)
self.send_email(subject, [email], context, language)
@transaction.atomic
def soft_delete(self):
"""
Soft delete the document, marking the deletion on descendants.
We still keep the .delete() method untouched for programmatic purposes.
"""
if (
self._meta.model.objects.filter(
models.Q(deleted_at__isnull=False)
| models.Q(ancestors_deleted_at__isnull=False),
pk=self.pk,
).exists()
or self.get_ancestors().filter(deleted_at__isnull=False).exists()
):
raise RuntimeError(
"This document is already deleted or has deleted ancestors."
)
self.ancestors_deleted_at = self.deleted_at = timezone.now()
self.save()
self.invalidate_nb_accesses_cache()
if self.depth > 1:
self._meta.model.objects.filter(pk=self.get_parent().pk).update(
numchild=models.F("numchild") - 1
)
# Mark all descendants as soft deleted
self.get_descendants().filter(ancestors_deleted_at__isnull=True).update(
ancestors_deleted_at=self.ancestors_deleted_at
)
@transaction.atomic
def restore(self):
"""Cancelling a soft delete with checks."""
# This should not happen
if self._meta.model.objects.filter(
pk=self.pk, deleted_at__isnull=True
).exists():
raise RuntimeError("This document is not deleted.")
if self.deleted_at < get_trashbin_cutoff():
raise RuntimeError(
"This document was permanently deleted and cannot be restored."
)
# save the current deleted_at value to exclude it from the descendants update
current_deleted_at = self.deleted_at
# Restore the current document
self.deleted_at = None
# Calculate the minimum `deleted_at` among all ancestors
ancestors_deleted_at = (
self.get_ancestors()
.filter(deleted_at__isnull=False)
.order_by("deleted_at")
.values_list("deleted_at", flat=True)
.first()
)
self.ancestors_deleted_at = ancestors_deleted_at
self.save(update_fields=["deleted_at", "ancestors_deleted_at"])
self.invalidate_nb_accesses_cache()
self.get_descendants().exclude(
models.Q(deleted_at__isnull=False)
| models.Q(ancestors_deleted_at__lt=current_deleted_at)
).update(ancestors_deleted_at=self.ancestors_deleted_at)
if self.depth > 1:
self._meta.model.objects.filter(pk=self.get_parent().pk).update(
numchild=models.F("numchild") + 1
)
class LinkTrace(BaseModel):
"""
@@ -1097,55 +745,11 @@ class DocumentAccess(BaseAccess):
def __str__(self):
return f"{self.user!s} is {self.role:s} in document {self.document!s}"
def save(self, *args, **kwargs):
"""Override save to clear the document's cache for number of accesses."""
super().save(*args, **kwargs)
self.document.invalidate_nb_accesses_cache()
def delete(self, *args, **kwargs):
"""Override delete to clear the document's cache for number of accesses."""
super().delete(*args, **kwargs)
self.document.invalidate_nb_accesses_cache()
def get_abilities(self, user):
"""
Compute and return abilities for a given user on the document access.
"""
roles = self._get_roles(self.document, user)
is_owner_or_admin = bool(set(roles).intersection(set(PRIVILEGED_ROLES)))
if self.role == RoleChoices.OWNER:
can_delete = (
RoleChoices.OWNER in roles
and self.document.accesses.filter(role=RoleChoices.OWNER).count() > 1
)
set_role_to = (
[RoleChoices.ADMIN, RoleChoices.EDITOR, RoleChoices.READER]
if can_delete
else []
)
else:
can_delete = is_owner_or_admin
set_role_to = []
if RoleChoices.OWNER in roles:
set_role_to.append(RoleChoices.OWNER)
if is_owner_or_admin:
set_role_to.extend(
[RoleChoices.ADMIN, RoleChoices.EDITOR, RoleChoices.READER]
)
# Remove the current role as we don't want to propose it as an option
try:
set_role_to.remove(self.role)
except ValueError:
pass
return {
"destroy": can_delete,
"update": bool(set_role_to) and is_owner_or_admin,
"partial_update": bool(set_role_to) and is_owner_or_admin,
"retrieve": self.user and self.user.id == user.id or is_owner_or_admin,
"set_role_to": set_role_to,
}
return self._get_abilities(self.document, user)
class Template(BaseModel):
@@ -1170,27 +774,11 @@ class Template(BaseModel):
def __str__(self):
return self.title
def get_roles(self, user):
"""Return the roles a user has on a resource as an iterable."""
if not user.is_authenticated:
return []
try:
roles = self.user_roles or []
except AttributeError:
try:
roles = self.accesses.filter(
models.Q(user=user) | models.Q(team__in=user.teams),
).values_list("role", flat=True)
except (models.ObjectDoesNotExist, IndexError):
roles = []
return roles
def get_abilities(self, user):
"""
Compute and return abilities for a given user on the template.
"""
roles = self.get_roles(user)
roles = get_resource_roles(self, user)
is_owner_or_admin = bool(
set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
)
@@ -1206,6 +794,107 @@ class Template(BaseModel):
"retrieve": can_get,
}
def generate_pdf(self, body_html, metadata):
"""
Generate and return a pdf document wrapped around the current template
"""
document_html = weasyprint.HTML(
string=DjangoTemplate(self.code).render(
Context({"body": html.format_html(body_html), **metadata})
)
)
css = weasyprint.CSS(
string=self.css,
font_config=weasyprint.text.fonts.FontConfiguration(),
)
pdf_content = document_html.write_pdf(stylesheets=[css], zoom=1)
response = FileResponse(BytesIO(pdf_content), content_type="application/pdf")
response["Content-Disposition"] = f"attachment; filename={self.title}.pdf"
return response
def generate_word(self, body_html, metadata):
"""
Generate and return a docx document wrapped around the current template
"""
template_string = DjangoTemplate(self.code).render(
Context({"body": html.format_html(body_html), **metadata})
)
html_string = f"""
<!DOCTYPE html>
<html>
<head>
<style>
{self.css}
</style>
</head>
<body>
{template_string}
</body>
</html>
"""
reference_docx = "core/static/reference.docx"
output = BytesIO()
# Convert the HTML to a temporary docx file
with tempfile.NamedTemporaryFile(suffix=".docx", prefix="docx_") as tmp_file:
output_path = tmp_file.name
pypandoc.convert_text(
html_string,
"docx",
format="html",
outputfile=output_path,
extra_args=["--reference-doc", reference_docx],
)
# Create a BytesIO object to store the output of the temporary docx file
with open(output_path, "rb") as f:
output = BytesIO(f.read())
# Ensure the pointer is at the beginning
output.seek(0)
response = FileResponse(
output,
content_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
)
response["Content-Disposition"] = f"attachment; filename={self.title}.docx"
return response
def generate_document(self, body, body_type, export_format):
"""
Generate and return a document for this template around the
body passed as argument.
2 types of body are accepted:
- HTML: body_type = "html"
- Markdown: body_type = "markdown"
2 types of documents can be generated:
- PDF: export_format = "pdf"
- Docx: export_format = "docx"
"""
document = frontmatter.loads(body)
metadata = document.metadata
strip_body = document.content.strip()
if body_type == "html":
body_html = strip_body
else:
body_html = (
markdown.markdown(textwrap.dedent(strip_body)) if strip_body else ""
)
if export_format == "pdf":
return self.generate_pdf(body_html, metadata)
return self.generate_word(body_html, metadata)
class TemplateAccess(BaseAccess):
"""Relation model to give access to a template for a user or a team with a role."""
@@ -1294,8 +983,8 @@ class Invitation(BaseModel):
User.objects.filter(email=self.email).exists()
and not settings.OIDC_ALLOW_DUPLICATE_EMAILS
):
raise ValidationError(
{"email": [_("This email is already associated to a registered user.")]}
raise exceptions.ValidationError(
{"email": _("This email is already associated to a registered user.")}
)
@property

View File

@@ -1,5 +1,8 @@
"""AI services."""
import json
import re
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
@@ -9,44 +12,32 @@ from core import enums
AI_ACTIONS = {
"prompt": (
"Answer the prompt in markdown format. "
"Preserve the language and markdown formatting. "
"Do not provide any other information. "
"Preserve the language."
"Answer the prompt in markdown format. Return JSON: "
'{"answer": "Your markdown answer"}. '
"Do not provide any other information."
),
"correct": (
"Correct grammar and spelling of the markdown text, "
"preserving language and markdown formatting. "
"Do not provide any other information. "
"Preserve the language."
'Return JSON: {"answer": "your corrected markdown text"}. '
"Do not provide any other information."
),
"rephrase": (
"Rephrase the given markdown text, "
"preserving language and markdown formatting. "
"Do not provide any other information. "
"Preserve the language."
'Return JSON: {"answer": "your rephrased markdown text"}. '
"Do not provide any other information."
),
"summarize": (
"Summarize the markdown text, preserving language and markdown formatting. "
"Do not provide any other information. "
"Preserve the language."
),
"beautify": (
"Add formatting to the text to make it more readable. "
"Do not provide any other information. "
"Preserve the language."
),
"emojify": (
"Add emojis to the important parts of the text. "
"Do not provide any other information. "
"Preserve the language."
'Return JSON: {"answer": "your markdown summary"}. '
"Do not provide any other information."
),
}
AI_TRANSLATE = (
"Keep the same html stucture and formatting. "
"Translate the content in the html to the specified language {language:s}. "
"Check the translation for accuracy and make any necessary corrections. "
"Translate the markdown text to {language:s}, preserving markdown formatting. "
'Return JSON: {{"answer": "your translated markdown text in {language:s}"}}. '
"Do not provide any other information."
)
@@ -68,18 +59,32 @@ class AIService:
"""Helper method to call the OpenAI API and process the response."""
response = self.client.chat.completions.create(
model=settings.AI_MODEL,
response_format={"type": "json_object"},
messages=[
{"role": "system", "content": system_content},
{"role": "user", "content": text},
{"role": "user", "content": json.dumps({"markdown_input": text})},
],
)
content = response.choices[0].message.content
if not content:
try:
sanitized_content = re.sub(r'\s*"answer"\s*:\s*', '"answer": ', content)
sanitized_content = re.sub(r"\s*\}", "}", sanitized_content)
sanitized_content = re.sub(r"(?<!\\)\n", "\\\\n", sanitized_content)
sanitized_content = re.sub(r"(?<!\\)\t", "\\\\t", sanitized_content)
json_response = json.loads(sanitized_content)
except (json.JSONDecodeError, IndexError):
try:
json_response = json.loads(content)
except json.JSONDecodeError as err:
raise RuntimeError("AI response is not valid JSON", content) from err
if "answer" not in json_response:
raise RuntimeError("AI response does not contain an answer")
return {"answer": content}
return json_response
def transform(self, text, action):
"""Transform text based on specified action."""

View File

@@ -1,25 +0,0 @@
"""Config services."""
import logging
import requests
logger = logging.getLogger(__name__)
def get_footer_json(footer_json_url: str) -> dict:
"""
Fetches the footer JSON from the given URL."
"""
try:
response = requests.get(
footer_json_url, timeout=5, headers={"User-Agent": "Docs-Application"}
)
response.raise_for_status()
footer_json = response.json()
return footer_json
except (requests.RequestException, ValueError) as e:
logger.error("Failed to fetch footer JSON: %s", e)
return {}

Binary file not shown.

View File

@@ -42,9 +42,9 @@ def test_update_files_content_type_metadata():
for key in keys:
head_resp = s3_client.head_object(Bucket=bucket_name, Key=key)
assert head_resp["ContentType"] == "image/png", (
f"ContentType not fixed, got {head_resp['ContentType']!r}"
)
assert (
head_resp["ContentType"] == "image/png"
), f"ContentType not fixed, got {head_resp['ContentType']!r}"
# Check that original metadata was preserved
assert head_resp["Metadata"].get("owner") == "None"

View File

@@ -2,8 +2,6 @@
from unittest import mock
from django.core.cache import cache
import pytest
USER = "user"
@@ -11,12 +9,6 @@ TEAM = "team"
VIA = [USER, TEAM]
@pytest.fixture(autouse=True)
def clear_cache():
"""Fixture to clear the cache before each test."""
cache.clear()
@pytest.fixture
def mock_user_teams():
"""Mock for the "teams" property on the User model."""

View File

@@ -59,32 +59,8 @@ def test_api_document_accesses_list_authenticated_unrelated():
}
def test_api_document_accesses_list_unexisting_document():
"""
Listing document accesses for an unexisting document should return an empty list.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/documents/{uuid4()!s}/accesses/")
assert response.status_code == 200
assert response.json() == {
"count": 0,
"next": None,
"previous": None,
"results": [],
}
@pytest.mark.parametrize("via", VIA)
@pytest.mark.parametrize(
"role", [role for role in models.RoleChoices if role not in models.PRIVILEGED_ROLES]
)
def test_api_document_accesses_list_authenticated_related_non_privileged(
via, role, mock_user_teams
):
def test_api_document_accesses_list_authenticated_related(via, mock_user_teams):
"""
Authenticated users should be able to list document accesses for a document
to which they are directly related, whatever their role in the document.
@@ -94,114 +70,24 @@ def test_api_document_accesses_list_authenticated_related_non_privileged(
client = APIClient()
client.force_login(user)
owner = factories.UserFactory()
accesses = []
document_access = factories.UserDocumentAccessFactory(
user=owner, role=models.RoleChoices.OWNER
)
accesses.append(document_access)
document = document_access.document
if via == USER:
models.DocumentAccess.objects.create(
document=document,
user=user,
role=role,
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
models.DocumentAccess.objects.create(
document=document,
team="lasuite",
role=role,
)
access1 = factories.TeamDocumentAccessFactory(document=document)
access2 = factories.UserDocumentAccessFactory(document=document)
accesses.append(access1)
accesses.append(access2)
# Accesses for other documents to which the user is related should not be listed either
other_access = factories.UserDocumentAccessFactory(user=user)
factories.UserDocumentAccessFactory(document=other_access.document)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/accesses/",
)
# Return only owners
owners_accesses = [
access for access in accesses if access.role in models.PRIVILEGED_ROLES
]
assert response.status_code == 200
content = response.json()
assert content["count"] == len(owners_accesses)
assert sorted(content["results"], key=lambda x: x["id"]) == sorted(
[
{
"id": str(access.id),
"user": {
"id": None,
"email": None,
"full_name": access.user.full_name,
"short_name": access.user.short_name,
}
if access.user
else None,
"team": access.team,
"role": access.role,
"abilities": access.get_abilities(user),
}
for access in owners_accesses
],
key=lambda x: x["id"],
)
for access in content["results"]:
assert access["role"] in models.PRIVILEGED_ROLES
@pytest.mark.parametrize("via", VIA)
@pytest.mark.parametrize("role", models.PRIVILEGED_ROLES)
def test_api_document_accesses_list_authenticated_related_privileged_roles(
via, role, mock_user_teams
):
"""
Authenticated users should be able to list document accesses for a document
to which they are directly related, whatever their role in the document.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
owner = factories.UserFactory()
accesses = []
document_access = factories.UserDocumentAccessFactory(
user=owner, role=models.RoleChoices.OWNER
)
accesses.append(document_access)
document = document_access.document
document = factories.DocumentFactory()
user_access = None
if via == USER:
user_access = models.DocumentAccess.objects.create(
document=document,
user=user,
role=role,
role=random.choice(models.RoleChoices.choices)[0],
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
user_access = models.DocumentAccess.objects.create(
document=document,
team="lasuite",
role=role,
role=random.choice(models.RoleChoices.choices)[0],
)
access1 = factories.TeamDocumentAccessFactory(document=document)
access2 = factories.UserDocumentAccessFactory(document=document)
accesses.append(access1)
accesses.append(access2)
# Accesses for other documents to which the user is related should not be listed either
other_access = factories.UserDocumentAccessFactory(user=user)
@@ -216,7 +102,7 @@ def test_api_document_accesses_list_authenticated_related_privileged_roles(
assert response.status_code == 200
content = response.json()
assert len(content["results"]) == 4
assert len(content["results"]) == 3
assert sorted(content["results"], key=lambda x: x["id"]) == sorted(
[
{
@@ -240,13 +126,6 @@ def test_api_document_accesses_list_authenticated_related_privileged_roles(
"role": access2.role,
"abilities": access2.get_abilities(user),
},
{
"id": str(document_access.id),
"user": serializers.UserSerializer(instance=owner).data,
"team": "",
"role": models.RoleChoices.OWNER,
"abilities": document_access.get_abilities(user),
},
],
key=lambda x: x["id"],
)
@@ -305,10 +184,7 @@ def test_api_document_accesses_retrieve_authenticated_unrelated():
@pytest.mark.parametrize("via", VIA)
@pytest.mark.parametrize("role", models.RoleChoices)
def test_api_document_accesses_retrieve_authenticated_related(
via, role, mock_user_teams
):
def test_api_document_accesses_retrieve_authenticated_related(via, mock_user_teams):
"""
A user who is related to a document should be allowed to retrieve the
associated document user accesses.
@@ -320,12 +196,10 @@ def test_api_document_accesses_retrieve_authenticated_related(
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
factories.UserDocumentAccessFactory(document=document, user=user)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
)
factories.TeamDocumentAccessFactory(document=document, team="lasuite")
access = factories.UserDocumentAccessFactory(document=document)
@@ -333,19 +207,16 @@ def test_api_document_accesses_retrieve_authenticated_related(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
)
if not role in models.PRIVILEGED_ROLES:
assert response.status_code == 403
else:
access_user = serializers.UserSerializer(instance=access.user).data
access_user = serializers.UserSerializer(instance=access.user).data
assert response.status_code == 200
assert response.json() == {
"id": str(access.id),
"user": access_user,
"team": "",
"role": access.role,
"abilities": access.get_abilities(user),
}
assert response.status_code == 200
assert response.json() == {
"id": str(access.id),
"user": access_user,
"team": "",
"role": access.role,
"abilities": access.get_abilities(user),
}
def test_api_document_accesses_update_anonymous():
@@ -356,7 +227,7 @@ def test_api_document_accesses_update_anonymous():
new_values = {
"id": uuid4(),
"user": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.values),
"role": random.choice(models.RoleChoices.choices)[0],
}
api_client = APIClient()
@@ -389,7 +260,7 @@ def test_api_document_accesses_update_authenticated_unrelated():
new_values = {
"id": uuid4(),
"user": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.values),
"role": random.choice(models.RoleChoices.choices)[0],
}
for field, value in new_values.items():
@@ -431,7 +302,7 @@ def test_api_document_accesses_update_authenticated_reader_or_editor(
new_values = {
"id": uuid4(),
"user": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.values),
"role": random.choice(models.RoleChoices.choices)[0],
}
for field, value in new_values.items():
@@ -542,7 +413,7 @@ def test_api_document_accesses_update_administrator_from_owner(via, mock_user_te
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.values),
"role": random.choice(models.RoleChoices.choices)[0],
}
for field, value in new_values.items():
@@ -656,7 +527,7 @@ def test_api_document_accesses_update_owner(
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.values),
"role": random.choice(models.RoleChoices.choices)[0],
}
for field, value in new_values.items():

View File

@@ -16,9 +16,6 @@ from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
# Create
def test_api_document_accesses_create_anonymous():
"""Anonymous users should not be allowed to create document accesses."""
document = factories.DocumentFactory()
@@ -29,7 +26,7 @@ def test_api_document_accesses_create_anonymous():
{
"user_id": str(other_user.id),
"document": str(document.id),
"role": random.choice(models.RoleChoices.values),
"role": random.choice(models.RoleChoices.choices)[0],
},
format="json",
)
@@ -126,7 +123,7 @@ def test_api_document_accesses_create_authenticated_administrator(via, mock_user
document=document, team="lasuite", role="administrator"
)
other_user = factories.UserFactory(language="en-us")
other_user = factories.UserFactory()
# It should not be allowed to create an owner access
response = client.post(
@@ -202,7 +199,7 @@ def test_api_document_accesses_create_authenticated_owner(via, mock_user_teams):
document=document, team="lasuite", role="owner"
)
other_user = factories.UserFactory(language="en-us")
other_user = factories.UserFactory()
role = random.choice([role[0] for role in models.RoleChoices.choices])
@@ -238,73 +235,3 @@ def test_api_document_accesses_create_authenticated_owner(via, mock_user_teams):
f"on the following document: {document.title}"
) in email_content
assert "docs/" + str(document.id) + "/" in email_content
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_create_email_in_receivers_language(via, mock_user_teams):
"""
The email sent to the accesses to notify them of the adding, should be in their language.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="owner"
)
role = random.choice([role[0] for role in models.RoleChoices.choices])
assert len(mail.outbox) == 0
other_users = (
factories.UserFactory(language="en-us"),
factories.UserFactory(language="fr-fr"),
)
for index, other_user in enumerate(other_users):
expected_language = other_user.language
response = client.post(
f"/api/v1.0/documents/{document.id!s}/accesses/",
{
"user_id": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == 201
assert models.DocumentAccess.objects.filter(user=other_user).count() == 1
new_document_access = models.DocumentAccess.objects.filter(
user=other_user
).get()
other_user_data = serializers.UserSerializer(instance=other_user).data
assert response.json() == {
"id": str(new_document_access.id),
"user": other_user_data,
"team": "",
"role": role,
"abilities": new_document_access.get_abilities(user),
}
assert len(mail.outbox) == index + 1
email = mail.outbox[index]
assert email.to == [other_user_data["email"]]
email_content = " ".join(email.body.split())
email_subject = " ".join(email.subject.split())
if expected_language == "en-us":
assert (
f"{user.full_name} shared a document with you: {document.title}".lower()
in email_subject.lower()
)
elif expected_language == "fr-fr":
assert (
f"{user.full_name} a partagé un document avec vous: {document.title}".lower()
in email_subject.lower()
)
assert "docs/" + str(document.id) + "/" in email_content.lower()

View File

@@ -304,7 +304,7 @@ def test_api_document_invitations_create_anonymous():
document = factories.DocumentFactory()
invitation_values = {
"email": "guest@example.com",
"role": random.choice(models.RoleChoices.values),
"role": random.choice(models.RoleChoices.choices)[0],
}
response = APIClient().post(
@@ -325,7 +325,7 @@ def test_api_document_invitations_create_authenticated_outsider():
document = factories.DocumentFactory()
invitation_values = {
"email": "guest@example.com",
"role": random.choice(models.RoleChoices.values),
"role": random.choice(models.RoleChoices.choices)[0],
}
client = APIClient()
@@ -370,7 +370,7 @@ def test_api_document_invitations_create_privileged_members(
Only owners and administrators should be able to invite new users.
Only owners can invite owners.
"""
user = factories.UserFactory(language="en-us")
user = factories.UserFactory()
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=inviting)
@@ -422,12 +422,11 @@ def test_api_document_invitations_create_privileged_members(
}
def test_api_document_invitations_create_email_from_senders_language():
def test_api_document_invitations_create_email_from_content_language():
"""
When inviting on a document a user who does not exist yet in our database,
the invitation email should be sent in the language of the sending user.
The email generated is from the language set in the Content-Language header
"""
user = factories.UserFactory(language="fr-fr")
user = factories.UserFactory()
document = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
@@ -445,6 +444,7 @@ def test_api_document_invitations_create_email_from_senders_language():
f"/api/v1.0/documents/{document.id!s}/invitations/",
invitation_values,
format="json",
headers={"Content-Language": "fr-fr"},
)
assert response.status_code == 201
@@ -458,17 +458,52 @@ def test_api_document_invitations_create_email_from_senders_language():
email_content = " ".join(email.body.split())
assert f"{user.full_name} a partagé un document avec vous!" in email_content
assert (
"Docs, votre nouvel outil incontournable pour organiser, partager et collaborer "
"sur vos documents en équipe." in email_content
def test_api_document_invitations_create_email_from_content_language_not_supported():
"""
If the language from the Content-Language is not supported
it will display the default language, English.
"""
user = factories.UserFactory()
document = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
invitation_values = {
"email": "guest@example.com",
"role": "reader",
}
assert len(mail.outbox) == 0
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/invitations/",
invitation_values,
format="json",
headers={"Content-Language": "not-supported"},
)
assert response.status_code == 201
assert response.json()["email"] == "guest@example.com"
assert models.Invitation.objects.count() == 1
assert len(mail.outbox) == 1
email = mail.outbox[0]
assert email.to == ["guest@example.com"]
email_content = " ".join(email.body.split())
assert f"{user.full_name} shared a document with you!" in email_content
def test_api_document_invitations_create_email_full_name_empty():
"""
If the full name of the user is empty, it will display the email address.
"""
user = factories.UserFactory(full_name="", language="en-us")
user = factories.UserFactory(full_name="")
document = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
@@ -515,7 +550,7 @@ def test_api_document_invitations_create_issuer_and_document_override():
"document": str(other_document.id),
"issuer": str(factories.UserFactory().id),
"email": "guest@example.com",
"role": random.choice(models.RoleChoices.values),
"role": random.choice(models.RoleChoices.choices)[0],
}
client = APIClient()
@@ -560,11 +595,9 @@ def test_api_document_invitations_create_cannot_duplicate_invitation():
)
assert response.status_code == 400
assert response.json() == {
"__all__": [
"Document invitation with this Email address and Document already exists."
],
}
assert response.json() == [
"Document invitation with this Email address and Document already exists."
]
def test_api_document_invitations_create_cannot_invite_existing_users():
@@ -578,7 +611,7 @@ def test_api_document_invitations_create_cannot_invite_existing_users():
# Build an invitation to the email of an exising identity in the db
invitation_values = {
"email": existing_user.email,
"role": random.choice(models.RoleChoices.values),
"role": random.choice(models.RoleChoices.choices)[0],
}
client = APIClient()
@@ -591,9 +624,7 @@ def test_api_document_invitations_create_cannot_invite_existing_users():
)
assert response.status_code == 400
assert response.json() == {
"email": ["This email is already associated to a registered user."]
}
assert response.json() == ["This email is already associated to a registered user."]
# Update

View File

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

View File

@@ -2,9 +2,9 @@
Test AI transform API endpoint for users in impress's core app.
"""
import random
from unittest.mock import MagicMock, patch
from django.core.cache import cache
from django.test import override_settings
import pytest
@@ -16,6 +16,12 @@ from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
@pytest.fixture(autouse=True)
def clear_cache():
"""Fixture to clear the cache before each test."""
cache.clear()
@pytest.fixture
def ai_settings():
"""Fixture to set AI settings."""
@@ -25,9 +31,6 @@ def ai_settings():
yield
@override_settings(
AI_ALLOW_REACH_FROM=random.choice(["public", "authenticated", "restricted"])
)
@pytest.mark.parametrize(
"reach, role",
[
@@ -54,7 +57,6 @@ def test_api_documents_ai_transform_anonymous_forbidden(reach, role):
}
@override_settings(AI_ALLOW_REACH_FROM="public")
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_transform_anonymous_success(mock_create):
@@ -64,40 +66,6 @@ def test_api_documents_ai_transform_anonymous_success(mock_create):
"""
document = factories.DocumentFactory(link_reach="public", link_role="editor")
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content="Salut"))]
)
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
response = APIClient().post(url, {"text": "Hello", "action": "summarize"})
assert response.status_code == 200
assert response.json() == {"answer": "Salut"}
mock_create.assert_called_once_with(
model="llama",
messages=[
{
"role": "system",
"content": (
"Summarize the markdown text, preserving language and markdown formatting. "
"Do not provide any other information. Preserve the language."
),
},
{"role": "user", "content": "Hello"},
],
)
@override_settings(AI_ALLOW_REACH_FROM=random.choice(["authenticated", "restricted"]))
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_transform_anonymous_limited_by_setting(mock_create):
"""
Anonymous users should be able to request AI transform to a document
if the link reach and role permit it.
"""
document = factories.DocumentFactory(link_reach="public", link_role="editor")
answer = '{"answer": "Salut"}'
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=answer))]
@@ -106,7 +74,23 @@ def test_api_documents_ai_transform_anonymous_limited_by_setting(mock_create):
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
response = APIClient().post(url, {"text": "Hello", "action": "summarize"})
assert response.status_code == 401
assert response.status_code == 200
assert response.json() == {"answer": "Salut"}
mock_create.assert_called_once_with(
model="llama",
response_format={"type": "json_object"},
messages=[
{
"role": "system",
"content": (
"Summarize the markdown text, preserving language and markdown formatting. "
'Return JSON: {"answer": "your markdown summary"}. Do not provide any other '
"information."
),
},
{"role": "user", "content": '{"markdown_input": "Hello"}'},
],
)
@pytest.mark.parametrize(
@@ -160,8 +144,9 @@ def test_api_documents_ai_transform_authenticated_success(mock_create, reach, ro
document = factories.DocumentFactory(link_reach=reach, link_role=role)
answer = '{"answer": "Salut"}'
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content="Salut"))]
choices=[MagicMock(message=MagicMock(content=answer))]
)
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
@@ -171,15 +156,16 @@ def test_api_documents_ai_transform_authenticated_success(mock_create, reach, ro
assert response.json() == {"answer": "Salut"}
mock_create.assert_called_once_with(
model="llama",
response_format={"type": "json_object"},
messages=[
{
"role": "system",
"content": (
"Answer the prompt in markdown format. Preserve the language and markdown "
"formatting. Do not provide any other information. Preserve the language."
'Answer the prompt in markdown format. Return JSON: {"answer": '
'"Your markdown answer"}. Do not provide any other information.'
),
},
{"role": "user", "content": "Hello"},
{"role": "user", "content": '{"markdown_input": "Hello"}'},
],
)
@@ -234,8 +220,9 @@ def test_api_documents_ai_transform_success(mock_create, via, role, mock_user_te
document=document, team="lasuite", role=role
)
answer = '{"answer": "Salut"}'
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content="Salut"))]
choices=[MagicMock(message=MagicMock(content=answer))]
)
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
@@ -245,15 +232,16 @@ def test_api_documents_ai_transform_success(mock_create, via, role, mock_user_te
assert response.json() == {"answer": "Salut"}
mock_create.assert_called_once_with(
model="llama",
response_format={"type": "json_object"},
messages=[
{
"role": "system",
"content": (
"Answer the prompt in markdown format. Preserve the language and markdown "
"formatting. Do not provide any other information. Preserve the language."
'Answer the prompt in markdown format. Return JSON: {"answer": '
'"Your markdown answer"}. Do not provide any other information.'
),
},
{"role": "user", "content": "Hello"},
{"role": "user", "content": '{"markdown_input": "Hello"}'},
],
)
@@ -301,8 +289,9 @@ def test_api_documents_ai_transform_throttling_document(mock_create):
client = APIClient()
document = factories.DocumentFactory(link_reach="public", link_role="editor")
answer = '{"answer": "Salut"}'
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content="Salut"))]
choices=[MagicMock(message=MagicMock(content=answer))]
)
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
@@ -335,8 +324,9 @@ def test_api_documents_ai_transform_throttling_user(mock_create):
client = APIClient()
client.force_login(user)
answer = '{"answer": "Salut"}'
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content="Salut"))]
choices=[MagicMock(message=MagicMock(content=answer))]
)
for _ in range(3):

View File

@@ -2,9 +2,9 @@
Test AI translate API endpoint for users in impress's core app.
"""
import random
from unittest.mock import MagicMock, patch
from django.core.cache import cache
from django.test import override_settings
import pytest
@@ -16,6 +16,12 @@ from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
@pytest.fixture(autouse=True)
def clear_cache():
"""Fixture to clear the cache before each test."""
cache.clear()
@pytest.fixture
def ai_settings():
"""Fixture to set AI settings."""
@@ -45,9 +51,6 @@ def test_api_documents_ai_translate_viewset_options_metadata():
}
@override_settings(
AI_ALLOW_REACH_FROM=random.choice(["public", "authenticated", "restricted"])
)
@pytest.mark.parametrize(
"reach, role",
[
@@ -74,7 +77,6 @@ def test_api_documents_ai_translate_anonymous_forbidden(reach, role):
}
@override_settings(AI_ALLOW_REACH_FROM="public")
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_translate_anonymous_success(mock_create):
@@ -84,42 +86,6 @@ def test_api_documents_ai_translate_anonymous_success(mock_create):
"""
document = factories.DocumentFactory(link_reach="public", link_role="editor")
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content="Ola"))]
)
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
response = APIClient().post(url, {"text": "Hello", "language": "es"})
assert response.status_code == 200
assert response.json() == {"answer": "Ola"}
mock_create.assert_called_once_with(
model="llama",
messages=[
{
"role": "system",
"content": (
"Keep the same html stucture and formatting. "
"Translate the content in the html to the specified language Spanish. "
"Check the translation for accuracy and make any necessary corrections. "
"Do not provide any other information."
),
},
{"role": "user", "content": "Hello"},
],
)
@override_settings(AI_ALLOW_REACH_FROM=random.choice(["authenticated", "restricted"]))
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_translate_anonymous_limited_by_setting(mock_create):
"""
Anonymous users should be able to request AI translate to a document
if the link reach and role permit it.
"""
document = factories.DocumentFactory(link_reach="public", link_role="editor")
answer = '{"answer": "Salut"}'
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=answer))]
@@ -128,7 +94,23 @@ def test_api_documents_ai_translate_anonymous_limited_by_setting(mock_create):
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
response = APIClient().post(url, {"text": "Hello", "language": "es"})
assert response.status_code == 401
assert response.status_code == 200
assert response.json() == {"answer": "Salut"}
mock_create.assert_called_once_with(
model="llama",
response_format={"type": "json_object"},
messages=[
{
"role": "system",
"content": (
"Translate the markdown text to Spanish, preserving markdown formatting. "
'Return JSON: {"answer": "your translated markdown text in Spanish"}. '
"Do not provide any other information."
),
},
{"role": "user", "content": '{"markdown_input": "Hello"}'},
],
)
@pytest.mark.parametrize(
@@ -182,8 +164,9 @@ def test_api_documents_ai_translate_authenticated_success(mock_create, reach, ro
document = factories.DocumentFactory(link_reach=reach, link_role=role)
answer = '{"answer": "Salut"}'
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content="Salut"))]
choices=[MagicMock(message=MagicMock(content=answer))]
)
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
@@ -193,18 +176,18 @@ def test_api_documents_ai_translate_authenticated_success(mock_create, reach, ro
assert response.json() == {"answer": "Salut"}
mock_create.assert_called_once_with(
model="llama",
response_format={"type": "json_object"},
messages=[
{
"role": "system",
"content": (
"Keep the same html stucture and formatting. "
"Translate the content in the html to the "
"specified language Colombian Spanish. "
"Check the translation for accuracy and make any necessary corrections. "
"Translate the markdown text to Colombian Spanish, "
"preserving markdown formatting. Return JSON: "
'{"answer": "your translated markdown text in Colombian Spanish"}. '
"Do not provide any other information."
),
},
{"role": "user", "content": "Hello"},
{"role": "user", "content": '{"markdown_input": "Hello"}'},
],
)
@@ -259,8 +242,9 @@ def test_api_documents_ai_translate_success(mock_create, via, role, mock_user_te
document=document, team="lasuite", role=role
)
answer = '{"answer": "Salut"}'
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content="Salut"))]
choices=[MagicMock(message=MagicMock(content=answer))]
)
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
@@ -270,18 +254,18 @@ def test_api_documents_ai_translate_success(mock_create, via, role, mock_user_te
assert response.json() == {"answer": "Salut"}
mock_create.assert_called_once_with(
model="llama",
response_format={"type": "json_object"},
messages=[
{
"role": "system",
"content": (
"Keep the same html stucture and formatting. "
"Translate the content in the html to the "
"specified language Colombian Spanish. "
"Check the translation for accuracy and make any necessary corrections. "
"Translate the markdown text to Colombian Spanish, "
"preserving markdown formatting. Return JSON: "
'{"answer": "your translated markdown text in Colombian Spanish"}. '
"Do not provide any other information."
),
},
{"role": "user", "content": "Hello"},
{"role": "user", "content": '{"markdown_input": "Hello"}'},
],
)
@@ -329,8 +313,9 @@ def test_api_documents_ai_translate_throttling_document(mock_create):
client = APIClient()
document = factories.DocumentFactory(link_reach="public", link_role="editor")
answer = '{"answer": "Salut"}'
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content="Salut"))]
choices=[MagicMock(message=MagicMock(content=answer))]
)
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
@@ -363,8 +348,9 @@ def test_api_documents_ai_translate_throttling_user(mock_create):
client = APIClient()
client.force_login(user)
answer = '{"answer": "Salut"}'
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content="Salut"))]
choices=[MagicMock(message=MagicMock(content=answer))]
)
for _ in range(3):

View File

@@ -67,12 +67,10 @@ def test_api_documents_attachment_upload_anonymous_success():
file_path = response.json()["file"]
match = pattern.search(file_path)
file_id = match.group(1)
# Validate that file_id is a valid UUID
uuid.UUID(file_id)
document.refresh_from_db()
assert document.attachments == [f"{document.id!s}/attachments/{file_id!s}.png"]
# Now, check the metadata of the uploaded file
key = file_path.replace("/media", "")
file_head = default_storage.connection.meta.client.head_object(
@@ -81,7 +79,6 @@ def test_api_documents_attachment_upload_anonymous_success():
assert file_head["Metadata"] == {"owner": "None"}
assert file_head["ContentType"] == "image/png"
assert file_head["ContentDisposition"] == 'inline; filename="test.png"'
@pytest.mark.parametrize(
@@ -114,9 +111,6 @@ def test_api_documents_attachment_upload_authenticated_forbidden(reach, role):
"detail": "You do not have permission to perform this action."
}
document.refresh_from_db()
assert document.attachments == []
@pytest.mark.parametrize(
"reach, role",
@@ -127,8 +121,8 @@ def test_api_documents_attachment_upload_authenticated_forbidden(reach, role):
)
def test_api_documents_attachment_upload_authenticated_success(reach, role):
"""
Autenticated users who are not related to a document should be able to upload
a file when the link reach and role permit it.
Autenticated who are not related to a document should be able to upload a file
if the link reach and role permit it.
"""
user = factories.UserFactory()
@@ -150,9 +144,6 @@ def test_api_documents_attachment_upload_authenticated_success(reach, role):
# Validate that file_id is a valid UUID
uuid.UUID(file_id)
document.refresh_from_db()
assert document.attachments == [f"{document.id!s}/attachments/{file_id!s}.png"]
@pytest.mark.parametrize("via", VIA)
def test_api_documents_attachment_upload_reader(via, mock_user_teams):
@@ -183,9 +174,6 @@ def test_api_documents_attachment_upload_reader(via, mock_user_teams):
"detail": "You do not have permission to perform this action."
}
document.refresh_from_db()
assert document.attachments == []
@pytest.mark.parametrize("role", ["editor", "administrator", "owner"])
@pytest.mark.parametrize("via", VIA)
@@ -222,9 +210,6 @@ def test_api_documents_attachment_upload_success(via, role, mock_user_teams):
# Validate that file_id is a valid UUID
uuid.UUID(file_id)
document.refresh_from_db()
assert document.attachments == [f"{document.id!s}/attachments/{file_id!s}.png"]
# Now, check the metadata of the uploaded file
key = file_path.replace("/media", "")
file_head = default_storage.connection.meta.client.head_object(
@@ -232,7 +217,6 @@ def test_api_documents_attachment_upload_success(via, role, mock_user_teams):
)
assert file_head["Metadata"] == {"owner": str(user.id)}
assert file_head["ContentType"] == "image/png"
assert file_head["ContentDisposition"] == 'inline; filename="test.png"'
def test_api_documents_attachment_upload_invalid(client):
@@ -250,9 +234,6 @@ def test_api_documents_attachment_upload_invalid(client):
assert response.status_code == 400
assert response.json() == {"file": ["No file was submitted."]}
document.refresh_from_db()
assert document.attachments == []
def test_api_documents_attachment_upload_size_limit_exceeded(settings):
"""The uploaded file should not exceeed the maximum size in settings."""
@@ -275,9 +256,6 @@ def test_api_documents_attachment_upload_size_limit_exceeded(settings):
assert response.status_code == 400
assert response.json() == {"file": ["File size exceeds the maximum limit of 1 MB."]}
document.refresh_from_db()
assert document.attachments == []
@pytest.mark.parametrize(
"name,content,extension,content_type",
@@ -313,14 +291,7 @@ def test_api_documents_attachment_upload_fix_extension(
match = pattern.search(file_path)
file_id = match.group(1)
document.refresh_from_db()
assert document.attachments == [
f"{document.id!s}/attachments/{file_id!s}.{extension:s}"
]
assert "-unsafe" in file_id
# Validate that file_id is a valid UUID
file_id = file_id.replace("-unsafe", "")
uuid.UUID(file_id)
# Now, check the metadata of the uploaded file
@@ -330,7 +301,6 @@ def test_api_documents_attachment_upload_fix_extension(
)
assert file_head["Metadata"] == {"owner": str(user.id), "is_unsafe": "true"}
assert file_head["ContentType"] == content_type
assert file_head["ContentDisposition"] == f'attachment; filename="{name:s}"'
def test_api_documents_attachment_upload_empty_file():
@@ -348,9 +318,6 @@ def test_api_documents_attachment_upload_empty_file():
assert response.status_code == 400
assert response.json() == {"file": ["The submitted file is empty."]}
document.refresh_from_db()
assert document.attachments == []
def test_api_documents_attachment_upload_unsafe():
"""A file with an unsafe mime type should be tagged as such."""
@@ -373,12 +340,7 @@ def test_api_documents_attachment_upload_unsafe():
match = pattern.search(file_path)
file_id = match.group(1)
document.refresh_from_db()
assert document.attachments == [f"{document.id!s}/attachments/{file_id!s}.exe"]
assert "-unsafe" in file_id
# Validate that file_id is a valid UUID
file_id = file_id.replace("-unsafe", "")
uuid.UUID(file_id)
# Now, check the metadata of the uploaded file
@@ -388,4 +350,3 @@ def test_api_documents_attachment_upload_unsafe():
)
assert file_head["Metadata"] == {"owner": str(user.id), "is_unsafe": "true"}
assert file_head["ContentType"] == "application/octet-stream"
assert file_head["ContentDisposition"] == 'attachment; filename="script.exe"'

View File

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

View File

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

View File

@@ -1,121 +0,0 @@
"""Test on the CORS proxy API for documents."""
import pytest
import responses
from rest_framework.test import APIClient
from core import factories
pytestmark = pytest.mark.django_db
@responses.activate
def test_api_docs_cors_proxy_valid_url():
"""Test the CORS proxy API for documents with a valid URL."""
document = factories.DocumentFactory(link_reach="public")
client = APIClient()
url_to_fetch = "https://external-url.com/assets/logo-gouv.png"
responses.get(url_to_fetch, body=b"", status=200, content_type="image/png")
response = client.get(
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
)
assert response.status_code == 200
assert response.headers["Content-Type"] == "image/png"
assert response.headers["Content-Disposition"] == "attachment;"
assert (
response.headers["Content-Security-Policy"]
== "default-src 'none'; img-src 'none' data:;"
)
assert response.streaming_content
def test_api_docs_cors_proxy_without_url_query_string():
"""Test the CORS proxy API for documents without a URL query string."""
document = factories.DocumentFactory(link_reach="public")
client = APIClient()
response = client.get(f"/api/v1.0/documents/{document.id!s}/cors-proxy/")
assert response.status_code == 400
assert response.json() == {"detail": "Missing 'url' query parameter"}
@responses.activate
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."""
document = factories.DocumentFactory(link_reach="authenticated")
client = APIClient()
url_to_fetch = "https://external-url.com/assets/logo-gouv.png"
responses.get(url_to_fetch, body=b"", status=200, content_type="image/png")
response = client.get(
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
@responses.activate
def test_api_docs_cors_proxy_authenticated_user_accessing_protected_doc():
"""
Test the CORS proxy API for documents with an authenticated user accessing a protected
document.
"""
document = factories.DocumentFactory(link_reach="authenticated")
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
url_to_fetch = "https://external-url.com/assets/logo-gouv.png"
responses.get(url_to_fetch, body=b"", status=200, content_type="image/png")
response = client.get(
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
)
assert response.status_code == 200
assert response.headers["Content-Type"] == "image/png"
assert response.headers["Content-Disposition"] == "attachment;"
assert (
response.headers["Content-Security-Policy"]
== "default-src 'none'; img-src 'none' data:;"
)
assert response.streaming_content
@responses.activate
def test_api_docs_cors_proxy_authenticated_not_accessing_restricted_doc():
"""
Test the CORS proxy API for documents with an authenticated user not accessing a restricted
document.
"""
document = factories.DocumentFactory(link_reach="restricted")
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
url_to_fetch = "https://external-url.com/assets/logo-gouv.png"
responses.get(url_to_fetch, body=b"", status=200, content_type="image/png")
response = client.get(
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
@responses.activate
def test_api_docs_cors_proxy_unsupported_media_type():
"""Test the CORS proxy API for documents with an unsupported media type."""
document = factories.DocumentFactory(link_reach="public")
client = APIClient()
url_to_fetch = "https://external-url.com/assets/index.html"
responses.get(url_to_fetch, body=b"", status=200, content_type="text/html")
response = client.get(
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
)
assert response.status_code == 415

View File

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

View File

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

View File

@@ -1,88 +0,0 @@
"""
Tests for Documents API endpoint in impress's core app: list
"""
import pytest
from faker import Faker
from rest_framework.test import APIClient
from core import factories
fake = Faker()
pytestmark = pytest.mark.django_db
# Filters: unknown field
def test_api_documents_descendants_filter_unknown_field():
"""
Trying to filter by an unknown field should be ignored.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory()
document = factories.DocumentFactory(users=[user])
expected_ids = {
str(document.id)
for document in factories.DocumentFactory.create_batch(2, parent=document)
}
response = client.get(
f"/api/v1.0/documents/{document.id!s}/descendants/?unknown=true"
)
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 2
assert {result["id"] for result in results} == expected_ids
# Filters: title
@pytest.mark.parametrize(
"query,nb_results",
[
("Project Alpha", 1), # Exact match
("project", 2), # Partial match (case-insensitive)
("Guide", 1), # Word match within a title
("Special", 0), # No match (nonexistent keyword)
("2024", 2), # Match by numeric keyword
("", 5), # Empty string
],
)
def test_api_documents_descendants_filter_title(query, nb_results):
"""Authenticated users should be able to search documents by their title."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[user])
# Create documents with predefined titles
titles = [
"Project Alpha Documentation",
"Project Beta Overview",
"User Guide",
"Financial Report 2024",
"Annual Review 2024",
]
for title in titles:
factories.DocumentFactory(title=title, parent=document)
# Perform the search query
response = client.get(
f"/api/v1.0/documents/{document.id!s}/descendants/?title={query:s}"
)
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == nb_results
# Ensure all results contain the query in their title
for result in results:
assert query.lower().strip() in result["title"].lower()

View File

@@ -1,207 +0,0 @@
"""
Test file uploads API endpoint for users in impress's core app.
"""
import base64
import uuid
from io import BytesIO
from urllib.parse import urlparse
from django.conf import settings
from django.core.files.storage import default_storage
from django.utils import timezone
import pycrdt
import pytest
import requests
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
PIXEL = (
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00"
b"\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\xf8\xff\xff?\x00\x05\xfe\x02\xfe"
b"\xa7V\xbd\xfa\x00\x00\x00\x00IEND\xaeB`\x82"
)
def get_image_refs(document_id):
"""Generate an image key for testing."""
image_key = f"{document_id!s}/attachments/{uuid.uuid4()!s}.png"
default_storage.connection.meta.client.put_object(
Bucket=default_storage.bucket_name,
Key=image_key,
Body=BytesIO(PIXEL),
ContentType="image/png",
)
return image_key, f"http://localhost/media/{image_key:s}"
def test_api_documents_duplicate_forbidden():
"""A user who doesn't have read access to a document should not be allowed to duplicate it."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(
link_reach="restricted",
users=[factories.UserFactory()],
title="my document",
)
response = client.post(f"/api/v1.0/documents/{document.id!s}/duplicate/")
assert response.status_code == 403
assert models.Document.objects.count() == 1
def test_api_documents_duplicate_anonymous():
"""Anonymous users should not be able to duplicate documents even with read access."""
document = factories.DocumentFactory(link_reach="public")
response = APIClient().post(f"/api/v1.0/documents/{document.id!s}/duplicate/")
assert response.status_code == 401
assert models.Document.objects.count() == 1
@pytest.mark.parametrize("index", range(3))
def test_api_documents_duplicate_success(index):
"""
Anonymous users should be able to retrieve attachments linked to a public document.
Accesses should not be duplicated if the user does not request it specifically.
Attachments that are not in the content should not be passed for access in the
duplicated document's "attachments" list.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document_ids = [uuid.uuid4() for _ in range(3)]
image_refs = [get_image_refs(doc_id) for doc_id in document_ids]
# Create document content with the first image only
ydoc = pycrdt.Doc()
fragment = pycrdt.XmlFragment(
[
pycrdt.XmlElement("img", {"src": image_refs[0][1]}),
]
)
ydoc["document-store"] = fragment
update = ydoc.get_update()
base64_content = base64.b64encode(update).decode("utf-8")
# Create documents
document = factories.DocumentFactory(
id=document_ids[index],
content=base64_content,
link_reach="restricted",
users=[user, factories.UserFactory()],
title="document with an image",
attachments=[key for key, _ in image_refs],
)
factories.DocumentFactory(id=document_ids[(index + 1) % 3])
# Don't create document for third ID to check that it doesn't impact access to attachments
# Duplicate the document via the API endpoint
response = client.post(f"/api/v1.0/documents/{document.id}/duplicate/")
assert response.status_code == 201
duplicated_document = models.Document.objects.get(id=response.json()["id"])
assert duplicated_document.title == "Copy of document with an image"
assert duplicated_document.content == document.content
assert duplicated_document.creator == user
assert duplicated_document.link_reach == "restricted"
assert duplicated_document.link_role == "reader"
assert duplicated_document.duplicated_from == document
assert duplicated_document.attachments == [
image_refs[0][0]
] # Only the first image key
assert duplicated_document.get_parent() == document.get_parent()
assert duplicated_document.path == document.get_next_sibling().path
# Check that accesses were not duplicated.
# The user who did the duplicate is forced as owner
assert duplicated_document.accesses.count() == 1
access = duplicated_document.accesses.first()
assert access.user == user
assert access.role == "owner"
# Ensure access persists after the owner loses access to the original document
models.DocumentAccess.objects.filter(document=document).delete()
response = client.get(
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=image_refs[0][1]
)
assert response.status_code == 200
authorization = response["Authorization"]
assert "AWS4-HMAC-SHA256 Credential=" in authorization
assert (
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
in authorization
)
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
response = requests.get(
f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{image_refs[0][0]:s}",
headers={
"authorization": authorization,
"x-amz-date": response["x-amz-date"],
"x-amz-content-sha256": response["x-amz-content-sha256"],
"Host": f"{s3_url.hostname:s}:{s3_url.port:d}",
},
timeout=1,
)
assert response.content == PIXEL
# Ensure the other images are not accessible
for _, url in image_refs[1:]:
response = client.get(
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=url
)
assert response.status_code == 403
def test_api_documents_duplicate_with_accesses():
"""Accesses should be duplicated if the user requests it specifically."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(
users=[user],
title="document with accesses",
)
user_access = factories.UserDocumentAccessFactory(document=document)
team_access = factories.TeamDocumentAccessFactory(document=document)
# Duplicate the document via the API endpoint requesting to duplicate accesses
response = client.post(
f"/api/v1.0/documents/{document.id!s}/duplicate/",
{"with_accesses": True},
format="json",
)
assert response.status_code == 201
duplicated_document = models.Document.objects.get(id=response.json()["id"])
assert duplicated_document.title == "Copy of document with accesses"
assert duplicated_document.content == document.content
assert duplicated_document.link_reach == document.link_reach
assert duplicated_document.link_role == document.link_role
assert duplicated_document.creator == user
assert duplicated_document.duplicated_from == document
assert duplicated_document.attachments == []
# Check that accesses were duplicated and the user who did the duplicate is forced as owner
duplicated_accesses = duplicated_document.accesses
assert duplicated_accesses.count() == 3
assert duplicated_accesses.get(user=user).role == "owner"
assert duplicated_accesses.get(user=user_access.user).role == user_access.role
assert duplicated_accesses.get(team=team_access.team).role == team_access.role

View File

@@ -1,80 +0,0 @@
"""Test for the document favorite_list endpoint."""
import pytest
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
def test_api_document_favorite_list_anonymous():
"""Anonymous users should receive a 401 error."""
client = APIClient()
response = client.get("/api/v1.0/documents/favorite_list/")
assert response.status_code == 401
def test_api_document_favorite_list_authenticated_no_favorite():
"""Authenticated users should receive an empty list."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.get("/api/v1.0/documents/favorite_list/")
assert response.status_code == 200
assert response.json() == {
"count": 0,
"next": None,
"previous": None,
"results": [],
}
def test_api_document_favorite_list_authenticated_with_favorite():
"""Authenticated users with a favorite should receive the favorite."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# User don't have access to this document, let say it had access and this access has been
# removed. It should not be in the favorite list anymore.
factories.DocumentFactory(favorited_by=[user])
document = factories.UserDocumentAccessFactory(
user=user, role=models.RoleChoices.READER, document__favorited_by=[user]
).document
response = client.get("/api/v1.0/documents/favorite_list/")
assert response.status_code == 200
assert response.json() == {
"count": 1,
"next": None,
"previous": None,
"results": [
{
"abilities": document.get_abilities(user),
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"content": document.content,
"depth": document.depth,
"excerpt": document.excerpt,
"id": str(document.id),
"is_favorite": True,
"link_reach": document.link_reach,
"link_role": document.link_role,
"nb_accesses_ancestors": 1,
"nb_accesses_direct": 1,
"numchild": document.numchild,
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": ["reader"],
}
],
}

View File

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

View File

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

View File

@@ -1,10 +1,10 @@
"""
Test media-auth authorization API endpoint in docs core app.
Test file uploads API endpoint for users in impress's core app.
"""
import uuid
from io import BytesIO
from urllib.parse import urlparse
from uuid import uuid4
from django.conf import settings
from django.core.files.storage import default_storage
@@ -14,32 +14,19 @@ import pytest
import requests
from rest_framework.test import APIClient
from core import factories, models
from core import factories
from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
def test_api_documents_media_auth_unkown_document():
"""
Trying to download a media related to a document ID that does not exist
should not have the side effect to create it (no regression test).
"""
original_url = f"http://localhost/media/{uuid4()!s}/attachments/{uuid4()!s}.jpg"
response = APIClient().get(
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 403
assert models.Document.objects.exists() is False
def test_api_documents_media_auth_anonymous_public():
"""Anonymous users should be able to retrieve attachments linked to a public document"""
document_id = uuid4()
filename = f"{uuid4()!s}.jpg"
key = f"{document_id!s}/attachments/{filename:s}"
document = factories.DocumentFactory(link_reach="public")
filename = f"{uuid.uuid4()!s}.jpg"
key = f"{document.pk!s}/attachments/{filename:s}"
default_storage.connection.meta.client.put_object(
Bucket=default_storage.bucket_name,
Key=key,
@@ -47,8 +34,6 @@ def test_api_documents_media_auth_anonymous_public():
ContentType="text/plain",
)
factories.DocumentFactory(id=document_id, link_reach="public", attachments=[key])
original_url = f"http://localhost/media/{key:s}"
response = APIClient().get(
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url
@@ -79,44 +64,16 @@ def test_api_documents_media_auth_anonymous_public():
assert response.content.decode("utf-8") == "my prose"
def test_api_documents_media_auth_extensions():
"""Files with extensions of any format should work."""
extensions = [
"c",
"go",
"gif",
"mp4",
"woff2",
"appimage",
]
document_id = uuid4()
keys = []
for ext in extensions:
filename = f"{uuid4()!s}.{ext:s}"
keys.append(f"{document_id!s}/attachments/{filename:s}")
factories.DocumentFactory(link_reach="public", attachments=keys)
for key in keys:
original_url = f"http://localhost/media/{key:s}"
response = APIClient().get(
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 200
@pytest.mark.parametrize("reach", ["authenticated", "restricted"])
def test_api_documents_media_auth_anonymous_authenticated_or_restricted(reach):
"""
Anonymous users should not be allowed to retrieve attachments linked to a document
with link reach set to authenticated or restricted.
"""
document_id = uuid4()
filename = f"{uuid4()!s}.jpg"
media_url = f"http://localhost/media/{document_id!s}/attachments/{filename:s}"
document = factories.DocumentFactory(link_reach=reach)
factories.DocumentFactory(id=document_id, link_reach=reach)
filename = f"{uuid.uuid4()!s}.jpg"
media_url = f"http://localhost/media/{document.pk!s}/attachments/{filename:s}"
response = APIClient().get(
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url
@@ -126,79 +83,20 @@ def test_api_documents_media_auth_anonymous_authenticated_or_restricted(reach):
assert "Authorization" not in response
def test_api_documents_media_auth_anonymous_attachments():
"""
Declaring a media key as original attachment on a document to which
a user has access should give them access to the attachment file
regardless of their access rights on the original document.
"""
document_id = uuid4()
filename = f"{uuid4()!s}.jpg"
key = f"{document_id!s}/attachments/{filename:s}"
media_url = f"http://localhost/media/{key:s}"
default_storage.connection.meta.client.put_object(
Bucket=default_storage.bucket_name,
Key=key,
Body=BytesIO(b"my prose"),
ContentType="text/plain",
)
factories.DocumentFactory(id=document_id, link_reach="restricted")
response = APIClient().get(
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url
)
assert response.status_code == 403
# Let's now add a document to which the anonymous user has access and
# pointing to the attachment
parent = factories.DocumentFactory(link_reach="public")
factories.DocumentFactory(parent=parent, link_reach="restricted", attachments=[key])
response = APIClient().get(
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url
)
assert response.status_code == 200
authorization = response["Authorization"]
assert "AWS4-HMAC-SHA256 Credential=" in authorization
assert (
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
in authorization
)
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}"
response = requests.get(
file_url,
headers={
"authorization": authorization,
"x-amz-date": response["x-amz-date"],
"x-amz-content-sha256": response["x-amz-content-sha256"],
"Host": f"{s3_url.hostname:s}:{s3_url.port:d}",
},
timeout=1,
)
assert response.content.decode("utf-8") == "my prose"
@pytest.mark.parametrize("reach", ["public", "authenticated"])
def test_api_documents_media_auth_authenticated_public_or_authenticated(reach):
"""
Authenticated users who are not related to a document should be able to retrieve
attachments related to a document with public or authenticated link reach.
"""
document = factories.DocumentFactory(link_reach=reach)
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document_id = uuid4()
filename = f"{uuid4()!s}.jpg"
key = f"{document_id!s}/attachments/{filename:s}"
media_url = f"http://localhost/media/{key:s}"
filename = f"{uuid.uuid4()!s}.jpg"
key = f"{document.pk!s}/attachments/{filename:s}"
default_storage.connection.meta.client.put_object(
Bucket=default_storage.bucket_name,
@@ -207,10 +105,9 @@ def test_api_documents_media_auth_authenticated_public_or_authenticated(reach):
ContentType="text/plain",
)
factories.DocumentFactory(id=document_id, link_reach=reach, attachments=[key])
original_url = f"http://localhost/media/{key:s}"
response = client.get(
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 200
@@ -243,18 +140,14 @@ def test_api_documents_media_auth_authenticated_restricted():
Authenticated users who are not related to a document should not be allowed to
retrieve attachments linked to a document that is restricted.
"""
document = factories.DocumentFactory(link_reach="restricted")
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
document_id = uuid4()
filename = f"{uuid4()!s}.jpg"
key = f"{document_id!s}/attachments/{filename:s}"
media_url = f"http://localhost/media/{key:s}"
factories.DocumentFactory(
id=document_id, link_reach="restricted", attachments=[key]
)
filename = f"{uuid.uuid4()!s}.jpg"
media_url = f"http://localhost/media/{document.pk!s}/attachments/{filename:s}"
response = client.get(
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url
@@ -274,10 +167,16 @@ def test_api_documents_media_auth_related(via, mock_user_teams):
client = APIClient()
client.force_login(user)
document_id = uuid4()
filename = f"{uuid4()!s}.jpg"
key = f"{document_id!s}/attachments/{filename:s}"
media_url = f"http://localhost/media/{key:s}"
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(document=document, team="lasuite")
filename = f"{uuid.uuid4()!s}.jpg"
key = f"{document.pk!s}/attachments/{filename:s}"
default_storage.connection.meta.client.put_object(
Bucket=default_storage.bucket_name,
Key=key,
@@ -285,17 +184,9 @@ def test_api_documents_media_auth_related(via, mock_user_teams):
ContentType="text/plain",
)
document = factories.DocumentFactory(
id=document_id, link_reach="restricted", attachments=[key]
)
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(document=document, team="lasuite")
original_url = f"http://localhost/media/{key:s}"
response = client.get(
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 200

View File

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

View File

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

View File

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

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