mirror of
https://github.com/suitenumerique/docs.git
synced 2026-05-15 11:27:16 +02:00
Compare commits
228 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b200ef7e3 | ||
|
|
81076f18fe | ||
|
|
0cf8b9da1a | ||
|
|
7be761ce84 | ||
|
|
5181bba083 | ||
|
|
f434d78b5d | ||
|
|
e07f709dd4 | ||
|
|
afbacb0a24 | ||
|
|
409e073192 | ||
|
|
886dcb75d5 | ||
|
|
bb4d2a9fea | ||
|
|
5e5054282e | ||
|
|
f497e75426 | ||
|
|
97ab13ded6 | ||
|
|
99d674c615 | ||
|
|
1cdb6b62c8 | ||
|
|
2bf53301d2 | ||
|
|
ec84f31bc7 | ||
|
|
7813219b86 | ||
|
|
cecb4f5756 | ||
|
|
63efe40a7b | ||
|
|
e26c3dff35 | ||
|
|
f5f9d8a877 | ||
|
|
e7709badbb | ||
|
|
2a7c0ef800 | ||
|
|
155e7dfe22 | ||
|
|
afa48b6675 | ||
|
|
f12d30cffa | ||
|
|
30dfea744a | ||
|
|
2cbe363a5f | ||
|
|
7f450e8aa8 | ||
|
|
7021c0f849 | ||
|
|
e8d18d85e9 | ||
|
|
67a195f89c | ||
|
|
09b6fef63f | ||
|
|
11d0bafc94 | ||
|
|
1ae831cabd | ||
|
|
f1c2219270 | ||
|
|
8c9380c356 | ||
|
|
3ff6d2541c | ||
|
|
34ce276222 | ||
|
|
04273c3b3e | ||
|
|
0b301b95c8 | ||
|
|
228bdf733e | ||
|
|
b7ffc766d8 | ||
|
|
148890a295 | ||
|
|
ab05fa6557 | ||
|
|
bbf48f088f | ||
|
|
b28ff8f632 | ||
|
|
cdc24114b6 | ||
|
|
0bd53aed2c | ||
|
|
14b7cdf561 | ||
|
|
c534fed196 | ||
|
|
c1a740b7d4 | ||
|
|
83f2b3886e | ||
|
|
966e514c5a | ||
|
|
ef6d6c6a59 | ||
|
|
e79f3281b1 | ||
|
|
b78550b513 | ||
|
|
5a23c97681 | ||
|
|
040eddbe6b | ||
|
|
f2e54308d2 | ||
|
|
cd6e0ef9e1 | ||
|
|
02acc7233f | ||
|
|
1c71e830a2 | ||
|
|
ac0c16a44a | ||
|
|
ca09f9a158 | ||
|
|
d12b608db9 | ||
|
|
08a0eb59c8 | ||
|
|
0afc50fb93 | ||
|
|
c48a4309c1 | ||
|
|
a212417fb8 | ||
|
|
500d4ea5ac | ||
|
|
8a057b9c39 | ||
|
|
6a12ac560e | ||
|
|
2e6cb109ef | ||
|
|
70635136cb | ||
|
|
52a8dd0b5c | ||
|
|
8a3dfe0252 | ||
|
|
1110ec92d5 | ||
|
|
1d01f6512e | ||
|
|
cd366213ca | ||
|
|
d15285d385 | ||
|
|
377d4e8971 | ||
|
|
70f0c7052c | ||
|
|
ca2e02806a | ||
|
|
33bd5ef116 | ||
|
|
7abe1c9eb4 | ||
|
|
95838e332c | ||
|
|
82f2cb59e6 | ||
|
|
44909faa67 | ||
|
|
1c5270e301 | ||
|
|
6af8d78ede | ||
|
|
304b3be273 | ||
|
|
17ece3b715 | ||
|
|
510d6c3ff1 | ||
|
|
cab7771b82 | ||
|
|
93d9dec068 | ||
|
|
adb15dedb8 | ||
|
|
6ece3264d6 | ||
|
|
2a3b31fcff | ||
|
|
9a64ebc1e9 | ||
|
|
cb2ecfcea3 | ||
|
|
13696ffbd7 | ||
|
|
40ed2d2e22 | ||
|
|
ecb20f6f77 | ||
|
|
7bc060988d | ||
|
|
122e510ff4 | ||
|
|
f717a39109 | ||
|
|
04b8400766 | ||
|
|
d232654c55 | ||
|
|
d0eb2275e5 | ||
|
|
50faf766c8 | ||
|
|
433cead0ac | ||
|
|
d12c637dad | ||
|
|
184b5c015b | ||
|
|
1ab237af3b | ||
|
|
f782a0236b | ||
|
|
c1fc1bd52f | ||
|
|
1c34305393 | ||
|
|
611ba496d2 | ||
|
|
0a9a583a67 | ||
|
|
8f67e382ba | ||
|
|
18d46acd75 | ||
|
|
fae024229e | ||
|
|
df2b953e53 | ||
|
|
a7c91f9443 | ||
|
|
0a5887c162 | ||
|
|
26c7af0dbf | ||
|
|
0499aec624 | ||
|
|
21624e9224 | ||
|
|
b0a9ce0938 | ||
|
|
e256017628 | ||
|
|
50ce604ade | ||
|
|
55979e4370 | ||
|
|
9a8f952210 | ||
|
|
118804e810 | ||
|
|
651f2d1d75 | ||
|
|
b96de36382 | ||
|
|
65b6701708 | ||
|
|
0be366b7b6 | ||
|
|
78a6772bab | ||
|
|
fde520a6f3 | ||
|
|
cef2d274fc | ||
|
|
a9db392a61 | ||
|
|
186ae952f5 | ||
|
|
f3c9c41b86 | ||
|
|
58bf5071c2 | ||
|
|
e148c237f1 | ||
|
|
e82e6a1fcf | ||
|
|
fc1678d0c2 | ||
|
|
2b2e81f042 | ||
|
|
c8ae2f6549 | ||
|
|
1d741871d7 | ||
|
|
6c3850b22b | ||
|
|
31e8ed3a00 | ||
|
|
7e63e9e460 | ||
|
|
388f71d9d0 | ||
|
|
2360a832af | ||
|
|
411d52c73b | ||
|
|
394f91387d | ||
|
|
878de08b1e | ||
|
|
d33286019c | ||
|
|
c2e46fa9e2 | ||
|
|
2e1b112133 | ||
|
|
8f7ac12ea1 | ||
|
|
dfdfe83db5 | ||
|
|
4ae757ce93 | ||
|
|
6964686f7c | ||
|
|
45bbffdf9f | ||
|
|
95a55e7805 | ||
|
|
e9ac36e811 | ||
|
|
d8294ee11d | ||
|
|
00009ecc16 | ||
|
|
9b0676ec15 | ||
|
|
9f222bbaa3 | ||
|
|
f0b253f0ff | ||
|
|
1e76e6e04c | ||
|
|
a71453206b | ||
|
|
71cd016d4d | ||
|
|
2a7ffff96d | ||
|
|
ff8275fb4e | ||
|
|
c3f81c2b62 | ||
|
|
c7261cf507 | ||
|
|
e504f43611 | ||
|
|
3ad6d0ea12 | ||
|
|
9e8a7b3502 | ||
|
|
05db9c8e51 | ||
|
|
7ed33019c2 | ||
|
|
a99c813421 | ||
|
|
a83902a0d4 | ||
|
|
080f855083 | ||
|
|
90d94f6b7a | ||
|
|
f97ab51c8e | ||
|
|
ba4f90a607 | ||
|
|
6c16e081de | ||
|
|
56a945983e | ||
|
|
4fbbead405 | ||
|
|
9a212400a0 | ||
|
|
f07fcd4c0d | ||
|
|
4fc49d5cb2 | ||
|
|
0fd16b4371 | ||
|
|
fbb2799050 | ||
|
|
afbb4b29dc | ||
|
|
db63ebd0c8 | ||
|
|
c5f018e03e | ||
|
|
1c93fbc007 | ||
|
|
d811e3c2fc | ||
|
|
fe5fda5d73 | ||
|
|
bf66265125 | ||
|
|
ce329142dc | ||
|
|
f8cff43dac | ||
|
|
f5b2c27bd8 | ||
|
|
62433ef7f1 | ||
|
|
bc0824d110 | ||
|
|
fa653c6776 | ||
|
|
d12f942d29 | ||
|
|
62f85e7d24 | ||
|
|
65cc088a17 | ||
|
|
94e99784f3 | ||
|
|
fa83955a77 | ||
|
|
5962f7aae1 | ||
|
|
dc06315566 | ||
|
|
f4ad26a8fa | ||
|
|
d952815932 | ||
|
|
cde64ed80a | ||
|
|
cfd88d0469 | ||
|
|
5e45fec296 |
@@ -34,3 +34,4 @@ db.sqlite3
|
|||||||
|
|
||||||
# Frontend
|
# Frontend
|
||||||
node_modules
|
node_modules
|
||||||
|
.next
|
||||||
|
|||||||
23
.gitattributes
vendored
Normal file
23
.gitattributes
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Set the default behavior for all files
|
||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
# Binary files (should not be modified)
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
|
*.jpeg binary
|
||||||
|
*.gif binary
|
||||||
|
*.ico binary
|
||||||
|
*.mov binary
|
||||||
|
*.mp4 binary
|
||||||
|
*.mp3 binary
|
||||||
|
*.flv binary
|
||||||
|
*.fla binary
|
||||||
|
*.swf binary
|
||||||
|
*.gz binary
|
||||||
|
*.zip binary
|
||||||
|
*.7z binary
|
||||||
|
*.ttf binary
|
||||||
|
*.woff binary
|
||||||
|
*.woff2 binary
|
||||||
|
*.eot binary
|
||||||
|
*.pdf binary
|
||||||
6
.github/ISSUE_TEMPLATE.md
vendored
6
.github/ISSUE_TEMPLATE.md
vendored
@@ -1,6 +0,0 @@
|
|||||||
<!---
|
|
||||||
Thanks for filing an issue 😄 ! Before you submit, please read the following:
|
|
||||||
|
|
||||||
Check the other issue templates if you are trying to submit a bug report, feature request, or question
|
|
||||||
Search open/closed issues before submitting since someone might have asked the same thing before!
|
|
||||||
-->
|
|
||||||
4
.github/ISSUE_TEMPLATE/Bug_report.md
vendored
4
.github/ISSUE_TEMPLATE/Bug_report.md
vendored
@@ -6,6 +6,10 @@ labels: ["bug", "triage"]
|
|||||||
|
|
||||||
## Bug Report
|
## Bug Report
|
||||||
|
|
||||||
|
**Before you file your issue**
|
||||||
|
- Check the other [issues](https://github.com/suitenumerique/docs/issues) before filing your own
|
||||||
|
- If your report is related to the ([BlockNote](https://github.com/TypeCellOS/BlockNote)) text editor, [file it on their repo](https://github.com/TypeCellOS/BlockNote/issues). If you're not sure whether your issue is with BlockNote or Docs, file it on our repo: if we support it, we'll backport it upstream ourselves 😊, otherwise we'll ask you to do so.
|
||||||
|
|
||||||
**Problematic behavior**
|
**Problematic behavior**
|
||||||
A clear and concise description of the behavior.
|
A clear and concise description of the behavior.
|
||||||
|
|
||||||
|
|||||||
19
.github/PULL_REQUEST_TEMPLATE.md
vendored
19
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -1,11 +1,22 @@
|
|||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
Description...
|
Describe the purpose of this pull request.
|
||||||
|
|
||||||
|
|
||||||
## Proposal
|
## Proposal
|
||||||
|
|
||||||
Description...
|
- [ ] item 1...
|
||||||
|
- [ ] item 2...
|
||||||
|
|
||||||
- [] item 1...
|
## External contributions
|
||||||
- [] item 2...
|
|
||||||
|
Thank you for your contribution! 🎉
|
||||||
|
|
||||||
|
Please ensure the following items are checked before submitting your pull request:
|
||||||
|
- [ ] I have read and followed the [contributing guidelines](https://github.com/suitenumerique/docs/blob/main/CONTRIBUTING.md)
|
||||||
|
- [ ] I have read and agreed to the [Code of Conduct](https://github.com/suitenumerique/docs/blob/main/CODE_OF_CONDUCT.md)
|
||||||
|
- [ ] I have signed off my commits with `git commit --signoff` (DCO compliance)
|
||||||
|
- [ ] I have signed my commits with my SSH or GPG key (`git commit -S`)
|
||||||
|
- [ ] My commit messages follow the required format: `<gitmoji>(type) title description`
|
||||||
|
- [ ] I have added a changelog entry under `## [Unreleased]` section (if noticeable change)
|
||||||
|
- [ ] I have added corresponding tests for new features or bug fixes (if applicable)
|
||||||
2
.github/workflows/crowdin_download.yml
vendored
2
.github/workflows/crowdin_download.yml
vendored
@@ -10,7 +10,7 @@ jobs:
|
|||||||
install-dependencies:
|
install-dependencies:
|
||||||
uses: ./.github/workflows/dependencies.yml
|
uses: ./.github/workflows/dependencies.yml
|
||||||
with:
|
with:
|
||||||
node_version: '20.x'
|
node_version: '22.x'
|
||||||
with-front-dependencies-installation: true
|
with-front-dependencies-installation: true
|
||||||
|
|
||||||
synchronize-with-crowdin:
|
synchronize-with-crowdin:
|
||||||
|
|||||||
2
.github/workflows/crowdin_upload.yml
vendored
2
.github/workflows/crowdin_upload.yml
vendored
@@ -10,7 +10,7 @@ jobs:
|
|||||||
install-dependencies:
|
install-dependencies:
|
||||||
uses: ./.github/workflows/dependencies.yml
|
uses: ./.github/workflows/dependencies.yml
|
||||||
with:
|
with:
|
||||||
node_version: '20.x'
|
node_version: '22.x'
|
||||||
with-front-dependencies-installation: true
|
with-front-dependencies-installation: true
|
||||||
with-build_mails: true
|
with-build_mails: true
|
||||||
|
|
||||||
|
|||||||
2
.github/workflows/dependencies.yml
vendored
2
.github/workflows/dependencies.yml
vendored
@@ -5,7 +5,7 @@ on:
|
|||||||
inputs:
|
inputs:
|
||||||
node_version:
|
node_version:
|
||||||
required: false
|
required: false
|
||||||
default: '20.x'
|
default: '22.x'
|
||||||
type: string
|
type: string
|
||||||
with-front-dependencies-installation:
|
with-front-dependencies-installation:
|
||||||
type: boolean
|
type: boolean
|
||||||
|
|||||||
18
.github/workflows/impress-frontend.yml
vendored
18
.github/workflows/impress-frontend.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
|||||||
install-dependencies:
|
install-dependencies:
|
||||||
uses: ./.github/workflows/dependencies.yml
|
uses: ./.github/workflows/dependencies.yml
|
||||||
with:
|
with:
|
||||||
node_version: '20.x'
|
node_version: '22.x'
|
||||||
with-front-dependencies-installation: true
|
with-front-dependencies-installation: true
|
||||||
|
|
||||||
test-front:
|
test-front:
|
||||||
@@ -26,7 +26,7 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20.x"
|
node-version: "22.x"
|
||||||
|
|
||||||
- name: Restore the frontend cache
|
- name: Restore the frontend cache
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
@@ -48,7 +48,7 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20.x"
|
node-version: "22.x"
|
||||||
- name: Restore the frontend cache
|
- name: Restore the frontend cache
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
@@ -70,7 +70,7 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20.x"
|
node-version: "22.x"
|
||||||
|
|
||||||
- name: Restore the frontend cache
|
- name: Restore the frontend cache
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
@@ -80,13 +80,13 @@ jobs:
|
|||||||
fail-on-cache-miss: true
|
fail-on-cache-miss: true
|
||||||
|
|
||||||
- name: Set e2e env variables
|
- name: Set e2e env variables
|
||||||
run: cat env.d/development/common.e2e.dist >> env.d/development/common.dist
|
run: cat env.d/development/common.e2e >> env.d/development/common.local
|
||||||
|
|
||||||
- name: Install Playwright Browsers
|
- name: Install Playwright Browsers
|
||||||
run: cd src/frontend/apps/e2e && yarn install --frozen-lockfile && yarn install-playwright chromium
|
run: cd src/frontend/apps/e2e && yarn install --frozen-lockfile && yarn install-playwright chromium
|
||||||
|
|
||||||
- name: Start Docker services
|
- name: Start Docker services
|
||||||
run: make bootstrap FLUSH_ARGS='--no-input' cache=
|
run: make bootstrap-e2e FLUSH_ARGS='--no-input'
|
||||||
|
|
||||||
- name: Run e2e tests
|
- name: Run e2e tests
|
||||||
run: cd src/frontend/ && yarn e2e:test --project='chromium'
|
run: cd src/frontend/ && yarn e2e:test --project='chromium'
|
||||||
@@ -109,7 +109,7 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20.x"
|
node-version: "22.x"
|
||||||
|
|
||||||
- name: Restore the frontend cache
|
- name: Restore the frontend cache
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
@@ -119,13 +119,13 @@ jobs:
|
|||||||
fail-on-cache-miss: true
|
fail-on-cache-miss: true
|
||||||
|
|
||||||
- name: Set e2e env variables
|
- name: Set e2e env variables
|
||||||
run: cat env.d/development/common.e2e.dist >> env.d/development/common.dist
|
run: cat env.d/development/common.e2e >> env.d/development/common.local
|
||||||
|
|
||||||
- name: Install Playwright Browsers
|
- name: Install Playwright Browsers
|
||||||
run: cd src/frontend/apps/e2e && yarn install --frozen-lockfile && yarn install-playwright firefox webkit chromium
|
run: cd src/frontend/apps/e2e && yarn install --frozen-lockfile && yarn install-playwright firefox webkit chromium
|
||||||
|
|
||||||
- name: Start Docker services
|
- name: Start Docker services
|
||||||
run: make bootstrap FLUSH_ARGS='--no-input' cache=
|
run: make bootstrap-e2e FLUSH_ARGS='--no-input'
|
||||||
|
|
||||||
- name: Run e2e tests
|
- name: Run e2e tests
|
||||||
run: cd src/frontend/ && yarn e2e:test --project=firefox --project=webkit
|
run: cd src/frontend/ && yarn e2e:test --project=firefox --project=webkit
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -40,8 +40,7 @@ venv/
|
|||||||
ENV/
|
ENV/
|
||||||
env.bak/
|
env.bak/
|
||||||
venv.bak/
|
venv.bak/
|
||||||
env.d/development/*
|
env.d/development/*.local
|
||||||
!env.d/development/*.dist
|
|
||||||
env.d/terraform
|
env.d/terraform
|
||||||
|
|
||||||
# npm
|
# npm
|
||||||
|
|||||||
189
CHANGELOG.md
189
CHANGELOG.md
@@ -8,6 +8,116 @@ and this project adheres to
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- ⚡️(frontend) improve accessibility:
|
||||||
|
- #1248
|
||||||
|
- #1235
|
||||||
|
- #1255
|
||||||
|
- #1262
|
||||||
|
- #1244
|
||||||
|
- #1270
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 🐛(makefile) Windows compatibility fix for Docker volume mounting #1264
|
||||||
|
- 🐛(minio) fix user permission error with Minio and Windows #1264
|
||||||
|
|
||||||
|
|
||||||
|
## [3.5.0] - 2025-07-31
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- ✨(helm) Service Account support for K8s Resources in Helm Charts #780
|
||||||
|
- ✨(backend) allow masking documents from the list view #1172
|
||||||
|
- ✨(frontend) subdocs can manage link reach #1190
|
||||||
|
- ✨(frontend) add duplicate action to doc tree #1175
|
||||||
|
- ✨(frontend) Interlinking doc #904
|
||||||
|
- ✨(frontend) add multi columns support for editor #1219
|
||||||
|
- ✨(api) add API route to fetch document content #1206
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- ♻️(frontend) search on all docs if no children #1184
|
||||||
|
- ♻️(frontend) redirect to doc after duplicate #1175
|
||||||
|
- 🔧(project) change env.d system by using local files #1200
|
||||||
|
- ⚡️(frontend) improve tree stability #1207
|
||||||
|
- ⚡️(frontend) improve accessibility #1232
|
||||||
|
- 🛂(frontend) block drag n drop when not desktop #1239
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 🐛(service-worker) Fix useOffline Maximum update depth exceeded #1196
|
||||||
|
- 🐛(frontend) fix empty left panel after deleting root doc #1197
|
||||||
|
- 🐛(helm) charts generate invalid YAML for collaboration API / WS #890
|
||||||
|
- 🐛(frontend) 401 redirection overridden #1214
|
||||||
|
- 🐛(frontend) include root parent in search #1243
|
||||||
|
|
||||||
|
## [3.4.2] - 2025-07-18
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- ⚡️(docker) Optimize Dockerfile to use apk with --no-cache #743
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 🐛(backend) improve prompt to not use code blocks delimiter #1188
|
||||||
|
|
||||||
|
## [3.4.1] - 2025-07-15
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 🌐(frontend) keep simple tag during export #1154
|
||||||
|
- 🐛(back) manage can-edit endpoint without created room
|
||||||
|
in the ws #1152
|
||||||
|
- 🐛(frontend) fix action buttons not clickable #1162
|
||||||
|
- 🐛(frontend) fix crash share modal on grid options #1174
|
||||||
|
- 🐛(frontend) fix unfold subdocs not clickable at the bottom #1179
|
||||||
|
|
||||||
|
## [3.4.0] - 2025-07-09
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- ✨(frontend) multi-pages #701
|
||||||
|
- ✨(frontend) Duplicate a doc #1078
|
||||||
|
- ✨Ask for access #1081
|
||||||
|
- ✨(frontend) add customization for translations #857
|
||||||
|
- ✨(backend) add ancestors links definitions to document abilities #846
|
||||||
|
- ✨(backend) include ancestors accesses on document accesses list view #846
|
||||||
|
- ✨(backend) add ancestors links reach and role to document API #846
|
||||||
|
- 📝(project) add troubleshoot doc #1066
|
||||||
|
- 📝(project) add system-requirement doc #1066
|
||||||
|
- 🔧(frontend) configure x-frame-options to DENY in nginx conf #1084
|
||||||
|
- ✨(backend) allow to disable checking unsafe mimetype on
|
||||||
|
attachment upload #1099
|
||||||
|
- ✨(doc) add documentation to install with compose #855
|
||||||
|
- ✨ Give priority to users connected to collaboration server
|
||||||
|
(aka no websocket feature) #1093
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- ♻️(backend) stop requiring owner for non-root documents #846
|
||||||
|
- ♻️(backend) simplify roles by ranking them and return only the max role #846
|
||||||
|
- 📌(yjs) stop pinning node to minor version on yjs docker image #1005
|
||||||
|
- 🧑💻(docker) add .next to .dockerignore #1055
|
||||||
|
- 🧑💻(docker) handle frontend development images with docker compose #1033
|
||||||
|
- 🧑💻(docker) add y-provider config to development environment #1057
|
||||||
|
- ⚡️(frontend) optimize document fetch error handling #1089
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 🐛(backend) fix link definition select options linked to ancestors #846
|
||||||
|
- 🐛(frontend) table of content disappearing #982
|
||||||
|
- 🐛(frontend) fix multiple EmojiPicker #1012
|
||||||
|
- 🐛(frontend) fix meta title #1017
|
||||||
|
- 🔧(git) set LF line endings for all text files #1032
|
||||||
|
- 📝(docs) minor fixes to docs/env.md
|
||||||
|
- ✨support `_FILE` environment variables for secrets #912
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- 🔥(frontend) remove Beta from logo #1095
|
||||||
|
|
||||||
## [3.3.0] - 2025-05-06
|
## [3.3.0] - 2025-05-06
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -33,13 +143,13 @@ and this project adheres to
|
|||||||
- ⬆️(docker) upgrade node images to alpine 3.21 #973
|
- ⬆️(docker) upgrade node images to alpine 3.21 #973
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- 🐛(y-provider) increase JSON size limits for transcription conversion #989
|
- 🐛(y-provider) increase JSON size limits for transcription conversion #989
|
||||||
|
|
||||||
### Removed
|
### Removed
|
||||||
|
|
||||||
- 🔥(back) remove footer endpoint #948
|
- 🔥(back) remove footer endpoint #948
|
||||||
|
|
||||||
|
|
||||||
## [3.2.1] - 2025-05-06
|
## [3.2.1] - 2025-05-06
|
||||||
|
|
||||||
## Fixed
|
## Fixed
|
||||||
@@ -47,7 +157,6 @@ and this project adheres to
|
|||||||
- 🐛(frontend) fix list copy paste #943
|
- 🐛(frontend) fix list copy paste #943
|
||||||
- 📝(doc) update contributing policy (commit signatures are now mandatory) #895
|
- 📝(doc) update contributing policy (commit signatures are now mandatory) #895
|
||||||
|
|
||||||
|
|
||||||
## [3.2.0] - 2025-05-05
|
## [3.2.0] - 2025-05-05
|
||||||
|
|
||||||
## Added
|
## Added
|
||||||
@@ -58,7 +167,7 @@ and this project adheres to
|
|||||||
- ✨(settings) Allow configuring PKCE for the SSO #886
|
- ✨(settings) Allow configuring PKCE for the SSO #886
|
||||||
- 🌐(i18n) activate chinese and spanish languages #884
|
- 🌐(i18n) activate chinese and spanish languages #884
|
||||||
- 🔧(backend) allow overwriting the data directory #893
|
- 🔧(backend) allow overwriting the data directory #893
|
||||||
- ➕(backend) add `django-lasuite` dependency #839
|
- ➕(backend) add `django-lasuite` dependency #839
|
||||||
- ✨(frontend) advanced table features #908
|
- ✨(frontend) advanced table features #908
|
||||||
|
|
||||||
## Changed
|
## Changed
|
||||||
@@ -109,7 +218,6 @@ and this project adheres to
|
|||||||
- 🐛(backend) compute ancestor_links in get_abilities if needed #725
|
- 🐛(backend) compute ancestor_links in get_abilities if needed #725
|
||||||
- 🔒️(back) restrict access to document accesses #801
|
- 🔒️(back) restrict access to document accesses #801
|
||||||
|
|
||||||
|
|
||||||
## [2.6.0] - 2025-03-21
|
## [2.6.0] - 2025-03-21
|
||||||
|
|
||||||
## Added
|
## Added
|
||||||
@@ -128,7 +236,6 @@ and this project adheres to
|
|||||||
- 🔒️(back) throttle user list endpoint #636
|
- 🔒️(back) throttle user list endpoint #636
|
||||||
- 🔒️(back) remove pagination and limit to 5 for user list endpoint #636
|
- 🔒️(back) remove pagination and limit to 5 for user list endpoint #636
|
||||||
|
|
||||||
|
|
||||||
## [2.5.0] - 2025-03-18
|
## [2.5.0] - 2025-03-18
|
||||||
|
|
||||||
## Added
|
## Added
|
||||||
@@ -151,15 +258,14 @@ and this project adheres to
|
|||||||
## Fixed
|
## Fixed
|
||||||
|
|
||||||
- 🐛(frontend) SVG export #706
|
- 🐛(frontend) SVG export #706
|
||||||
- 🐛(frontend) remove scroll listener table content #688
|
- 🐛(frontend) remove scroll listener table content #688
|
||||||
- 🔒️(back) restrict access to favorite_list endpoint #690
|
- 🔒️(back) restrict access to favorite_list endpoint #690
|
||||||
- 🐛(backend) refactor to fix filtering on children
|
- 🐛(backend) refactor to fix filtering on children
|
||||||
and descendants views #695
|
and descendants views #695
|
||||||
- 🐛(action) fix notify-argocd workflow #713
|
- 🐛(action) fix notify-argocd workflow #713
|
||||||
- 🚨(helm) fix helmfile lint #736
|
- 🚨(helm) fix helmfile lint #736
|
||||||
- 🚚(frontend) redirect to 401 page when 401 error #759
|
- 🚚(frontend) redirect to 401 page when 401 error #759
|
||||||
|
|
||||||
|
|
||||||
## [2.4.0] - 2025-03-06
|
## [2.4.0] - 2025-03-06
|
||||||
|
|
||||||
## Added
|
## Added
|
||||||
@@ -174,7 +280,6 @@ and this project adheres to
|
|||||||
|
|
||||||
- 🐛(frontend) fix collaboration error #684
|
- 🐛(frontend) fix collaboration error #684
|
||||||
|
|
||||||
|
|
||||||
## [2.3.0] - 2025-03-03
|
## [2.3.0] - 2025-03-03
|
||||||
|
|
||||||
## Added
|
## Added
|
||||||
@@ -590,33 +695,37 @@ and this project adheres to
|
|||||||
- ✨(frontend) Coming Soon page (#67)
|
- ✨(frontend) Coming Soon page (#67)
|
||||||
- 🚀 Impress, project to manage your documents easily and collaboratively.
|
- 🚀 Impress, project to manage your documents easily and collaboratively.
|
||||||
|
|
||||||
[unreleased]: https://github.com/numerique-gouv/impress/compare/v3.3.0...main
|
[unreleased]: https://github.com/suitenumerique/docs/compare/v3.5.0...main
|
||||||
[v3.3.0]: https://github.com/numerique-gouv/impress/releases/v3.3.0
|
[v3.5.0]: https://github.com/suitenumerique/docs/releases/v3.5.0
|
||||||
[v3.2.1]: https://github.com/numerique-gouv/impress/releases/v3.2.1
|
[v3.4.2]: https://github.com/suitenumerique/docs/releases/v3.4.2
|
||||||
[v3.2.0]: https://github.com/numerique-gouv/impress/releases/v3.2.0
|
[v3.4.1]: https://github.com/suitenumerique/docs/releases/v3.4.1
|
||||||
[v3.1.0]: https://github.com/numerique-gouv/impress/releases/v3.1.0
|
[v3.4.0]: https://github.com/suitenumerique/docs/releases/v3.4.0
|
||||||
[v3.0.0]: https://github.com/numerique-gouv/impress/releases/v3.0.0
|
[v3.3.0]: https://github.com/suitenumerique/docs/releases/v3.3.0
|
||||||
[v2.6.0]: https://github.com/numerique-gouv/impress/releases/v2.6.0
|
[v3.2.1]: https://github.com/suitenumerique/docs/releases/v3.2.1
|
||||||
[v2.5.0]: https://github.com/numerique-gouv/impress/releases/v2.5.0
|
[v3.2.0]: https://github.com/suitenumerique/docs/releases/v3.2.0
|
||||||
[v2.4.0]: https://github.com/numerique-gouv/impress/releases/v2.4.0
|
[v3.1.0]: https://github.com/suitenumerique/docs/releases/v3.1.0
|
||||||
[v2.3.0]: https://github.com/numerique-gouv/impress/releases/v2.3.0
|
[v3.0.0]: https://github.com/suitenumerique/docs/releases/v3.0.0
|
||||||
[v2.2.0]: https://github.com/numerique-gouv/impress/releases/v2.2.0
|
[v2.6.0]: https://github.com/suitenumerique/docs/releases/v2.6.0
|
||||||
[v2.1.0]: https://github.com/numerique-gouv/impress/releases/v2.1.0
|
[v2.5.0]: https://github.com/suitenumerique/docs/releases/v2.5.0
|
||||||
[v2.0.1]: https://github.com/numerique-gouv/impress/releases/v2.0.1
|
[v2.4.0]: https://github.com/suitenumerique/docs/releases/v2.4.0
|
||||||
[v2.0.0]: https://github.com/numerique-gouv/impress/releases/v2.0.0
|
[v2.3.0]: https://github.com/suitenumerique/docs/releases/v2.3.0
|
||||||
[v1.10.0]: https://github.com/numerique-gouv/impress/releases/v1.10.0
|
[v2.2.0]: https://github.com/suitenumerique/docs/releases/v2.2.0
|
||||||
[v1.9.0]: https://github.com/numerique-gouv/impress/releases/v1.9.0
|
[v2.1.0]: https://github.com/suitenumerique/docs/releases/v2.1.0
|
||||||
[v1.8.2]: https://github.com/numerique-gouv/impress/releases/v1.8.2
|
[v2.0.1]: https://github.com/suitenumerique/docs/releases/v2.0.1
|
||||||
[v1.8.1]: https://github.com/numerique-gouv/impress/releases/v1.8.1
|
[v2.0.0]: https://github.com/suitenumerique/docs/releases/v2.0.0
|
||||||
[v1.8.0]: https://github.com/numerique-gouv/impress/releases/v1.8.0
|
[v1.10.0]: https://github.com/suitenumerique/docs/releases/v1.10.0
|
||||||
[v1.7.0]: https://github.com/numerique-gouv/impress/releases/v1.7.0
|
[v1.9.0]: https://github.com/suitenumerique/docs/releases/v1.9.0
|
||||||
[v1.6.0]: https://github.com/numerique-gouv/impress/releases/v1.6.0
|
[v1.8.2]: https://github.com/suitenumerique/docs/releases/v1.8.2
|
||||||
[1.5.1]: https://github.com/numerique-gouv/impress/releases/v1.5.1
|
[v1.8.1]: https://github.com/suitenumerique/docs/releases/v1.8.1
|
||||||
[1.5.0]: https://github.com/numerique-gouv/impress/releases/v1.5.0
|
[v1.8.0]: https://github.com/suitenumerique/docs/releases/v1.8.0
|
||||||
[1.4.0]: https://github.com/numerique-gouv/impress/releases/v1.4.0
|
[v1.7.0]: https://github.com/suitenumerique/docs/releases/v1.7.0
|
||||||
[1.3.0]: https://github.com/numerique-gouv/impress/releases/v1.3.0
|
[v1.6.0]: https://github.com/suitenumerique/docs/releases/v1.6.0
|
||||||
[1.2.1]: https://github.com/numerique-gouv/impress/releases/v1.2.1
|
[1.5.1]: https://github.com/suitenumerique/docs/releases/v1.5.1
|
||||||
[1.2.0]: https://github.com/numerique-gouv/impress/releases/v1.2.0
|
[1.5.0]: https://github.com/suitenumerique/docs/releases/v1.5.0
|
||||||
[1.1.0]: https://github.com/numerique-gouv/impress/releases/v1.1.0
|
[1.4.0]: https://github.com/suitenumerique/docs/releases/v1.4.0
|
||||||
[1.0.0]: https://github.com/numerique-gouv/impress/releases/v1.0.0
|
[1.3.0]: https://github.com/suitenumerique/docs/releases/v1.3.0
|
||||||
[0.1.0]: https://github.com/numerique-gouv/impress/releases/v0.1.0
|
[1.2.1]: https://github.com/suitenumerique/docs/releases/v1.2.1
|
||||||
|
[1.2.0]: https://github.com/suitenumerique/docs/releases/v1.2.0
|
||||||
|
[1.1.0]: https://github.com/suitenumerique/docs/releases/v1.1.0
|
||||||
|
[1.0.0]: https://github.com/suitenumerique/docs/releases/v1.0.0
|
||||||
|
[0.1.0]: https://github.com/suitenumerique/docs/releases/v0.1.0
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ FROM python:3.13.3-alpine AS base
|
|||||||
RUN python -m pip install --upgrade pip setuptools
|
RUN python -m pip install --upgrade pip setuptools
|
||||||
|
|
||||||
# Upgrade system packages to install security updates
|
# Upgrade system packages to install security updates
|
||||||
RUN apk update && \
|
RUN apk update && apk upgrade --no-cache
|
||||||
apk upgrade
|
|
||||||
|
|
||||||
# ---- Back-end builder image ----
|
# ---- Back-end builder image ----
|
||||||
FROM base AS back-builder
|
FROM base AS back-builder
|
||||||
@@ -45,7 +44,7 @@ FROM base AS link-collector
|
|||||||
ARG IMPRESS_STATIC_ROOT=/data/static
|
ARG IMPRESS_STATIC_ROOT=/data/static
|
||||||
|
|
||||||
# Install pango & rdfind
|
# Install pango & rdfind
|
||||||
RUN apk add \
|
RUN apk add --no-cache \
|
||||||
pango \
|
pango \
|
||||||
rdfind
|
rdfind
|
||||||
|
|
||||||
@@ -71,7 +70,7 @@ FROM base AS core
|
|||||||
ENV PYTHONUNBUFFERED=1
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
# Install required system libs
|
# Install required system libs
|
||||||
RUN apk add \
|
RUN apk add --no-cache \
|
||||||
cairo \
|
cairo \
|
||||||
file \
|
file \
|
||||||
font-noto \
|
font-noto \
|
||||||
@@ -117,7 +116,7 @@ FROM core AS backend-development
|
|||||||
USER root:root
|
USER root:root
|
||||||
|
|
||||||
# Install psql
|
# Install psql
|
||||||
RUN apk add postgresql-client
|
RUN apk add --no-cache postgresql-client
|
||||||
|
|
||||||
# Uninstall impress and re-install it in editable mode along with development
|
# Uninstall impress and re-install it in editable mode along with development
|
||||||
# dependencies
|
# dependencies
|
||||||
|
|||||||
101
Makefile
101
Makefile
@@ -35,10 +35,15 @@ DB_PORT = 5432
|
|||||||
|
|
||||||
# -- Docker
|
# -- Docker
|
||||||
# Get the current user ID to use for docker run and docker exec commands
|
# Get the current user ID to use for docker run and docker exec commands
|
||||||
DOCKER_UID = $(shell id -u)
|
ifeq ($(OS),Windows_NT)
|
||||||
DOCKER_GID = $(shell id -g)
|
DOCKER_USER := 0:0 # run containers as root on Windows
|
||||||
DOCKER_USER = $(DOCKER_UID):$(DOCKER_GID)
|
else
|
||||||
|
DOCKER_UID := $(shell id -u)
|
||||||
|
DOCKER_GID := $(shell id -g)
|
||||||
|
DOCKER_USER := $(DOCKER_UID):$(DOCKER_GID)
|
||||||
|
endif
|
||||||
COMPOSE = DOCKER_USER=$(DOCKER_USER) docker compose
|
COMPOSE = DOCKER_USER=$(DOCKER_USER) docker compose
|
||||||
|
COMPOSE_E2E = DOCKER_USER=$(DOCKER_USER) docker compose -f compose.yml -f compose-e2e.yml
|
||||||
COMPOSE_EXEC = $(COMPOSE) exec
|
COMPOSE_EXEC = $(COMPOSE) exec
|
||||||
COMPOSE_EXEC_APP = $(COMPOSE_EXEC) app-dev
|
COMPOSE_EXEC_APP = $(COMPOSE_EXEC) app-dev
|
||||||
COMPOSE_RUN = $(COMPOSE) run --rm
|
COMPOSE_RUN = $(COMPOSE) run --rm
|
||||||
@@ -47,7 +52,7 @@ COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
|
|||||||
|
|
||||||
# -- Backend
|
# -- Backend
|
||||||
MANAGE = $(COMPOSE_RUN_APP) python manage.py
|
MANAGE = $(COMPOSE_RUN_APP) python manage.py
|
||||||
MAIL_YARN = $(COMPOSE_RUN) -w /app/src/mail node yarn
|
MAIL_YARN = $(COMPOSE_RUN) -w //app/src/mail node yarn
|
||||||
|
|
||||||
# -- Frontend
|
# -- Frontend
|
||||||
PATH_FRONT = ./src/frontend
|
PATH_FRONT = ./src/frontend
|
||||||
@@ -66,30 +71,47 @@ data/static:
|
|||||||
|
|
||||||
# -- Project
|
# -- Project
|
||||||
|
|
||||||
create-env-files: ## Copy the dist env files to env files
|
create-env-local-files: ## create env.local files in env.d/development
|
||||||
create-env-files: \
|
create-env-local-files:
|
||||||
env.d/development/common \
|
@touch env.d/development/crowdin.local
|
||||||
env.d/development/crowdin \
|
@touch env.d/development/common.local
|
||||||
env.d/development/postgresql \
|
@touch env.d/development/postgresql.local
|
||||||
env.d/development/kc_postgresql
|
@touch env.d/development/kc_postgresql.local
|
||||||
.PHONY: create-env-files
|
.PHONY: create-env-local-files
|
||||||
|
|
||||||
bootstrap: ## Prepare Docker images for the project
|
pre-bootstrap: \
|
||||||
bootstrap: \
|
|
||||||
data/media \
|
data/media \
|
||||||
data/static \
|
data/static \
|
||||||
create-env-files \
|
create-env-local-files
|
||||||
build \
|
.PHONY: pre-bootstrap
|
||||||
|
|
||||||
|
post-bootstrap: \
|
||||||
migrate \
|
migrate \
|
||||||
demo \
|
demo \
|
||||||
back-i18n-compile \
|
back-i18n-compile \
|
||||||
mails-install \
|
mails-install \
|
||||||
mails-build \
|
mails-build
|
||||||
|
.PHONY: post-bootstrap
|
||||||
|
|
||||||
|
|
||||||
|
bootstrap: ## Prepare Docker developmentimages for the project
|
||||||
|
bootstrap: \
|
||||||
|
pre-bootstrap \
|
||||||
|
build \
|
||||||
|
post-bootstrap \
|
||||||
run
|
run
|
||||||
.PHONY: bootstrap
|
.PHONY: bootstrap
|
||||||
|
|
||||||
|
bootstrap-e2e: ## Prepare Docker production images to be used for e2e tests
|
||||||
|
bootstrap-e2e: \
|
||||||
|
pre-bootstrap \
|
||||||
|
build-e2e \
|
||||||
|
post-bootstrap \
|
||||||
|
run-e2e
|
||||||
|
.PHONY: bootstrap-e2e
|
||||||
|
|
||||||
# -- Docker/compose
|
# -- Docker/compose
|
||||||
build: cache ?= --no-cache
|
build: cache ?=
|
||||||
build: ## build the project containers
|
build: ## build the project containers
|
||||||
@$(MAKE) build-backend cache=$(cache)
|
@$(MAKE) build-backend cache=$(cache)
|
||||||
@$(MAKE) build-yjs-provider cache=$(cache)
|
@$(MAKE) build-yjs-provider cache=$(cache)
|
||||||
@@ -103,16 +125,23 @@ build-backend: ## build the app-dev container
|
|||||||
|
|
||||||
build-yjs-provider: cache ?=
|
build-yjs-provider: cache ?=
|
||||||
build-yjs-provider: ## build the y-provider container
|
build-yjs-provider: ## build the y-provider container
|
||||||
@$(COMPOSE) build y-provider $(cache)
|
@$(COMPOSE) build y-provider-development $(cache)
|
||||||
.PHONY: build-yjs-provider
|
.PHONY: build-yjs-provider
|
||||||
|
|
||||||
build-frontend: cache ?=
|
build-frontend: cache ?=
|
||||||
build-frontend: ## build the frontend container
|
build-frontend: ## build the frontend container
|
||||||
@$(COMPOSE) build frontend $(cache)
|
@$(COMPOSE) build frontend-development $(cache)
|
||||||
.PHONY: build-frontend
|
.PHONY: build-frontend
|
||||||
|
|
||||||
|
build-e2e: cache ?=
|
||||||
|
build-e2e: ## build the e2e container
|
||||||
|
@$(MAKE) build-backend cache=$(cache)
|
||||||
|
@$(COMPOSE_E2E) build frontend $(cache)
|
||||||
|
@$(COMPOSE_E2E) build y-provider $(cache)
|
||||||
|
.PHONY: build-e2e
|
||||||
|
|
||||||
down: ## stop and remove containers, networks, images, and volumes
|
down: ## stop and remove containers, networks, images, and volumes
|
||||||
@$(COMPOSE) down
|
@$(COMPOSE_E2E) down
|
||||||
.PHONY: down
|
.PHONY: down
|
||||||
|
|
||||||
logs: ## display app-dev logs (follow mode)
|
logs: ## display app-dev logs (follow mode)
|
||||||
@@ -121,22 +150,30 @@ logs: ## display app-dev logs (follow mode)
|
|||||||
|
|
||||||
run-backend: ## Start only the backend application and all needed services
|
run-backend: ## Start only the backend application and all needed services
|
||||||
@$(COMPOSE) up --force-recreate -d celery-dev
|
@$(COMPOSE) up --force-recreate -d celery-dev
|
||||||
@$(COMPOSE) up --force-recreate -d y-provider
|
@$(COMPOSE) up --force-recreate -d y-provider-development
|
||||||
@$(COMPOSE) up --force-recreate -d nginx
|
@$(COMPOSE) up --force-recreate -d nginx
|
||||||
.PHONY: run-backend
|
.PHONY: run-backend
|
||||||
|
|
||||||
run: ## start the wsgi (production) and development server
|
run: ## start the wsgi (production) and development server
|
||||||
run:
|
run:
|
||||||
@$(MAKE) run-backend
|
@$(MAKE) run-backend
|
||||||
@$(COMPOSE) up --force-recreate -d frontend
|
@$(COMPOSE) up --force-recreate -d frontend-development
|
||||||
.PHONY: run
|
.PHONY: run
|
||||||
|
|
||||||
|
run-e2e: ## start the e2e server
|
||||||
|
run-e2e:
|
||||||
|
@$(MAKE) run-backend
|
||||||
|
@$(COMPOSE_E2E) stop y-provider-development
|
||||||
|
@$(COMPOSE_E2E) up --force-recreate -d frontend
|
||||||
|
@$(COMPOSE_E2E) up --force-recreate -d y-provider
|
||||||
|
.PHONY: run-e2e
|
||||||
|
|
||||||
status: ## an alias for "docker compose ps"
|
status: ## an alias for "docker compose ps"
|
||||||
@$(COMPOSE) ps
|
@$(COMPOSE_E2E) ps
|
||||||
.PHONY: status
|
.PHONY: status
|
||||||
|
|
||||||
stop: ## stop the development server using Docker
|
stop: ## stop the development server using Docker
|
||||||
@$(COMPOSE) stop
|
@$(COMPOSE_E2E) stop
|
||||||
.PHONY: stop
|
.PHONY: stop
|
||||||
|
|
||||||
# -- Backend
|
# -- Backend
|
||||||
@@ -225,20 +262,6 @@ resetdb: ## flush database and create a superuser "admin"
|
|||||||
@${MAKE} superuser
|
@${MAKE} superuser
|
||||||
.PHONY: resetdb
|
.PHONY: resetdb
|
||||||
|
|
||||||
env.d/development/common:
|
|
||||||
cp -n env.d/development/common.dist env.d/development/common
|
|
||||||
|
|
||||||
env.d/development/postgresql:
|
|
||||||
cp -n env.d/development/postgresql.dist env.d/development/postgresql
|
|
||||||
|
|
||||||
env.d/development/kc_postgresql:
|
|
||||||
cp -n env.d/development/kc_postgresql.dist env.d/development/kc_postgresql
|
|
||||||
|
|
||||||
# -- Internationalization
|
|
||||||
|
|
||||||
env.d/development/crowdin:
|
|
||||||
cp -n env.d/development/crowdin.dist env.d/development/crowdin
|
|
||||||
|
|
||||||
crowdin-download: ## Download translated message from crowdin
|
crowdin-download: ## Download translated message from crowdin
|
||||||
@$(COMPOSE_RUN_CROWDIN) download -c crowdin/config.yml
|
@$(COMPOSE_RUN_CROWDIN) download -c crowdin/config.yml
|
||||||
.PHONY: crowdin-download
|
.PHONY: crowdin-download
|
||||||
@@ -315,7 +338,7 @@ frontend-lint: ## run the frontend linter
|
|||||||
.PHONY: frontend-lint
|
.PHONY: frontend-lint
|
||||||
|
|
||||||
run-frontend-development: ## Run the frontend in development mode
|
run-frontend-development: ## Run the frontend in development mode
|
||||||
@$(COMPOSE) stop frontend
|
@$(COMPOSE) stop frontend-development
|
||||||
cd $(PATH_FRONT_IMPRESS) && yarn dev
|
cd $(PATH_FRONT_IMPRESS) && yarn dev
|
||||||
.PHONY: run-frontend-development
|
.PHONY: run-frontend-development
|
||||||
|
|
||||||
|
|||||||
18
README.md
18
README.md
@@ -11,7 +11,7 @@
|
|||||||
<img alt="GitHub commit activity" src="https://img.shields.io/github/commit-activity/m/suitenumerique/docs"/>
|
<img alt="GitHub commit activity" src="https://img.shields.io/github/commit-activity/m/suitenumerique/docs"/>
|
||||||
<img alt="GitHub closed issues" src="https://img.shields.io/github/issues-closed/suitenumerique/docs"/>
|
<img alt="GitHub closed issues" src="https://img.shields.io/github/issues-closed/suitenumerique/docs"/>
|
||||||
<a href="https://github.com/suitenumerique/docs/blob/main/LICENSE">
|
<a href="https://github.com/suitenumerique/docs/blob/main/LICENSE">
|
||||||
<img alt="GitHub closed issues" src="https://img.shields.io/github/license/suitenumerique/docs"/>
|
<img alt="MIT License" src="https://img.shields.io/github/license/suitenumerique/docs"/>
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -34,8 +34,6 @@ Docs, where your notes can become knowledge through live collaboration.
|
|||||||
## Why use Docs ❓
|
## Why use Docs ❓
|
||||||
Docs is a collaborative text editor designed to address common challenges in knowledge building and sharing.
|
Docs is a collaborative text editor designed to address common challenges in knowledge building and sharing.
|
||||||
|
|
||||||
It offers a scalable and secure alternative to tools such as Google Docs, Notion (without the dbs), Outline, or Confluence.
|
|
||||||
|
|
||||||
### Write
|
### Write
|
||||||
* 😌 Get simple, accessible online editing for your team.
|
* 😌 Get simple, accessible online editing for your team.
|
||||||
* 💅 Create clean documents with beautiful formatting options.
|
* 💅 Create clean documents with beautiful formatting options.
|
||||||
@@ -57,7 +55,7 @@ Available methods: Helm chart, Nix package
|
|||||||
|
|
||||||
In the works: Docker Compose, YunoHost
|
In the works: Docker Compose, YunoHost
|
||||||
|
|
||||||
⚠️ For some advanced features (ex: Export as PDF) Docs relies on XL packages from BlockNote. These are licenced under AGPL-3.0 and are not MIT compatible. You can perfectly use Docs without these packages by setting the environment variable `PUBLISH_AS_MIT` to true. That way you'll build an image of the application without the features that are not MIT compatible. Read the [environment variables documentation](/docs/docs/env.md) for more information.
|
⚠️ For some advanced features (ex: Export as PDF) Docs relies on XL packages from BlockNote. These are licenced under AGPL-3.0 and are not MIT compatible. You can perfectly use Docs without these packages by setting the environment variable `PUBLISH_AS_MIT` to true. That way you'll build an image of the application without the features that are not MIT compatible. Read the [environment variables documentation](/docs/env.md) for more information.
|
||||||
|
|
||||||
## Getting started 🔧
|
## Getting started 🔧
|
||||||
|
|
||||||
@@ -93,11 +91,11 @@ The easiest way to start working on the project is to use [GNU Make](https://www
|
|||||||
$ make bootstrap FLUSH_ARGS='--no-input'
|
$ make bootstrap FLUSH_ARGS='--no-input'
|
||||||
```
|
```
|
||||||
|
|
||||||
This command builds the `app` container, installs dependencies, performs database migrations and compiles translations. It's a good idea to use this command each time you are pulling code from the project repository to avoid dependency-related or migration-related issues.
|
This command builds the `app-dev` and `frontend-dev` containers, installs dependencies, performs database migrations and compiles translations. It's a good idea to use this command each time you are pulling code from the project repository to avoid dependency-related or migration-related issues.
|
||||||
|
|
||||||
Your Docker services should now be up and running 🎉
|
Your Docker services should now be up and running 🎉
|
||||||
|
|
||||||
You can access to the project by going to <http://localhost:3000>.
|
You can access the project by going to <http://localhost:3000>.
|
||||||
|
|
||||||
You will be prompted to log in. The default credentials are:
|
You will be prompted to log in. The default credentials are:
|
||||||
|
|
||||||
@@ -106,7 +104,7 @@ username: impress
|
|||||||
password: impress
|
password: impress
|
||||||
```
|
```
|
||||||
|
|
||||||
📝 Note that if you need to run them afterwards, you can use the eponym Make rule:
|
📝 Note that if you need to run them afterwards, you can use the eponymous Make rule:
|
||||||
|
|
||||||
```shellscript
|
```shellscript
|
||||||
$ make run
|
$ make run
|
||||||
@@ -162,15 +160,15 @@ $ make superuser
|
|||||||
|
|
||||||
We'd love to hear your thoughts, and hear about your experiments, so come and say hi on [Matrix](https://matrix.to/#/#docs-official:matrix.org).
|
We'd love to hear your thoughts, and hear about your experiments, so come and say hi on [Matrix](https://matrix.to/#/#docs-official:matrix.org).
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap 💡
|
||||||
|
|
||||||
Want to know where the project is headed? [🗺️ Checkout our roadmap](https://github.com/orgs/numerique-gouv/projects/13/views/11)
|
Want to know where the project is headed? [🗺️ Checkout our roadmap](https://github.com/orgs/numerique-gouv/projects/13/views/11)
|
||||||
|
|
||||||
## Licence 📝
|
## License 📝
|
||||||
|
|
||||||
This work is released under the MIT License (see [LICENSE](https://github.com/suitenumerique/docs/blob/main/LICENSE)).
|
This work is released under the MIT License (see [LICENSE](https://github.com/suitenumerique/docs/blob/main/LICENSE)).
|
||||||
|
|
||||||
While Docs is a public-driven initiative, our licence choice is an invitation for private sector actors to use, sell and contribute to the project.
|
While Docs is a public-driven initiative, our license choice is an invitation for private sector actors to use, sell and contribute to the project.
|
||||||
|
|
||||||
## Contributing 🙌
|
## Contributing 🙌
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ the following command inside your docker container:
|
|||||||
|
|
||||||
## [3.3.0] - 2025-05-22
|
## [3.3.0] - 2025-05-22
|
||||||
|
|
||||||
⚠️ For some advanced features (ex: Export as PDF) Docs relies on XL packages from BlockNote. These are licenced under AGPL-3.0 and are not MIT compatible. You can perfectly use Docs without these packages by setting the environment variable `PUBLISH_AS_MIT` to true. That way you'll build an image of the application without the features that are not MIT compatible. Read the [environment variables documentation](/docs/docs/env.md) for more information.
|
⚠️ For some advanced features (ex: Export as PDF) Docs relies on XL packages from BlockNote. These are licenced under AGPL-3.0 and are not MIT compatible. You can perfectly use Docs without these packages by setting the environment variable `PUBLISH_AS_MIT` to true. That way you'll build an image of the application without the features that are not MIT compatible. Read the [environment variables documentation](/docs/env.md) for more information.
|
||||||
|
|
||||||
The footer is now configurable from a customization file. To override the default one, you can
|
The footer is now configurable from a customization file. To override the default one, you can
|
||||||
use the `THEME_CUSTOMIZATION_FILE_PATH` environment variable to point to your customization file.
|
use the `THEME_CUSTOMIZATION_FILE_PATH` environment variable to point to your customization file.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ REPO_DIR="$(cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd)"
|
|||||||
UNSET_USER=0
|
UNSET_USER=0
|
||||||
|
|
||||||
TERRAFORM_DIRECTORY="./env.d/terraform"
|
TERRAFORM_DIRECTORY="./env.d/terraform"
|
||||||
COMPOSE_FILE="${REPO_DIR}/docker-compose.yml"
|
COMPOSE_FILE="${REPO_DIR}/compose.yml"
|
||||||
|
|
||||||
|
|
||||||
# _set_user: set (or unset) default user id used to run docker commands
|
# _set_user: set (or unset) default user id used to run docker commands
|
||||||
@@ -38,6 +38,10 @@ function _set_user() {
|
|||||||
# options: docker compose command options
|
# options: docker compose command options
|
||||||
# ARGS : docker compose command arguments
|
# ARGS : docker compose command arguments
|
||||||
function _docker_compose() {
|
function _docker_compose() {
|
||||||
|
# Set DOCKER_USER for Windows compatibility with MinIO
|
||||||
|
if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" || -n "${WSL_DISTRO_NAME:-}" ]]; then
|
||||||
|
export DOCKER_USER="0:0"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "🐳(compose) file: '${COMPOSE_FILE}'"
|
echo "🐳(compose) file: '${COMPOSE_FILE}'"
|
||||||
docker compose \
|
docker compose \
|
||||||
|
|||||||
29
compose-e2e.yml
Normal file
29
compose-e2e.yml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
services:
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
user: "${DOCKER_USER:-1000}"
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: ./src/frontend/Dockerfile
|
||||||
|
target: frontend-production
|
||||||
|
args:
|
||||||
|
API_ORIGIN: "http://localhost:8071"
|
||||||
|
PUBLISH_AS_MIT: "false"
|
||||||
|
SW_DEACTIVATED: "true"
|
||||||
|
image: impress:frontend-production
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
|
||||||
|
y-provider:
|
||||||
|
user: ${DOCKER_USER:-1000}
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: ./src/frontend/servers/y-provider/Dockerfile
|
||||||
|
target: y-provider
|
||||||
|
image: impress:y-provider-production
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- env.d/development/common
|
||||||
|
- env.d/development/common.local
|
||||||
|
ports:
|
||||||
|
- "4444:4444"
|
||||||
@@ -10,6 +10,7 @@ services:
|
|||||||
retries: 300
|
retries: 300
|
||||||
env_file:
|
env_file:
|
||||||
- env.d/development/postgresql
|
- env.d/development/postgresql
|
||||||
|
- env.d/development/postgresql.local
|
||||||
ports:
|
ports:
|
||||||
- "15432:5432"
|
- "15432:5432"
|
||||||
|
|
||||||
@@ -66,7 +67,9 @@ services:
|
|||||||
- DJANGO_CONFIGURATION=Development
|
- DJANGO_CONFIGURATION=Development
|
||||||
env_file:
|
env_file:
|
||||||
- env.d/development/common
|
- env.d/development/common
|
||||||
|
- env.d/development/common.local
|
||||||
- env.d/development/postgresql
|
- env.d/development/postgresql
|
||||||
|
- env.d/development/postgresql.local
|
||||||
ports:
|
ports:
|
||||||
- "8071:8000"
|
- "8071:8000"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -91,47 +94,15 @@ services:
|
|||||||
- DJANGO_CONFIGURATION=Development
|
- DJANGO_CONFIGURATION=Development
|
||||||
env_file:
|
env_file:
|
||||||
- env.d/development/common
|
- env.d/development/common
|
||||||
|
- env.d/development/common.local
|
||||||
- env.d/development/postgresql
|
- env.d/development/postgresql
|
||||||
|
- env.d/development/postgresql.local
|
||||||
volumes:
|
volumes:
|
||||||
- ./src/backend:/app
|
- ./src/backend:/app
|
||||||
- ./data/static:/data/static
|
- ./data/static:/data/static
|
||||||
depends_on:
|
depends_on:
|
||||||
- app-dev
|
- app-dev
|
||||||
|
|
||||||
app:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
target: backend-production
|
|
||||||
args:
|
|
||||||
DOCKER_USER: ${DOCKER_USER:-1000}
|
|
||||||
user: ${DOCKER_USER:-1000}
|
|
||||||
image: impress:backend-production
|
|
||||||
environment:
|
|
||||||
- DJANGO_CONFIGURATION=Demo
|
|
||||||
env_file:
|
|
||||||
- env.d/development/common
|
|
||||||
- env.d/development/postgresql
|
|
||||||
depends_on:
|
|
||||||
postgresql:
|
|
||||||
condition: service_healthy
|
|
||||||
restart: true
|
|
||||||
redis:
|
|
||||||
condition: service_started
|
|
||||||
minio:
|
|
||||||
condition: service_started
|
|
||||||
|
|
||||||
celery:
|
|
||||||
user: ${DOCKER_USER:-1000}
|
|
||||||
image: impress:backend-production
|
|
||||||
command: ["celery", "-A", "impress.celery_app", "worker", "-l", "INFO"]
|
|
||||||
environment:
|
|
||||||
- DJANGO_CONFIGURATION=Demo
|
|
||||||
env_file:
|
|
||||||
- env.d/development/common
|
|
||||||
- env.d/development/postgresql
|
|
||||||
depends_on:
|
|
||||||
- app
|
|
||||||
|
|
||||||
nginx:
|
nginx:
|
||||||
image: nginx:1.25
|
image: nginx:1.25
|
||||||
ports:
|
ports:
|
||||||
@@ -141,23 +112,25 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
app-dev:
|
app-dev:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
y-provider:
|
|
||||||
condition: service_started
|
|
||||||
keycloak:
|
keycloak:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
restart: true
|
restart: true
|
||||||
|
|
||||||
frontend:
|
frontend-development:
|
||||||
user: "${DOCKER_USER:-1000}"
|
user: "${DOCKER_USER:-1000}"
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: ./src/frontend/Dockerfile
|
dockerfile: ./src/frontend/Dockerfile
|
||||||
target: frontend-production
|
target: impress-dev
|
||||||
args:
|
args:
|
||||||
API_ORIGIN: "http://localhost:8071"
|
API_ORIGIN: "http://localhost:8071"
|
||||||
PUBLISH_AS_MIT: "false"
|
PUBLISH_AS_MIT: "false"
|
||||||
SW_DEACTIVATED: "true"
|
SW_DEACTIVATED: "true"
|
||||||
image: impress:frontend-development
|
image: impress:frontend-development
|
||||||
|
volumes:
|
||||||
|
- ./src/frontend:/home/frontend
|
||||||
|
- /home/frontend/node_modules
|
||||||
|
- /home/frontend/apps/impress/node_modules
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
|
|
||||||
@@ -167,28 +140,35 @@ services:
|
|||||||
- ".:/app"
|
- ".:/app"
|
||||||
env_file:
|
env_file:
|
||||||
- env.d/development/crowdin
|
- env.d/development/crowdin
|
||||||
|
- env.d/development/crowdin.local
|
||||||
user: "${DOCKER_USER:-1000}"
|
user: "${DOCKER_USER:-1000}"
|
||||||
working_dir: /app
|
working_dir: /app
|
||||||
|
|
||||||
node:
|
node:
|
||||||
image: node:18
|
image: node:22
|
||||||
user: "${DOCKER_USER:-1000}"
|
user: "${DOCKER_USER:-1000}"
|
||||||
environment:
|
environment:
|
||||||
HOME: /tmp
|
HOME: /tmp
|
||||||
volumes:
|
volumes:
|
||||||
- ".:/app"
|
- ".:/app"
|
||||||
|
|
||||||
y-provider:
|
y-provider-development:
|
||||||
user: ${DOCKER_USER:-1000}
|
user: ${DOCKER_USER:-1000}
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: ./src/frontend/servers/y-provider/Dockerfile
|
dockerfile: ./src/frontend/servers/y-provider/Dockerfile
|
||||||
target: y-provider
|
target: y-provider-development
|
||||||
|
image: impress:y-provider-development
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
- env.d/development/common
|
- env.d/development/common
|
||||||
|
- env.d/development/common.local
|
||||||
ports:
|
ports:
|
||||||
- "4444:4444"
|
- "4444:4444"
|
||||||
|
volumes:
|
||||||
|
- ./src/frontend/:/home/frontend
|
||||||
|
- /home/frontend/node_modules
|
||||||
|
- /home/frontend/servers/y-provider/node_modules
|
||||||
|
|
||||||
kc_postgresql:
|
kc_postgresql:
|
||||||
image: postgres:14.3
|
image: postgres:14.3
|
||||||
@@ -201,6 +181,7 @@ services:
|
|||||||
- "5433:5432"
|
- "5433:5432"
|
||||||
env_file:
|
env_file:
|
||||||
- env.d/development/kc_postgresql
|
- env.d/development/kc_postgresql
|
||||||
|
- env.d/development/kc_postgresql.local
|
||||||
|
|
||||||
keycloak:
|
keycloak:
|
||||||
image: quay.io/keycloak/keycloak:20.0.1
|
image: quay.io/keycloak/keycloak:20.0.1
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"username": "user-e2e-chromium",
|
"username": "user-e2e-chromium",
|
||||||
"email": "user@chromium.e2e",
|
"email": "user@chromium.test",
|
||||||
"firstName": "E2E",
|
"firstName": "E2E",
|
||||||
"lastName": "Chromium",
|
"lastName": "Chromium",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"username": "user-e2e-webkit",
|
"username": "user-e2e-webkit",
|
||||||
"email": "user@webkit.e2e",
|
"email": "user@webkit.test",
|
||||||
"firstName": "E2E",
|
"firstName": "E2E",
|
||||||
"lastName": "Webkit",
|
"lastName": "Webkit",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
@@ -88,7 +88,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"username": "user-e2e-firefox",
|
"username": "user-e2e-firefox",
|
||||||
"email": "user@firefox.e2e",
|
"email": "user@firefox.test",
|
||||||
"firstName": "E2E",
|
"firstName": "E2E",
|
||||||
"lastName": "Firefox",
|
"lastName": "Firefox",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
|||||||
112
docker/files/production/etc/nginx/conf.d/default.conf.template
Normal file
112
docker/files/production/etc/nginx/conf.d/default.conf.template
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
upstream docs_backend {
|
||||||
|
server ${BACKEND_HOST}:8000 fail_timeout=0;
|
||||||
|
}
|
||||||
|
|
||||||
|
upstream docs_frontend {
|
||||||
|
server ${FRONTEND_HOST}:3000 fail_timeout=0;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 8083;
|
||||||
|
server_name localhost;
|
||||||
|
charset utf-8;
|
||||||
|
|
||||||
|
# Disables server version feedback on pages and in headers
|
||||||
|
server_tokens off;
|
||||||
|
|
||||||
|
proxy_ssl_server_name on;
|
||||||
|
|
||||||
|
location @proxy_to_docs_backend {
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
|
||||||
|
proxy_redirect off;
|
||||||
|
proxy_pass http://docs_backend;
|
||||||
|
}
|
||||||
|
|
||||||
|
location @proxy_to_docs_frontend {
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
|
||||||
|
proxy_redirect off;
|
||||||
|
proxy_pass http://docs_frontend;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri @proxy_to_docs_frontend;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api {
|
||||||
|
try_files $uri @proxy_to_docs_backend;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /admin {
|
||||||
|
try_files $uri @proxy_to_docs_backend;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /static {
|
||||||
|
try_files $uri @proxy_to_docs_backend;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy auth for collaboration server
|
||||||
|
location /collaboration/ws/ {
|
||||||
|
# Ensure WebSocket upgrade
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "Upgrade";
|
||||||
|
|
||||||
|
# Collaboration server
|
||||||
|
proxy_pass http://${YPROVIDER_HOST}:4444;
|
||||||
|
|
||||||
|
# Set appropriate timeout for WebSocket
|
||||||
|
proxy_read_timeout 86400;
|
||||||
|
proxy_send_timeout 86400;
|
||||||
|
|
||||||
|
# Preserve original host and additional headers
|
||||||
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
|
proxy_set_header Origin $http_origin;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /collaboration/api/ {
|
||||||
|
# Collaboration server
|
||||||
|
proxy_pass http://${YPROVIDER_HOST}:4444;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy auth for media
|
||||||
|
location /media/ {
|
||||||
|
# Auth request configuration
|
||||||
|
auth_request /media-auth;
|
||||||
|
auth_request_set $authHeader $upstream_http_authorization;
|
||||||
|
auth_request_set $authDate $upstream_http_x_amz_date;
|
||||||
|
auth_request_set $authContentSha256 $upstream_http_x_amz_content_sha256;
|
||||||
|
|
||||||
|
# Pass specific headers from the auth response
|
||||||
|
proxy_set_header Authorization $authHeader;
|
||||||
|
proxy_set_header X-Amz-Date $authDate;
|
||||||
|
proxy_set_header X-Amz-Content-SHA256 $authContentSha256;
|
||||||
|
|
||||||
|
# Get resource from Minio
|
||||||
|
proxy_pass https://${S3_HOST}/${BUCKET_NAME}/;
|
||||||
|
proxy_set_header Host ${S3_HOST};
|
||||||
|
|
||||||
|
proxy_ssl_name ${S3_HOST};
|
||||||
|
|
||||||
|
add_header Content-Security-Policy "default-src 'none'" always;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /media-auth {
|
||||||
|
proxy_pass http://docs_backend/api/v1.0/documents/media-auth/;
|
||||||
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Original-URL $request_uri;
|
||||||
|
|
||||||
|
# Prevent the body from being passed
|
||||||
|
proxy_pass_request_body off;
|
||||||
|
proxy_set_header Content-Length "";
|
||||||
|
proxy_set_header X-Original-Method $request_method;
|
||||||
|
}
|
||||||
|
}
|
||||||
198
docs/env.md
198
docs/env.md
@@ -6,102 +6,103 @@ Here we describe all environment variables that can be set for the docs applicat
|
|||||||
|
|
||||||
These are the environment variables you can set for the `impress-backend` container.
|
These are the environment variables you can set for the `impress-backend` container.
|
||||||
|
|
||||||
| Option | Description | default |
|
| Option | Description | default |
|
||||||
| ----------------------------------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
|
|-------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------|
|
||||||
| DJANGO_ALLOWED_HOSTS | allowed hosts | [] |
|
| AI_ALLOW_REACH_FROM | Users that can use AI must be this level. options are "public", "authenticated", "restricted" | authenticated |
|
||||||
| DJANGO_SECRET_KEY | secret key | |
|
| AI_API_KEY | AI key to be used for AI Base url | |
|
||||||
| DJANGO_SERVER_TO_SERVER_API_TOKENS | | [] |
|
| AI_BASE_URL | OpenAI compatible AI base url | |
|
||||||
| DB_ENGINE | engine to use for database connections | django.db.backends.postgresql_psycopg2 |
|
| AI_FEATURE_ENABLED | Enable AI options | false |
|
||||||
| DB_NAME | name of the database | impress |
|
| AI_MODEL | AI Model to use | |
|
||||||
| DB_USER | user to authenticate with | dinum |
|
| ALLOW_LOGOUT_GET_METHOD | Allow get logout method | true |
|
||||||
| DB_PASSWORD | password to authenticate with | pass |
|
| API_USERS_LIST_LIMIT | Limit on API users | 5 |
|
||||||
| DB_HOST | host of the database | localhost |
|
| API_USERS_LIST_THROTTLE_RATE_BURST | Throttle rate for api on burst | 30/minute |
|
||||||
| DB_PORT | port of the database | 5432 |
|
| API_USERS_LIST_THROTTLE_RATE_SUSTAINED | Throttle rate for api | 180/hour |
|
||||||
| MEDIA_BASE_URL | | |
|
| AWS_S3_ACCESS_KEY_ID | Access id for s3 endpoint | |
|
||||||
| STORAGES_STATICFILES_BACKEND | | whitenoise.storage.CompressedManifestStaticFilesStorage |
|
| AWS_S3_ENDPOINT_URL | S3 endpoint | |
|
||||||
| AWS_S3_ENDPOINT_URL | S3 endpoint | |
|
| AWS_S3_REGION_NAME | Region name for s3 endpoint | |
|
||||||
| AWS_S3_ACCESS_KEY_ID | access id for s3 endpoint | |
|
| AWS_S3_SECRET_ACCESS_KEY | Access key for s3 endpoint | |
|
||||||
| AWS_S3_SECRET_ACCESS_KEY | access key for s3 endpoint | |
|
| AWS_STORAGE_BUCKET_NAME | Bucket name for s3 endpoint | impress-media-storage |
|
||||||
| AWS_S3_REGION_NAME | region name for s3 endpoint | |
|
| CACHES_DEFAULT_TIMEOUT | Cache default timeout | 30 |
|
||||||
| AWS_STORAGE_BUCKET_NAME | bucket name for s3 endpoint | impress-media-storage |
|
| CACHES_KEY_PREFIX | The prefix used to every cache keys. | docs |
|
||||||
| DOCUMENT_IMAGE_MAX_SIZE | maximum size of document in bytes | 10485760 |
|
| COLLABORATION_API_URL | Collaboration api host | |
|
||||||
| LANGUAGE_CODE | default language | en-us |
|
| COLLABORATION_SERVER_SECRET | Collaboration api secret | |
|
||||||
| API_USERS_LIST_THROTTLE_RATE_SUSTAINED | throttle rate for api | 180/hour |
|
| COLLABORATION_WS_NOT_CONNECTED_READY_ONLY | Users not connected to the collaboration server cannot edit | false |
|
||||||
| API_USERS_LIST_THROTTLE_RATE_BURST | throttle rate for api on burst | 30/minute |
|
| COLLABORATION_WS_URL | Collaboration websocket url | |
|
||||||
| SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | | false |
|
| CONVERSION_API_CONTENT_FIELD | Conversion api content field | content |
|
||||||
| TRASHBIN_CUTOFF_DAYS | trashbin cutoff | 30 |
|
| CONVERSION_API_ENDPOINT | Conversion API endpoint | convert |
|
||||||
| DJANGO_EMAIL_BACKEND | email backend library | django.core.mail.backends.smtp.EmailBackend |
|
| CONVERSION_API_SECURE | Require secure conversion api | false |
|
||||||
| DJANGO_EMAIL_BRAND_NAME | brand name for email | |
|
| CONVERSION_API_TIMEOUT | Conversion api timeout | 30 |
|
||||||
| DJANGO_EMAIL_HOST | host name of email | |
|
| CRISP_WEBSITE_ID | Crisp website id for support | |
|
||||||
| DJANGO_EMAIL_HOST_USER | user to authenticate with on the email host | |
|
| DB_ENGINE | Engine to use for database connections | django.db.backends.postgresql_psycopg2 |
|
||||||
| DJANGO_EMAIL_HOST_PASSWORD | password to authenticate with on the email host | |
|
| DB_HOST | Host of the database | localhost |
|
||||||
| DJANGO_EMAIL_LOGO_IMG | logo for the email | |
|
| DB_NAME | Name of the database | impress |
|
||||||
| DJANGO_EMAIL_PORT | port used to connect to email host | |
|
| DB_PASSWORD | Password to authenticate with | pass |
|
||||||
| DJANGO_EMAIL_USE_TLS | use tls for email host connection | false |
|
| DB_PORT | Port of the database | 5432 |
|
||||||
| DJANGO_EMAIL_USE_SSL | use sstl for email host connection | false |
|
| DB_USER | User to authenticate with | dinum |
|
||||||
| DJANGO_EMAIL_FROM | email address used as sender | from@example.com |
|
| DJANGO_ALLOWED_HOSTS | Allowed hosts | [] |
|
||||||
| DJANGO_CORS_ALLOW_ALL_ORIGINS | allow all CORS origins | true |
|
| DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | Celery broker transport options | {} |
|
||||||
| DJANGO_CORS_ALLOWED_ORIGINS | list of origins allowed for CORS | [] |
|
| DJANGO_CELERY_BROKER_URL | Celery broker url | redis://redis:6379/0 |
|
||||||
| DJANGO_CORS_ALLOWED_ORIGIN_REGEXES | list of origins allowed for CORS using regulair expressions | [] |
|
| DJANGO_CORS_ALLOW_ALL_ORIGINS | Allow all CORS origins | false |
|
||||||
| SENTRY_DSN | sentry host | |
|
| DJANGO_CORS_ALLOWED_ORIGIN_REGEXES | List of origins allowed for CORS using regulair expressions | [] |
|
||||||
| COLLABORATION_API_URL | collaboration api host | |
|
| DJANGO_CORS_ALLOWED_ORIGINS | List of origins allowed for CORS | [] |
|
||||||
| COLLABORATION_SERVER_SECRET | collaboration api secret | |
|
| DJANGO_CSRF_TRUSTED_ORIGINS | CSRF trusted origins | [] |
|
||||||
| COLLABORATION_WS_URL | collaboration websocket url | |
|
| DJANGO_EMAIL_BACKEND | Email backend library | django.core.mail.backends.smtp.EmailBackend |
|
||||||
| COLLABORATION_WS_NOT_CONNECTED_READY_ONLY | Users not connected to the collaboration server cannot edit | false |
|
| DJANGO_EMAIL_BRAND_NAME | Brand name for email | |
|
||||||
| FRONTEND_CSS_URL | To add a external css file to the app | |
|
| DJANGO_EMAIL_FROM | Email address used as sender | from@example.com |
|
||||||
| FRONTEND_HOMEPAGE_FEATURE_ENABLED | frontend feature flag to display the homepage | false |
|
| DJANGO_EMAIL_HOST | Hostname of email | |
|
||||||
| FRONTEND_THEME | frontend theme to use | |
|
| DJANGO_EMAIL_HOST_PASSWORD | Password to authenticate with on the email host | |
|
||||||
| POSTHOG_KEY | posthog key for analytics | |
|
| DJANGO_EMAIL_HOST_USER | User to authenticate with on the email host | |
|
||||||
| CRISP_WEBSITE_ID | crisp website id for support | |
|
| DJANGO_EMAIL_LOGO_IMG | Logo for the email | |
|
||||||
| DJANGO_CELERY_BROKER_URL | celery broker url | redis://redis:6379/0 |
|
| DJANGO_EMAIL_PORT | Port used to connect to email host | |
|
||||||
| DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | celery broker transport options | {} |
|
| DJANGO_EMAIL_USE_SSL | Use ssl for email host connection | false |
|
||||||
| SESSION_COOKIE_AGE | duration of the cookie session | 60*60*12 |
|
| DJANGO_EMAIL_USE_TLS | Use tls for email host connection | false |
|
||||||
| OIDC_CREATE_USER | create used on OIDC | false |
|
| DJANGO_SECRET_KEY | Secret key | |
|
||||||
| OIDC_RP_SIGN_ALGO | verification algorithm used OIDC tokens | RS256 |
|
| DJANGO_SERVER_TO_SERVER_API_TOKENS | | [] |
|
||||||
| OIDC_RP_CLIENT_ID | client id used for OIDC | impress |
|
| DOCUMENT_IMAGE_MAX_SIZE | Maximum size of document in bytes | 10485760 |
|
||||||
| OIDC_RP_CLIENT_SECRET | client secret used for OIDC | |
|
| FRONTEND_CSS_URL | To add a external css file to the app | |
|
||||||
| OIDC_OP_JWKS_ENDPOINT | JWKS endpoint for OIDC | |
|
| FRONTEND_HOMEPAGE_FEATURE_ENABLED | Frontend feature flag to display the homepage | false |
|
||||||
| OIDC_OP_AUTHORIZATION_ENDPOINT | Authorization endpoint for OIDC | |
|
| FRONTEND_THEME | Frontend theme to use | |
|
||||||
| OIDC_OP_TOKEN_ENDPOINT | Token endpoint for OIDC | |
|
| LANGUAGE_CODE | Default language | en-us |
|
||||||
| OIDC_OP_USER_ENDPOINT | User endpoint for OIDC | |
|
| LOGGING_LEVEL_LOGGERS_APP | Application logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO |
|
||||||
| OIDC_OP_LOGOUT_ENDPOINT | Logout endpoint for OIDC | |
|
| LOGGING_LEVEL_LOGGERS_ROOT | Default logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO |
|
||||||
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | OIDC extra auth parameters | {} |
|
| LOGIN_REDIRECT_URL | Login redirect url | |
|
||||||
| OIDC_RP_SCOPES | scopes requested for OIDC | openid email |
|
| LOGIN_REDIRECT_URL_FAILURE | Login redirect url on failure | |
|
||||||
| LOGIN_REDIRECT_URL | login redirect url | |
|
| LOGOUT_REDIRECT_URL | Logout redirect url | |
|
||||||
| LOGIN_REDIRECT_URL_FAILURE | login redirect url on failure | |
|
| MALWARE_DETECTION_BACKEND | The malware detection backend use from the django-lasuite package | lasuite.malware_detection.backends.dummy.DummyBackend |
|
||||||
| LOGOUT_REDIRECT_URL | logout redirect url | |
|
| MALWARE_DETECTION_PARAMETERS | A dict containing all the parameters to initiate the malware detection backend | {"callback_path": "core.malware_detection.malware_detection_callback",} |
|
||||||
| OIDC_USE_NONCE | use nonce for OIDC | true |
|
| MEDIA_BASE_URL | | |
|
||||||
| OIDC_REDIRECT_REQUIRE_HTTPS | Require https for OIDC redirect url | false |
|
| NO_WEBSOCKET_CACHE_TIMEOUT | Cache used to store current editor session key when only users without websocket are editing a document | 120 |
|
||||||
| OIDC_REDIRECT_ALLOWED_HOSTS | Allowed hosts for OIDC redirect url | [] |
|
| OIDC_ALLOW_DUPLICATE_EMAILS | Allow duplicate emails | false |
|
||||||
| OIDC_STORE_ID_TOKEN | Store OIDC token | true |
|
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | OIDC extra auth parameters | {} |
|
||||||
| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | faillback to email for identification | true |
|
| OIDC_CREATE_USER | Create used on OIDC | false |
|
||||||
| OIDC_ALLOW_DUPLICATE_EMAILS | Allow duplicate emails | false |
|
| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | Fallback to email for identification | true |
|
||||||
| USER_OIDC_ESSENTIAL_CLAIMS | essential claims in OIDC token | [] |
|
| OIDC_OP_AUTHORIZATION_ENDPOINT | Authorization endpoint for OIDC | |
|
||||||
| OIDC_USERINFO_FULLNAME_FIELDS | OIDC token claims to create full name | ["first_name", "last_name"] |
|
| OIDC_OP_JWKS_ENDPOINT | JWKS endpoint for OIDC | |
|
||||||
| OIDC_USERINFO_SHORTNAME_FIELD | OIDC token claims to create shortname | first_name |
|
| OIDC_OP_LOGOUT_ENDPOINT | Logout endpoint for OIDC | |
|
||||||
| ALLOW_LOGOUT_GET_METHOD | Allow get logout method | true |
|
| OIDC_OP_TOKEN_ENDPOINT | Token endpoint for OIDC | |
|
||||||
| AI_API_KEY | AI key to be used for AI Base url | |
|
| OIDC_OP_USER_ENDPOINT | User endpoint for OIDC | |
|
||||||
| AI_BASE_URL | OpenAI compatible AI base url | |
|
| OIDC_REDIRECT_ALLOWED_HOSTS | Allowed hosts for OIDC redirect url | [] |
|
||||||
| AI_MODEL | AI Model to use | |
|
| OIDC_REDIRECT_REQUIRE_HTTPS | Require https for OIDC redirect url | false |
|
||||||
| AI_ALLOW_REACH_FROM | Users that can use AI must be this level. options are "public", "authenticated", "restricted" | authenticated |
|
| OIDC_RP_CLIENT_ID | Client id used for OIDC | impress |
|
||||||
| AI_FEATURE_ENABLED | Enable AI options | false |
|
| OIDC_RP_CLIENT_SECRET | Client secret used for OIDC | |
|
||||||
| Y_PROVIDER_API_KEY | Y provider API key | |
|
| OIDC_RP_SCOPES | Scopes requested for OIDC | openid email |
|
||||||
| Y_PROVIDER_API_BASE_URL | Y Provider url | |
|
| OIDC_RP_SIGN_ALGO | verification algorithm used OIDC tokens | RS256 |
|
||||||
| CONVERSION_API_ENDPOINT | Conversion API endpoint | convert-markdown |
|
| OIDC_STORE_ID_TOKEN | Store OIDC token | true |
|
||||||
| CONVERSION_API_CONTENT_FIELD | Conversion api content field | content |
|
| OIDC_USE_NONCE | Use nonce for OIDC | true |
|
||||||
| CONVERSION_API_TIMEOUT | Conversion api timeout | 30 |
|
| OIDC_USERINFO_FULLNAME_FIELDS | OIDC token claims to create full name | ["first_name", "last_name"] |
|
||||||
| CONVERSION_API_SECURE | Require secure conversion api | false |
|
| OIDC_USERINFO_SHORTNAME_FIELD | OIDC token claims to create shortname | first_name |
|
||||||
| LOGGING_LEVEL_LOGGERS_ROOT | default logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO |
|
| POSTHOG_KEY | Posthog key for analytics | |
|
||||||
| LOGGING_LEVEL_LOGGERS_APP | application logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO |
|
| REDIS_URL | Cache url | redis://redis:6379/1 |
|
||||||
| API_USERS_LIST_LIMIT | Limit on API users | 5 |
|
| SENTRY_DSN | Sentry host | |
|
||||||
| DJANGO_CSRF_TRUSTED_ORIGINS | CSRF trusted origins | [] |
|
| SESSION_COOKIE_AGE | duration of the cookie session | 60*60*12 |
|
||||||
| REDIS_URL | cache url | redis://redis:6379/1 |
|
| SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | | false |
|
||||||
| CACHES_DEFAULT_TIMEOUT | cache default timeout | 30 |
|
| STORAGES_STATICFILES_BACKEND | | whitenoise.storage.CompressedManifestStaticFilesStorage |
|
||||||
| CACHES_KEY_PREFIX | The prefix used to every cache keys. | docs |
|
| THEME_CUSTOMIZATION_CACHE_TIMEOUT | Cache duration for the customization settings | 86400 |
|
||||||
| MALWARE_DETECTION_BACKEND | The malware detection backend use from the django-lasuite package | lasuite.malware_detection.backends.dummy.DummyBackend |
|
| THEME_CUSTOMIZATION_FILE_PATH | Full path to the file customizing the theme. An example is provided in src/backend/impress/configuration/theme/default.json | BASE_DIR/impress/configuration/theme/default.json |
|
||||||
| MALWARE_DETECTION_PARAMETERS | A dict containing all the parameters to initiate the malware detection backend | {"callback_path": "core.malware_detection.malware_detection_callback",} |
|
| TRASHBIN_CUTOFF_DAYS | Trashbin cutoff | 30 |
|
||||||
| THEME_CUSTOMIZATION_FILE_PATH | full path to the file customizing the theme. An example is provided in src/backend/impress/configuration/theme/default.json | BASE_DIR/impress/configuration/theme/default.json |
|
| USER_OIDC_ESSENTIAL_CLAIMS | Essential claims in OIDC token | [] |
|
||||||
| THEME_CUSTOMIZATION_CACHE_TIMEOUT | Cache duration for the customization settings | 86400 |
|
| Y_PROVIDER_API_BASE_URL | Y Provider url | |
|
||||||
|
| Y_PROVIDER_API_KEY | Y provider API key | |
|
||||||
|
|
||||||
|
|
||||||
## impress-frontend image
|
## impress-frontend image
|
||||||
@@ -135,9 +136,10 @@ NODE_ENV=production NEXT_PUBLIC_PUBLISH_AS_MIT=false yarn build
|
|||||||
|
|
||||||
Packages with licences incompatible with the MIT licence:
|
Packages with licences incompatible with the MIT licence:
|
||||||
* `xl-docx-exporter`: [AGPL-3.0](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-docx-exporter/LICENSE),
|
* `xl-docx-exporter`: [AGPL-3.0](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-docx-exporter/LICENSE),
|
||||||
* `xl-pdf-exporter`: [AGPL-3.0](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-pdf-exporter/LICENSE)
|
* `xl-pdf-exporter`: [AGPL-3.0](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-pdf-exporter/LICENSE),
|
||||||
|
* `xl-multi-column`: [AGPL-3.0](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-multi-column/LICENSE).
|
||||||
|
|
||||||
In `.env.development`, `PUBLISH_AS_MIT` is set to `false`, allowing developers to test Docs with all its features.
|
In `.env.development`, `PUBLISH_AS_MIT` is set to `false`, allowing developers to test Docs with all its features.
|
||||||
|
|
||||||
⚠️ If you run Docs in production with `PUBLISH_AS_MIT` set to `false` make sure you fulfill your [BlockNote licensing](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-pdf-exporter/LICENSE) or [subscription](https://www.blocknotejs.org/about#partner-with-us) obligations.
|
⚠️ If you run Docs in production with `PUBLISH_AS_MIT` set to `false` make sure you fulfill your BlockNote licensing or [subscription](https://www.blocknotejs.org/about#partner-with-us) obligations.
|
||||||
|
|
||||||
|
|||||||
78
docs/examples/compose/compose.yaml
Normal file
78
docs/examples/compose/compose.yaml
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
services:
|
||||||
|
postgresql:
|
||||||
|
image: postgres:16
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
|
||||||
|
interval: 1s
|
||||||
|
timeout: 2s
|
||||||
|
retries: 300
|
||||||
|
env_file:
|
||||||
|
- env.d/postgresql
|
||||||
|
- env.d/common
|
||||||
|
environment:
|
||||||
|
- PGDATA=/var/lib/postgresql/data/pgdata
|
||||||
|
volumes:
|
||||||
|
- ./data/databases/backend:/var/lib/postgresql/data/pgdata
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:8
|
||||||
|
|
||||||
|
backend:
|
||||||
|
image: lasuite/impress-backend:latest
|
||||||
|
user: ${DOCKER_USER:-1000}
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
- DJANGO_CONFIGURATION=Production
|
||||||
|
env_file:
|
||||||
|
- env.d/common
|
||||||
|
- env.d/backend
|
||||||
|
- env.d/yprovider
|
||||||
|
- env.d/postgresql
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "manage.py", "check"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 30s
|
||||||
|
retries: 20
|
||||||
|
start_period: 10s
|
||||||
|
depends_on:
|
||||||
|
postgresql:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: true
|
||||||
|
redis:
|
||||||
|
condition: service_started
|
||||||
|
|
||||||
|
y-provider:
|
||||||
|
image: lasuite/impress-y-provider:latest
|
||||||
|
user: ${DOCKER_USER:-1000}
|
||||||
|
env_file:
|
||||||
|
- env.d/common
|
||||||
|
- env.d/yprovider
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
image: lasuite/impress-frontend:latest
|
||||||
|
user: "101"
|
||||||
|
entrypoint:
|
||||||
|
- /docker-entrypoint.sh
|
||||||
|
command: ["nginx", "-g", "daemon off;"]
|
||||||
|
env_file:
|
||||||
|
- env.d/common
|
||||||
|
# Uncomment and set your values if using our nginx proxy example
|
||||||
|
#environment:
|
||||||
|
# - VIRTUAL_HOST=${DOCS_HOST} # used by nginx proxy
|
||||||
|
# - VIRTUAL_PORT=8083 # used by nginx proxy
|
||||||
|
# - LETSENCRYPT_HOST=${DOCS_HOST} # used by lets encrypt to generate TLS certificate
|
||||||
|
volumes:
|
||||||
|
- ./default.conf.template:/etc/nginx/templates/docs.conf.template
|
||||||
|
depends_on:
|
||||||
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
# Uncomment if using our nginx proxy example
|
||||||
|
# networks:
|
||||||
|
# - proxy-tier
|
||||||
|
# - default
|
||||||
|
|
||||||
|
# Uncomment if using our nginx proxy example
|
||||||
|
#networks:
|
||||||
|
# proxy-tier:
|
||||||
|
# external: true
|
||||||
88
docs/examples/compose/keycloak/README.md
Normal file
88
docs/examples/compose/keycloak/README.md
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# Deploy and Configure Keycloak for Docs
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
> \[!CAUTION\]
|
||||||
|
> We provide those instructions as an example, for production environments, you should follow the [official documentation](https://www.keycloak.org/documentation).
|
||||||
|
|
||||||
|
### Step 1: Prepare your working environment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir keycloak
|
||||||
|
curl -o keycloak/compose.yaml https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/docs/examples/compose/keycloak/compose.yaml
|
||||||
|
curl -o keycloak/env.d/kc_postgresql https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/env.d/production.dist/kc_postgresql
|
||||||
|
curl -o keycloak/env.d/keycloak https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/env.d/production.dist/keycloak
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2:. Update `env.d/` files
|
||||||
|
|
||||||
|
The following variables need to be updated with your own values, others can be left as is:
|
||||||
|
|
||||||
|
```env
|
||||||
|
POSTGRES_PASSWORD=<generate postgres password>
|
||||||
|
KC_HOSTNAME=https://id.yourdomain.tld # Change with your own URL
|
||||||
|
KC_BOOTSTRAP_ADMIN_PASSWORD=<generate your password>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Expose keycloak instance on https
|
||||||
|
|
||||||
|
> \[!NOTE\]
|
||||||
|
> You can skip this section if you already have your own setup.
|
||||||
|
|
||||||
|
To access your Keycloak instance on the public network, it needs to be exposed on a domain with SSL termination. You can use our [example with nginx proxy and Let's Encrypt companion](../nginx-proxy/README.md) for automated creation/renewal of certificates using [acme.sh](http://acme.sh).
|
||||||
|
|
||||||
|
If following our example, uncomment the environment and network sections in compose file and update it with your values.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
version: '3'
|
||||||
|
services:
|
||||||
|
keycloak:
|
||||||
|
...
|
||||||
|
# Uncomment and set your values if using our nginx proxy example
|
||||||
|
# environment:
|
||||||
|
# - VIRTUAL_HOST=id.yourdomain.tld # used by nginx proxy
|
||||||
|
# - VIRTUAL_PORT=8080 # used by nginx proxy
|
||||||
|
# - LETSENCRYPT_HOST=id.yourdomain.tld # used by lets encrypt to generate TLS certificate
|
||||||
|
...
|
||||||
|
# Uncomment if using our nginx proxy example
|
||||||
|
# networks:
|
||||||
|
# - proxy-tier
|
||||||
|
# - default
|
||||||
|
|
||||||
|
# Uncomment if using our nginx proxy example
|
||||||
|
#networks:
|
||||||
|
# proxy-tier:
|
||||||
|
# external: true
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Start the service
|
||||||
|
|
||||||
|
```bash
|
||||||
|
`docker compose up -d`
|
||||||
|
```
|
||||||
|
|
||||||
|
Your keycloak instance is now available on https://doc.yourdomain.tld
|
||||||
|
|
||||||
|
## Creating an OIDC Client for Docs Application
|
||||||
|
|
||||||
|
### Step 1: Create a New Realm
|
||||||
|
|
||||||
|
1. Log in to the Keycloak administration console.
|
||||||
|
2. Navigate to the realm tab and click on the "Create realm" button.
|
||||||
|
3. Enter the name of the realm - `docs`.
|
||||||
|
4. Click "Create".
|
||||||
|
|
||||||
|
#### Step 2: Create a New Client
|
||||||
|
|
||||||
|
1. Navigate to the "Clients" tab.
|
||||||
|
2. Click on the "Create client" button.
|
||||||
|
3. Enter the client ID - e.g. `docs`.
|
||||||
|
4. Enable "Client authentication" option.
|
||||||
|
6. Set the "Valid redirect URIs" to the URL of your docs application suffixed with `/*` - e.g., "https://docs.example.com/*".
|
||||||
|
1. Set the "Web Origins" to the URL of your docs application - e.g. `https://docs.example.com`.
|
||||||
|
1. Click "Save".
|
||||||
|
|
||||||
|
#### Step 3: Get Client Credentials
|
||||||
|
|
||||||
|
1. Go to the "Credentials" tab.
|
||||||
|
2. Copy the client ID (`docs` in this example) and the client secret.
|
||||||
36
docs/examples/compose/keycloak/compose.yaml
Normal file
36
docs/examples/compose/keycloak/compose.yaml
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
services:
|
||||||
|
kc_postgresql:
|
||||||
|
image: postgres:16
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
|
||||||
|
interval: 1s
|
||||||
|
timeout: 2s
|
||||||
|
retries: 300
|
||||||
|
env_file:
|
||||||
|
- env.d/kc_postgresql
|
||||||
|
volumes:
|
||||||
|
- ./data/keycloak:/var/lib/postgresql/data/pgdata
|
||||||
|
|
||||||
|
keycloak:
|
||||||
|
image: quay.io/keycloak/keycloak:26.1.3
|
||||||
|
command: ["start"]
|
||||||
|
env_file:
|
||||||
|
- env.d/kc_postgresql
|
||||||
|
- env.d/keycloak
|
||||||
|
# Uncomment and set your values if using our nginx proxy example
|
||||||
|
# environment:
|
||||||
|
# - VIRTUAL_HOST=id.yourdomain.tld # used by nginx proxy
|
||||||
|
# - VIRTUAL_PORT=8080 # used by nginx proxy
|
||||||
|
# - LETSENCRYPT_HOST=id.yourdomain.tld # used by lets encrypt to generate TLS certificate
|
||||||
|
depends_on:
|
||||||
|
kc_postgresql::
|
||||||
|
condition: service_healthy
|
||||||
|
restart: true
|
||||||
|
# Uncomment if using our nginx proxy example
|
||||||
|
# networks:
|
||||||
|
# - proxy-tier
|
||||||
|
# - default
|
||||||
|
#
|
||||||
|
#networks:
|
||||||
|
# proxy-tier:
|
||||||
|
# external: true
|
||||||
103
docs/examples/compose/minio/README.md
Normal file
103
docs/examples/compose/minio/README.md
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
# Deploy and Configure Minio for Docs
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
> \[!CAUTION\]
|
||||||
|
> We provide those instructions as an example, it should not be run in production. For production environments, deploy MinIO [in a Multi-Node Multi-Drive (Distributed)](https://min.io/docs/minio/linux/operations/install-deploy-manage/deploy-minio-multi-node-multi-drive.html#minio-mnmd) topology
|
||||||
|
|
||||||
|
### Step 1: Prepare your working environment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir minio
|
||||||
|
curl -o minio/compose.yaml https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/docs/examples/compose/minio/compose.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2:. Update compose file with your own values
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
version: '3'
|
||||||
|
services:
|
||||||
|
minio:
|
||||||
|
...
|
||||||
|
environment:
|
||||||
|
- MINIO_ROOT_USER=<Set minio root username>
|
||||||
|
- MINIO_ROOT_PASSWORD=<Set minio root password>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Expose MinIO instance
|
||||||
|
|
||||||
|
#### Option 1: Internal network
|
||||||
|
|
||||||
|
You may not need to expose your MinIO instance to the public if only services hosted on the same private network need to access to your MinIO instance.
|
||||||
|
|
||||||
|
You should create a docker network that will be shared between those services
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker network create storage-tier
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Option 2: Public network
|
||||||
|
|
||||||
|
If you want to expose your MinIO instance to the public, it needs to be exposed on a domain with SSL termination. You can use our [example](../nginx-proxy/README.md) with an nginx proxy and Let's Encrypt companion for automated creation/renewal of Let's Encrypt certificates using [acme.sh](http://acme.sh).
|
||||||
|
|
||||||
|
If following our example, uncomment the environment and network sections in compose file and update it with your values.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
version: '3'
|
||||||
|
services:
|
||||||
|
docs:
|
||||||
|
...
|
||||||
|
minio:
|
||||||
|
...
|
||||||
|
environment:
|
||||||
|
...
|
||||||
|
# - VIRTUAL_HOST=storage.yourdomain.tld # used by nginx proxy
|
||||||
|
# - VIRTUAL_PORT=9000 # used by nginx proxy
|
||||||
|
# - LETSENCRYPT_HOST=storage.yourdomain.tld # used by lets encrypt to generate TLS certificate
|
||||||
|
...
|
||||||
|
# Uncomment if using our nginx proxy example
|
||||||
|
# networks:
|
||||||
|
# - proxy-tier
|
||||||
|
# - default
|
||||||
|
|
||||||
|
# Uncomment if using our nginx proxy example
|
||||||
|
#networks:
|
||||||
|
# proxy-tier:
|
||||||
|
# external: true
|
||||||
|
```
|
||||||
|
|
||||||
|
In this example we are only exposing MinIO API service. Follow the official documentation to configure Minio WebUI.
|
||||||
|
|
||||||
|
### Step 4: Start the service
|
||||||
|
|
||||||
|
```bash
|
||||||
|
`docker compose up -d`
|
||||||
|
```
|
||||||
|
|
||||||
|
Your minio instance is now available on https://storage.yourdomain.tld
|
||||||
|
|
||||||
|
## Creating a user and bucket for your Docs instance
|
||||||
|
|
||||||
|
### Installing mc
|
||||||
|
|
||||||
|
Follow the [official documentation](https://min.io/docs/minio/linux/reference/minio-mc.html#install-mc) to install mc
|
||||||
|
|
||||||
|
### Step 1: Configure `mc` to connect to your MinIO Server with your root user
|
||||||
|
|
||||||
|
```shellscript
|
||||||
|
mc alias set minio <MINIO_SERVER_URL> <MINIO_ROOT_USER> <MINIO_ROOT_PASSWORD>
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace the values with those you have set in the previous steps
|
||||||
|
|
||||||
|
### Step 2: Create a new bucket with versioning enabled
|
||||||
|
|
||||||
|
```shellscript
|
||||||
|
mc mb --with-versioning minio/<your-bucket-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace `your-bucket-name` with the desired name for your bucket e.g. `docs-media-storage`
|
||||||
|
|
||||||
|
### Additional notes:
|
||||||
|
|
||||||
|
For increased security you should create a dedicated user with `readwrite` access to the Bucket. In the following example we will use MinIO root user.
|
||||||
27
docs/examples/compose/minio/compose.yaml
Normal file
27
docs/examples/compose/minio/compose.yaml
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
services:
|
||||||
|
minio:
|
||||||
|
image: minio/minio
|
||||||
|
environment:
|
||||||
|
- MINIO_ROOT_USER=<set minio root username>
|
||||||
|
- MINIO_ROOT_PASSWORD=<set minio root password>
|
||||||
|
# Uncomment and set your values if using our nginx proxy example
|
||||||
|
# - VIRTUAL_HOST=storage.yourdomain.tld # used by nginx proxy
|
||||||
|
# - VIRTUAL_PORT=9000 # used by nginx proxy
|
||||||
|
# - LETSENCRYPT_HOST=storage.yourdomain.tld # used by lets encrypt to generate TLS certificate
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mc", "ready", "local"]
|
||||||
|
interval: 1s
|
||||||
|
timeout: 20s
|
||||||
|
retries: 300
|
||||||
|
entrypoint: ""
|
||||||
|
command: minio server /data
|
||||||
|
volumes:
|
||||||
|
- ./data/minio:/data
|
||||||
|
# Uncomment if using our nginx proxy example
|
||||||
|
# networks:
|
||||||
|
# - proxy-tier
|
||||||
|
|
||||||
|
# Uncomment if using our nginx proxy example
|
||||||
|
#networks:
|
||||||
|
# proxy-tier:
|
||||||
|
# external: true
|
||||||
39
docs/examples/compose/nginx-proxy/README.md
Normal file
39
docs/examples/compose/nginx-proxy/README.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# Nginx proxy with automatic SSL certificates
|
||||||
|
|
||||||
|
> \[!CAUTION\]
|
||||||
|
> We provide those instructions as an example, for extended development or production environments, you should follow the [official documentation](https://github.com/nginx-proxy/acme-companion/tree/main/docs).
|
||||||
|
|
||||||
|
Nginx-proxy sets up a container running nginx and docker-gen. docker-gen generates reverse proxy configs for nginx and reloads nginx when containers are started and stopped.
|
||||||
|
|
||||||
|
Acme-companion is a lightweight companion container for nginx-proxy. It handles the automated creation, renewal and use of SSL certificates for proxied Docker containers through the ACME protocol.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Step 1: Prepare your working environment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir nginx-proxy
|
||||||
|
curl -o nginx-proxy/compose.yaml https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/docs/examples/compose/nginx-proxy/compose.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Edit `DEFAULT_EMAIL` in the compose file.
|
||||||
|
|
||||||
|
Albeit optional, it is recommended to provide a valid default email address through the `DEFAULT_EMAIL` environment variable, so that Let's Encrypt can warn you about expiring certificates and allow you to recover your account.
|
||||||
|
|
||||||
|
### Step 3: Create docker network
|
||||||
|
|
||||||
|
Containers need share the same network for auto-discovery.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker network create proxy-tier
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Start service
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Once both nginx-proxy and acme-companion containers are up and running, start any container you want proxied with environment variables `VIRTUAL_HOST` and `LETSENCRYPT_HOST` both set to the domain(s) your proxied container is going to use.
|
||||||
36
docs/examples/compose/nginx-proxy/compose.yaml
Normal file
36
docs/examples/compose/nginx-proxy/compose.yaml
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
services:
|
||||||
|
nginx-proxy:
|
||||||
|
image: nginxproxy/nginx-proxy
|
||||||
|
container_name: nginx-proxy
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- html:/usr/share/nginx/html
|
||||||
|
- certs:/etc/nginx/certs:ro
|
||||||
|
- /var/run/docker.sock:/tmp/docker.sock:ro
|
||||||
|
networks:
|
||||||
|
- proxy-tier
|
||||||
|
|
||||||
|
acme-companion:
|
||||||
|
image: nginxproxy/acme-companion
|
||||||
|
container_name: nginx-proxy-acme
|
||||||
|
environment:
|
||||||
|
- DEFAULT_EMAIL=mail@yourdomain.tld
|
||||||
|
volumes_from:
|
||||||
|
- nginx-proxy
|
||||||
|
volumes:
|
||||||
|
- certs:/etc/nginx/certs:rw
|
||||||
|
- acme:/etc/acme.sh
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
networks:
|
||||||
|
- proxy-tier
|
||||||
|
|
||||||
|
networks:
|
||||||
|
proxy-tier:
|
||||||
|
external: true
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
html:
|
||||||
|
certs:
|
||||||
|
acme:
|
||||||
@@ -46,9 +46,6 @@ backend:
|
|||||||
DB_USER: dinum
|
DB_USER: dinum
|
||||||
DB_PASSWORD: pass
|
DB_PASSWORD: pass
|
||||||
DB_PORT: 5432
|
DB_PORT: 5432
|
||||||
POSTGRES_DB: impress
|
|
||||||
POSTGRES_USER: dinum
|
|
||||||
POSTGRES_PASSWORD: pass
|
|
||||||
REDIS_URL: redis://default:pass@redis-master:6379/1
|
REDIS_URL: redis://default:pass@redis-master:6379/1
|
||||||
AWS_S3_ENDPOINT_URL: http://minio.impress.svc.cluster.local:9000
|
AWS_S3_ENDPOINT_URL: http://minio.impress.svc.cluster.local:9000
|
||||||
AWS_S3_ACCESS_KEY_ID: root
|
AWS_S3_ACCESS_KEY_ID: root
|
||||||
@@ -85,7 +82,7 @@ backend:
|
|||||||
# Extra volume to manage our local custom CA and avoid to set ssl_verify: false
|
# Extra volume to manage our local custom CA and avoid to set ssl_verify: false
|
||||||
extraVolumeMounts:
|
extraVolumeMounts:
|
||||||
- name: certs
|
- name: certs
|
||||||
mountPath: /usr/local/lib/python3.12/site-packages/certifi/cacert.pem
|
mountPath: /usr/local/lib/python3.13/site-packages/certifi/cacert.pem
|
||||||
subPath: cacert.pem
|
subPath: cacert.pem
|
||||||
|
|
||||||
# Extra volume to manage our local custom CA and avoid to set ssl_verify: false
|
# Extra volume to manage our local custom CA and avoid to set ssl_verify: false
|
||||||
@@ -121,6 +118,22 @@ yProvider:
|
|||||||
COLLABORATION_SERVER_ORIGIN: https://impress.127.0.0.1.nip.io
|
COLLABORATION_SERVER_ORIGIN: https://impress.127.0.0.1.nip.io
|
||||||
COLLABORATION_SERVER_SECRET: my-secret
|
COLLABORATION_SERVER_SECRET: my-secret
|
||||||
Y_PROVIDER_API_KEY: my-secret
|
Y_PROVIDER_API_KEY: my-secret
|
||||||
|
COLLABORATION_BACKEND_BASE_URL: https://impress.127.0.0.1.nip.io
|
||||||
|
NODE_EXTRA_CA_CERTS: /usr/local/share/ca-certificates/cacert.pem
|
||||||
|
|
||||||
|
# Mount the certificate so yProvider can establish tls with the backend
|
||||||
|
extraVolumeMounts:
|
||||||
|
- name: certs
|
||||||
|
mountPath: /usr/local/share/ca-certificates/cacert.pem
|
||||||
|
subPath: cacert.pem
|
||||||
|
|
||||||
|
extraVolumes:
|
||||||
|
- name: certs
|
||||||
|
configMap:
|
||||||
|
name: certifi
|
||||||
|
items:
|
||||||
|
- key: cacert.pem
|
||||||
|
path: cacert.pem
|
||||||
|
|
||||||
posthog:
|
posthog:
|
||||||
ingress:
|
ingress:
|
||||||
@@ -135,9 +148,6 @@ ingress:
|
|||||||
ingressCollaborationWS:
|
ingressCollaborationWS:
|
||||||
enabled: true
|
enabled: true
|
||||||
host: impress.127.0.0.1.nip.io
|
host: impress.127.0.0.1.nip.io
|
||||||
|
|
||||||
annotations:
|
|
||||||
nginx.ingress.kubernetes.io/auth-url: https://impress.127.0.0.1.nip.io/api/v1.0/documents/collaboration-auth/
|
|
||||||
|
|
||||||
ingressCollaborationApi:
|
ingressCollaborationApi:
|
||||||
enabled: true
|
enabled: true
|
||||||
@@ -91,7 +91,7 @@ extraDeploy:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"username": "user-e2e-chromium",
|
"username": "user-e2e-chromium",
|
||||||
"email": "user@chromium.e2e",
|
"email": "user@chromium.test",
|
||||||
"firstName": "E2E",
|
"firstName": "E2E",
|
||||||
"lastName": "Chromium",
|
"lastName": "Chromium",
|
||||||
"enabled": "true",
|
"enabled": "true",
|
||||||
@@ -105,7 +105,7 @@ extraDeploy:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"username": "user-e2e-webkit",
|
"username": "user-e2e-webkit",
|
||||||
"email": "user@webkit.e2e",
|
"email": "user@webkit.test",
|
||||||
"firstName": "E2E",
|
"firstName": "E2E",
|
||||||
"lastName": "Webkit",
|
"lastName": "Webkit",
|
||||||
"enabled": "true",
|
"enabled": "true",
|
||||||
@@ -119,7 +119,7 @@ extraDeploy:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"username": "user-e2e-firefox",
|
"username": "user-e2e-firefox",
|
||||||
"email": "user@firefox.e2e",
|
"email": "user@firefox.test",
|
||||||
"firstName": "E2E",
|
"firstName": "E2E",
|
||||||
"lastName": "Firefox",
|
"lastName": "Firefox",
|
||||||
"enabled": "true",
|
"enabled": "true",
|
||||||
232
docs/installation/compose.md
Normal file
232
docs/installation/compose.md
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
# Installation with docker compose
|
||||||
|
|
||||||
|
We provide a sample configuration for running Docs using Docker Compose. Please note that this configuration is experimental, and the official way to deploy Docs in production is to use [k8s](../installation/kubernetes.md)
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- A modern version of Docker and its Compose plugin.
|
||||||
|
- A domain name and DNS configured to your server.
|
||||||
|
- An Identity Provider that supports OpenID Connect protocol - we provide [an example to deploy Keycloak](../examples/compose/keycloak/README.md).
|
||||||
|
- An Object Storage that implements S3 API - we provide [an example to deploy Minio](../examples/compose/minio/README.md).
|
||||||
|
- A Postgresql database - we provide [an example in the compose file](../examples/compose/compose.yaml).
|
||||||
|
- A Redis database - we provide [an example in the compose file](../examples/compose/compose.yaml).
|
||||||
|
|
||||||
|
## Software Requirements
|
||||||
|
|
||||||
|
Ensure you have Docker Compose(v2) installed on your host server. Follow the official guidelines for a reliable setup:
|
||||||
|
|
||||||
|
Docker Compose is included with Docker Engine:
|
||||||
|
|
||||||
|
- **Docker Engine:** We suggest adhering to the instructions provided by Docker
|
||||||
|
for [installing Docker Engine](https://docs.docker.com/engine/install/).
|
||||||
|
|
||||||
|
For older versions of Docker Engine that do not include Docker Compose:
|
||||||
|
|
||||||
|
- **Docker Compose:** Install it as per the [official documentation](https://docs.docker.com/compose/install/).
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> `docker-compose` may not be supported. You are advised to use `docker compose` instead.
|
||||||
|
|
||||||
|
## Step 1: Prepare your working environment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p docs/env.d
|
||||||
|
cd docs
|
||||||
|
curl -o compose.yaml https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/docs/examples/compose/compose.yaml
|
||||||
|
curl -o env.d/common https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/env.d/production.dist/common
|
||||||
|
curl -o env.d/backend https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/env.d/production.dist/backend
|
||||||
|
curl -o env.d/yprovider https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/env.d/production.dist/yprovider
|
||||||
|
curl -o env.d/postgresql https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/env.d/production.dist/postgresql
|
||||||
|
```
|
||||||
|
|
||||||
|
If you are using the sample nginx-proxy configuration:
|
||||||
|
```bash
|
||||||
|
curl -o default.conf.template https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/docker/files/production/etc/nginx/conf.d/default.conf.template
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 2: Configuration
|
||||||
|
|
||||||
|
Docs configuration is achieved through environment variables. We provide a [detailed description of all variables](../env.md).
|
||||||
|
|
||||||
|
In this example, we assume the following services:
|
||||||
|
|
||||||
|
- OIDC provider on https://id.yourdomain.tld
|
||||||
|
- Object Storage on https://storage.yourdomain.tld
|
||||||
|
- Docs on https://docs.yourdomain.tld
|
||||||
|
- Bucket name is docs-media-storage
|
||||||
|
|
||||||
|
**Set your own values in `env.d/common`**
|
||||||
|
|
||||||
|
### OIDC
|
||||||
|
|
||||||
|
Authentication in Docs is managed through Open ID Connect protocol. A functional Identity Provider implementing this protocol is required.
|
||||||
|
|
||||||
|
For guidance, refer to our [Keycloak deployment example](../examples/compose/keycloak/README.md).
|
||||||
|
|
||||||
|
If using Keycloak as your Identity Provider, set `OIDC_RP_CLIENT_ID` and `OIDC_RP_CLIENT_SECRET` variables with those of the OIDC client created for Docs. By default we have set `docs` as the realm name, if you have named your realm differently, update the value `REALM_NAME` in `env.d/common`
|
||||||
|
|
||||||
|
For others OIDC providers, update the variables in `env.d/backend`.
|
||||||
|
|
||||||
|
### Object Storage
|
||||||
|
|
||||||
|
Files and media are stored in an Object Store that supports the S3 API.
|
||||||
|
|
||||||
|
For guidance, refer to our [Minio deployment example](../examples/compose/minio/README.md).
|
||||||
|
|
||||||
|
Set `AWS_S3_ACCESS_KEY_ID` and `AWS_S3_SECRET_ACCESS_KEY` with the credentials of a user with `readwrite` access to the bucket created for Docs.
|
||||||
|
|
||||||
|
### Postgresql
|
||||||
|
|
||||||
|
Docs uses PostgreSQL as its database. Although an external PostgreSQL can be used, our example provides a deployment method.
|
||||||
|
|
||||||
|
If you are using the example provided, you need to generate a secure key for `DB_PASSWORD` and set it in `env.d/postgresql`.
|
||||||
|
|
||||||
|
If you are using an external service or not using our default values, you should update the variables in `env.d/postgresql`
|
||||||
|
|
||||||
|
### Redis
|
||||||
|
|
||||||
|
Docs uses Redis for caching. While an external Redis can be used, our example provides a deployment method.
|
||||||
|
|
||||||
|
If you are using an external service, you need to set `REDIS_URL` environment variable in `env.d/backend`.
|
||||||
|
|
||||||
|
### Y Provider
|
||||||
|
|
||||||
|
The Y provider service enables collaboration through websockets.
|
||||||
|
|
||||||
|
Generates a secure key for `Y_PROVIDER_API_KEY` and `COLLABORATION_SERVER_SECRET` in ``env.d/yprovider``.
|
||||||
|
|
||||||
|
### Docs
|
||||||
|
|
||||||
|
The Docs backend is built on the Django Framework.
|
||||||
|
|
||||||
|
Generates a secure key for `DJANGO_SECRET_KEY` in `env.d/backend`.
|
||||||
|
|
||||||
|
### Logging
|
||||||
|
|
||||||
|
Update the following variables in `env.d/backend` if you want to change the logging levels:
|
||||||
|
```env
|
||||||
|
LOGGING_LEVEL_HANDLERS_CONSOLE=DEBUG
|
||||||
|
LOGGING_LEVEL_LOGGERS_ROOT=DEBUG
|
||||||
|
LOGGING_LEVEL_LOGGERS_APP=DEBUG
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mail
|
||||||
|
|
||||||
|
The following environment variables are required in `env.d/backend` for the mail service to send invitations :
|
||||||
|
|
||||||
|
```env
|
||||||
|
DJANGO_EMAIL_HOST=<smtp host>
|
||||||
|
DJANGO_EMAIL_HOST_USER=<smtp user>
|
||||||
|
DJANGO_EMAIL_HOST_PASSWORD=<smtp password>
|
||||||
|
DJANGO_EMAIL_PORT=<smtp port>
|
||||||
|
DJANGO_EMAIL_FROM=<your email address>
|
||||||
|
|
||||||
|
#DJANGO_EMAIL_USE_TLS=true # A flag to enable or disable TLS for email sending.
|
||||||
|
#DJANGO_EMAIL_USE_SSL=true # A flag to enable or disable SSL for email sending.
|
||||||
|
|
||||||
|
|
||||||
|
DJANGO_EMAIL_BRAND_NAME=<brand name used in email templates> # e.g. "La Suite Numérique"
|
||||||
|
DJANGO_EMAIL_LOGO_IMG=<logo image to use in email templates.> # e.g. "https://docs.yourdomain.tld/assets/logo-suite-numerique.png"
|
||||||
|
```
|
||||||
|
|
||||||
|
### AI
|
||||||
|
|
||||||
|
Built-in AI actions let users generate, summarize, translate, and correct content.
|
||||||
|
|
||||||
|
AI is disabled by default. To enable it, the following environment variables must be set in in `env.d/backend`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
AI_FEATURE_ENABLED=true # is false by default
|
||||||
|
AI_BASE_URL=https://openaiendpoint.com
|
||||||
|
AI_API_KEY=<API key>
|
||||||
|
AI_MODEL=<model used> e.g. llama
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend theme
|
||||||
|
|
||||||
|
You can [customize your Docs instance](../theming.md) with your own theme and custom css.
|
||||||
|
|
||||||
|
The following environment variables must be set in `env.d/backend`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
FRONTEND_THEME=default # name of your theme built with cuningham
|
||||||
|
FRONTEND_CSS_URL=https://storage.yourdomain.tld/themes/custom.css # custom css
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 3: Reverse proxy and SSL/TLS
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> In a production environment, configure SSL/TLS termination to run your instance on https.
|
||||||
|
|
||||||
|
If you have your own certificates and proxy setup, you can skip this part.
|
||||||
|
|
||||||
|
You can follow our [nginx proxy example](../examples/compose/nginx-proxy/README.md) with automatic generation and renewal of certificate with Let's Encrypt.
|
||||||
|
|
||||||
|
You will need to uncomment the environment and network sections in compose file and update it with your values.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
frontend:
|
||||||
|
...
|
||||||
|
# Uncomment and set your values if using our nginx proxy example
|
||||||
|
#environment:
|
||||||
|
# - VIRTUAL_HOST=${DOCS_HOST} # used by nginx proxy
|
||||||
|
# - VIRTUAL_PORT=8083 # used by nginx proxy
|
||||||
|
# - LETSENCRYPT_HOST=${DOCS_HOST} # used by lets encrypt to generate TLS certificate
|
||||||
|
...
|
||||||
|
# Uncomment if using our nginx proxy example
|
||||||
|
# networks:
|
||||||
|
# - proxy-tier
|
||||||
|
#
|
||||||
|
#networks:
|
||||||
|
# proxy-tier:
|
||||||
|
# external: true
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 4: Start Docs
|
||||||
|
|
||||||
|
You are ready to start your Docs application !
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
> [!NOTE]
|
||||||
|
> Version of the images are set to latest, you should pin it to the desired version to avoid unwanted upgrades when pulling latest image.
|
||||||
|
|
||||||
|
## Step 5: Run the database migration and create Django admin user
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose run --rm backend python manage.py migrate
|
||||||
|
docker compose run --rm backend python manage.py createsuperuser --email <admin email> --password <admin password>
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace `<admin email>` with the email of your admin user and generate a secure password.
|
||||||
|
|
||||||
|
Your docs instance is now available on the domain you defined, https://docs.yourdomain.tld.
|
||||||
|
|
||||||
|
THe admin interface is available on https://docs.yourdomain.tld/admin with the admin user you just created.
|
||||||
|
|
||||||
|
## How to upgrade your Docs application
|
||||||
|
|
||||||
|
Before running an upgrade you must check the [Upgrade document](../../UPGRADE.md) for specific procedures that might be needed.
|
||||||
|
|
||||||
|
You can also check the [Changelog](../../CHANGELOG.md) for brief summary of the changes.
|
||||||
|
|
||||||
|
### Step 1: Edit the images tag with the desired version
|
||||||
|
|
||||||
|
### Step 2: Pull the images
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose pull
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Restart your containers
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose restart
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Run the database migration
|
||||||
|
Your database schema may need to be updated, run:
|
||||||
|
```bash
|
||||||
|
docker compose run --rm backend python manage.py migrate
|
||||||
|
```
|
||||||
@@ -168,9 +168,6 @@ DB_NAME: impress
|
|||||||
DB_USER: dinum
|
DB_USER: dinum
|
||||||
DB_PASSWORD: pass
|
DB_PASSWORD: pass
|
||||||
DB_PORT: 5432
|
DB_PORT: 5432
|
||||||
POSTGRES_DB: impress
|
|
||||||
POSTGRES_USER: dinum
|
|
||||||
POSTGRES_PASSWORD: pass
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Find s3 bucket connection values
|
### Find s3 bucket connection values
|
||||||
110
docs/system-requirements.md
Normal file
110
docs/system-requirements.md
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
# La Suite Docs – System & Requirements (2025-06)
|
||||||
|
|
||||||
|
## 1. Quick-Reference Matrix (single VM / laptop)
|
||||||
|
|
||||||
|
| Scenario | RAM | vCPU | SSD | Notes |
|
||||||
|
| ------------------------- | ----- | ---- | ------- | ------------------------- |
|
||||||
|
| **Solo dev** | 8 GB | 4 | 15 GB | Hot-reload + one IDE |
|
||||||
|
| **Team QA** | 16 GB | 6 | 30 GB | Runs integration tests |
|
||||||
|
| **Prod ≤ 100 live users** | 32 GB | 8 + | 50 GB + | Scale linearly above this |
|
||||||
|
|
||||||
|
Memory is the first bottleneck; CPU matters only when Celery or the Next.js build is saturated.
|
||||||
|
|
||||||
|
> **Note:** Memory consumption varies by operating system. Windows tends to be more memory-hungry than Linux, so consider adding 10-20% extra RAM when running on Windows compared to Linux-based systems.
|
||||||
|
|
||||||
|
## 2. Development Environment Memory Requirements
|
||||||
|
|
||||||
|
| Service | Typical use | Rationale / source |
|
||||||
|
| ------------------------ | ----------------------------- | --------------------------------------------------------------------------------------- |
|
||||||
|
| PostgreSQL | **1 – 2 GB** | `shared_buffers` starting point ≈ 25% RAM ([postgresql.org][1]) |
|
||||||
|
| Keycloak | **≈ 1.3 GB** | 70% of limit for heap + ~300 MB non-heap ([keycloak.org][2]) |
|
||||||
|
| Redis | **≤ 256 MB** | Empty instance ≈ 3 MB; budget 256 MB to allow small datasets ([stackoverflow.com][3]) |
|
||||||
|
| MinIO | **2 GB (dev) / 32 GB (prod)**| Pre-allocates 1–2 GiB; docs recommend 32 GB per host for ≤ 100 Ti storage ([min.io][4]) |
|
||||||
|
| Django API (+ Celery) | **0.8 – 1.5 GB** | Empirical in-house metrics |
|
||||||
|
| Next.js frontend | **0.5 – 1 GB** | Dev build chain |
|
||||||
|
| Y-Provider (y-websocket) | **< 200 MB** | Large 40 MB YDoc called “big” in community thread ([discuss.yjs.dev][5]) |
|
||||||
|
| Nginx | **< 100 MB** | Static reverse-proxy footprint |
|
||||||
|
|
||||||
|
[1]: https://www.postgresql.org/docs/9.1/runtime-config-resource.html "PostgreSQL: Documentation: 9.1: Resource Consumption"
|
||||||
|
[2]: https://www.keycloak.org/high-availability/concepts-memory-and-cpu-sizing "Concepts for sizing CPU and memory resources - Keycloak"
|
||||||
|
[3]: https://stackoverflow.com/questions/45233052/memory-footprint-for-redis-empty-instance "Memory footprint for Redis empty instance - Stack Overflow"
|
||||||
|
[4]: https://min.io/docs/minio/kubernetes/upstream/operations/checklists/hardware.html "Hardware Checklist — MinIO Object Storage for Kubernetes"
|
||||||
|
[5]: https://discuss.yjs.dev/t/understanding-memory-requirements-for-production-usage/198 "Understanding memory requirements for production usage - Yjs Community"
|
||||||
|
|
||||||
|
> **Rule of thumb:** add 2 GB for OS/overhead, then sum only the rows you actually run.
|
||||||
|
|
||||||
|
## 3. Production Environment Memory Requirements
|
||||||
|
|
||||||
|
Production deployments differ significantly from development environments. The table below shows typical memory usage for production services:
|
||||||
|
|
||||||
|
| Service | Typical use | Rationale / notes |
|
||||||
|
| ------------------------ | ----------------------------- | --------------------------------------------------------------------------------------- |
|
||||||
|
| PostgreSQL | **2 – 8 GB** | Higher `shared_buffers` and connection pooling for concurrent users |
|
||||||
|
| OIDC Provider (optional) | **Variable** | Any OIDC-compatible provider (Keycloak, Auth0, Azure AD, etc.) - external or self-hosted |
|
||||||
|
| Redis | **256 MB – 2 GB** | Session storage and caching; scales with active user sessions |
|
||||||
|
| Object Storage (optional)| **External or self-hosted** | Can use AWS S3, Azure Blob, Google Cloud Storage, or self-hosted MinIO |
|
||||||
|
| Django API (+ Celery) | **1 – 3 GB** | Production workloads with background tasks and higher concurrency |
|
||||||
|
| Static Files (Nginx) | **< 200 MB** | Serves Next.js build output and static assets; no development overhead |
|
||||||
|
| Y-Provider (y-websocket) | **200 MB – 1 GB** | Scales with concurrent document editing sessions |
|
||||||
|
| Nginx (Load Balancer) | **< 200 MB** | Reverse proxy, SSL termination, static file serving |
|
||||||
|
|
||||||
|
### Production Architecture Notes
|
||||||
|
|
||||||
|
- **Frontend**: Uses pre-built Next.js static assets served by Nginx (no Node.js runtime needed)
|
||||||
|
- **Authentication**: Any OIDC-compatible provider can be used instead of self-hosted Keycloak
|
||||||
|
- **Object Storage**: External services (S3, Azure Blob) or self-hosted solutions (MinIO) are both viable
|
||||||
|
- **Database**: Consider PostgreSQL clustering or managed database services for high availability
|
||||||
|
- **Scaling**: Horizontal scaling is recommended for Django API and Y-Provider services
|
||||||
|
|
||||||
|
### Minimal Production Setup (Core Services Only)
|
||||||
|
|
||||||
|
| Service | Memory | Notes |
|
||||||
|
| ------------------------ | --------- | --------------------------------------- |
|
||||||
|
| PostgreSQL | **2 GB** | Core database |
|
||||||
|
| Django API (+ Celery) | **1.5 GB**| Backend services |
|
||||||
|
| Y-Provider | **200 MB**| Real-time collaboration |
|
||||||
|
| Nginx | **100 MB**| Static files + reverse proxy |
|
||||||
|
| Redis | **256 MB**| Session storage |
|
||||||
|
| **Total (without auth/storage)** | **≈ 4 GB** | External OIDC + object storage assumed |
|
||||||
|
|
||||||
|
## 4. Recommended Software Versions
|
||||||
|
|
||||||
|
| Tool | Minimum |
|
||||||
|
| ----------------------- | ------- |
|
||||||
|
| Docker Engine / Desktop | 24.0 |
|
||||||
|
| Docker Compose | v2 |
|
||||||
|
| Git | 2.40 |
|
||||||
|
| **Node.js** | 22+ |
|
||||||
|
| **Python** | 3.13+ |
|
||||||
|
| GNU Make | 4.4 |
|
||||||
|
| Kind | 0.22 |
|
||||||
|
| Helm | 3.14 |
|
||||||
|
| kubectl | 1.29 |
|
||||||
|
| mkcert | 1.4 |
|
||||||
|
|
||||||
|
|
||||||
|
## 5. Ports (dev defaults)
|
||||||
|
|
||||||
|
| Port | Service |
|
||||||
|
| --------- | --------------------- |
|
||||||
|
| 3000 | Next.js |
|
||||||
|
| 8071 | Django |
|
||||||
|
| 4444 | Y-Provider |
|
||||||
|
| 8080 | Keycloak |
|
||||||
|
| 8083 | Nginx proxy |
|
||||||
|
| 9000/9001 | MinIO |
|
||||||
|
| 15432 | PostgreSQL (main) |
|
||||||
|
| 5433 | PostgreSQL (Keycloak) |
|
||||||
|
| 1081 | MailCatcher |
|
||||||
|
|
||||||
|
## 6. Sizing Guidelines
|
||||||
|
|
||||||
|
**RAM** – start at 8 GB dev / 16 GB staging / 32 GB prod. Postgres and Keycloak are the first to OOM; scale them first.
|
||||||
|
|
||||||
|
> **OS considerations:** Windows systems typically require 10-20% more RAM than Linux due to higher OS overhead. Docker Desktop on Windows also uses additional memory compared to native Linux Docker.
|
||||||
|
|
||||||
|
**CPU** – budget one vCPU per busy container until Celery or Next.js builds saturate.
|
||||||
|
|
||||||
|
**Disk** – SSD; add 10 GB extra for the Docker layer cache.
|
||||||
|
|
||||||
|
**MinIO** – for demos, mount a local folder instead of running MinIO to save 2 GB+ of RAM.
|
||||||
@@ -53,4 +53,18 @@ Below is a visual example of a configured footer ⬇️:
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
----
|
||||||
|
|
||||||
|
# **Custom Translations** 📝
|
||||||
|
|
||||||
|
The translations can be partially overridden from the theme customization file.
|
||||||
|
|
||||||
|
### Settings 🔧
|
||||||
|
|
||||||
|
```shellscript
|
||||||
|
THEME_CUSTOMIZATION_FILE_PATH=<path>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example of JSON
|
||||||
|
|
||||||
|
The json must follow some rules: https://github.com/suitenumerique/docs/blob/main/src/helm/env.d/dev/configuration/theme/demo.json
|
||||||
145
docs/troubleshoot.md
Normal file
145
docs/troubleshoot.md
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
# Troubleshooting Guide
|
||||||
|
|
||||||
|
## Line Ending Issues on Windows (LF/CRLF)
|
||||||
|
|
||||||
|
### Problem Description
|
||||||
|
|
||||||
|
This project uses **LF (Line Feed: `\n`) line endings** exclusively. Windows users may encounter issues because:
|
||||||
|
|
||||||
|
- **Windows** defaults to CRLF (Carriage Return + Line Feed: `\r\n`) for line endings
|
||||||
|
- **This project** uses LF line endings for consistency across all platforms
|
||||||
|
- **Git** may automatically convert line endings, causing conflicts or build failures
|
||||||
|
|
||||||
|
### Common Symptoms
|
||||||
|
|
||||||
|
- Git shows files as modified even when no changes were made
|
||||||
|
- Error messages like "warning: LF will be replaced by CRLF"
|
||||||
|
- Build failures or linting errors due to line ending mismatches
|
||||||
|
|
||||||
|
### Solutions for Windows Users
|
||||||
|
|
||||||
|
#### Configure Git to Preserve LF (Recommended)
|
||||||
|
|
||||||
|
Configure Git to NOT convert line endings and preserve LF:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git config core.autocrlf false
|
||||||
|
git config core.eol lf
|
||||||
|
```
|
||||||
|
|
||||||
|
This tells Git to:
|
||||||
|
- Never convert line endings automatically
|
||||||
|
- Always use LF for line endings in working directory
|
||||||
|
|
||||||
|
|
||||||
|
#### Fix Existing Repository with Wrong Line Endings
|
||||||
|
|
||||||
|
If you already have CRLF line endings in your local repository, the **best approach** is to configure Git properly and clone the project again:
|
||||||
|
|
||||||
|
1. **Configure Git first**:
|
||||||
|
```bash
|
||||||
|
git config --global core.autocrlf false
|
||||||
|
git config --global core.eol lf
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Clone the project fresh** (recommended):
|
||||||
|
```bash
|
||||||
|
# Navigate to parent directory
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
# Remove current repository (backup your changes first!)
|
||||||
|
rm -rf docs
|
||||||
|
|
||||||
|
# Clone again with correct line endings
|
||||||
|
git clone git@github.com:suitenumerique/docs.git
|
||||||
|
```
|
||||||
|
|
||||||
|
**Alternative**: If you have uncommitted changes and cannot re-clone:
|
||||||
|
|
||||||
|
1. **Backup your changes**:
|
||||||
|
```bash
|
||||||
|
git add .
|
||||||
|
git commit -m "Save changes before fixing line endings"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Remove all files from Git's index**:
|
||||||
|
```bash
|
||||||
|
git rm --cached -r .
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Reset Git configuration** (if not done globally):
|
||||||
|
```bash
|
||||||
|
git config core.autocrlf false
|
||||||
|
git config core.eol lf
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Re-add all files** (Git will use LF line endings):
|
||||||
|
```bash
|
||||||
|
git add .
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Commit the changes**:
|
||||||
|
```bash
|
||||||
|
git commit -m "✏️(project) Fix line endings to LF"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Frontend File Watching Issues on Windows
|
||||||
|
|
||||||
|
### Problem Description
|
||||||
|
|
||||||
|
Windows users may experience issues with file watching in the frontend-development container. This typically happens because:
|
||||||
|
|
||||||
|
- **Docker on Windows** has known limitations with file change detection
|
||||||
|
- **Node.js file watchers** may not detect changes properly on Windows filesystem
|
||||||
|
- **Hot reloading** fails to trigger when files are modified
|
||||||
|
|
||||||
|
### Common Symptoms
|
||||||
|
|
||||||
|
- Changes to frontend code aren't detected automatically
|
||||||
|
- Hot module replacement doesn't work as expected
|
||||||
|
- Need to manually restart the frontend container after code changes
|
||||||
|
- Console shows no reaction when saving files
|
||||||
|
|
||||||
|
### Solution: Enable WATCHPACK_POLLING
|
||||||
|
|
||||||
|
Add the `WATCHPACK_POLLING=true` environment variable to the frontend-development service in your local environment:
|
||||||
|
|
||||||
|
1. **Modify the `compose.yml` file** by adding the environment variable to the frontend-development service:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
frontend-development:
|
||||||
|
user: "${DOCKER_USER:-1000}"
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: ./src/frontend/Dockerfile
|
||||||
|
target: impress-dev
|
||||||
|
args:
|
||||||
|
API_ORIGIN: "http://localhost:8071"
|
||||||
|
PUBLISH_AS_MIT: "false"
|
||||||
|
SW_DEACTIVATED: "true"
|
||||||
|
image: impress:frontend-development
|
||||||
|
environment:
|
||||||
|
- WATCHPACK_POLLING=true # Add this line for Windows users
|
||||||
|
volumes:
|
||||||
|
- ./src/frontend:/home/frontend
|
||||||
|
- /home/frontend/node_modules
|
||||||
|
- /home/frontend/apps/impress/node_modules
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Restart your containers**:
|
||||||
|
```bash
|
||||||
|
make run
|
||||||
|
```
|
||||||
|
|
||||||
|
### Why This Works
|
||||||
|
|
||||||
|
- `WATCHPACK_POLLING=true` forces the file watcher to use polling instead of filesystem events
|
||||||
|
- Polling periodically checks for file changes rather than relying on OS-level file events
|
||||||
|
- This is more reliable on Windows but slightly increases CPU usage
|
||||||
|
- Changes to your frontend code should now be detected properly, enabling hot reloading
|
||||||
|
|
||||||
|
### Note
|
||||||
|
|
||||||
|
This setting is primarily needed for Windows users. Linux and macOS users typically don't need this setting as file watching works correctly by default on those platforms.
|
||||||
@@ -56,8 +56,13 @@ AI_API_KEY=password
|
|||||||
AI_MODEL=llama
|
AI_MODEL=llama
|
||||||
|
|
||||||
# Collaboration
|
# Collaboration
|
||||||
COLLABORATION_API_URL=http://y-provider:4444/collaboration/api/
|
COLLABORATION_API_URL=http://y-provider-development:4444/collaboration/api/
|
||||||
COLLABORATION_BACKEND_BASE_URL=http://app-dev:8000
|
COLLABORATION_BACKEND_BASE_URL=http://app-dev:8000
|
||||||
COLLABORATION_SERVER_ORIGIN=http://localhost:3000
|
COLLABORATION_SERVER_ORIGIN=http://localhost:3000
|
||||||
COLLABORATION_SERVER_SECRET=my-secret
|
COLLABORATION_SERVER_SECRET=my-secret
|
||||||
|
COLLABORATION_WS_NOT_CONNECTED_READY_ONLY=true
|
||||||
COLLABORATION_WS_URL=ws://localhost:4444/collaboration/ws/
|
COLLABORATION_WS_URL=ws://localhost:4444/collaboration/ws/
|
||||||
|
|
||||||
|
DJANGO_SERVER_TO_SERVER_API_TOKENS=server-api-token
|
||||||
|
Y_PROVIDER_API_BASE_URL=http://y-provider-development:4444/api/
|
||||||
|
Y_PROVIDER_API_KEY=yprovider-api-key
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
# For the CI job test-e2e
|
# For the CI job test-e2e
|
||||||
BURST_THROTTLE_RATES="200/minute"
|
BURST_THROTTLE_RATES="200/minute"
|
||||||
DJANGO_SERVER_TO_SERVER_API_TOKENS=test-e2e
|
COLLABORATION_API_URL=http://y-provider:4444/collaboration/api/
|
||||||
SUSTAINED_THROTTLE_RATES="200/hour"
|
SUSTAINED_THROTTLE_RATES="200/hour"
|
||||||
Y_PROVIDER_API_KEY=yprovider-api-key
|
|
||||||
Y_PROVIDER_API_BASE_URL=http://y-provider:4444/api/
|
Y_PROVIDER_API_BASE_URL=http://y-provider:4444/api/
|
||||||
65
env.d/production.dist/backend
Normal file
65
env.d/production.dist/backend
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
## Django
|
||||||
|
DJANGO_ALLOWED_HOSTS=${DOCS_HOST}
|
||||||
|
DJANGO_SECRET_KEY=<generate a random key>
|
||||||
|
DJANGO_SETTINGS_MODULE=impress.settings
|
||||||
|
DJANGO_CONFIGURATION=Production
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
# Set to DEBUG level for dev only
|
||||||
|
LOGGING_LEVEL_HANDLERS_CONSOLE=ERROR
|
||||||
|
LOGGING_LEVEL_LOGGERS_ROOT=INFO
|
||||||
|
LOGGING_LEVEL_LOGGERS_APP=INFO
|
||||||
|
|
||||||
|
# Python
|
||||||
|
PYTHONPATH=/app
|
||||||
|
|
||||||
|
# Mail
|
||||||
|
DJANGO_EMAIL_HOST=<smtp host>
|
||||||
|
DJANGO_EMAIL_HOST_USER=<smtp user>
|
||||||
|
DJANGO_EMAIL_HOST_PASSWORD=<smtp password>
|
||||||
|
DJANGO_EMAIL_PORT=<smtp port>
|
||||||
|
DJANGO_EMAIL_FROM=<your email address>
|
||||||
|
|
||||||
|
#DJANGO_EMAIL_USE_TLS=true # A flag to enable or disable TLS for email sending.
|
||||||
|
#DJANGO_EMAIL_USE_SSL=true # A flag to enable or disable SSL for email sending.
|
||||||
|
|
||||||
|
DJANGO_EMAIL_BRAND_NAME="La Suite Numérique"
|
||||||
|
DJANGO_EMAIL_LOGO_IMG="https://${DOCS_HOST}/assets/logo-suite-numerique.png"
|
||||||
|
|
||||||
|
# Media
|
||||||
|
AWS_S3_ENDPOINT_URL=https://${S3_HOST}
|
||||||
|
AWS_S3_ACCESS_KEY_ID=<s3 access key>
|
||||||
|
AWS_S3_SECRET_ACCESS_KEY=<s3 secret key>
|
||||||
|
AWS_STORAGE_BUCKET_NAME=${BUCKET_NAME}
|
||||||
|
MEDIA_BASE_URL=https://${DOCS_HOST}
|
||||||
|
|
||||||
|
# OIDC
|
||||||
|
OIDC_OP_JWKS_ENDPOINT=https://${KEYCLOAK_HOST}/realms/${REALM_NAME}/protocol/openid-connect/certs
|
||||||
|
OIDC_OP_AUTHORIZATION_ENDPOINT=https://${KEYCLOAK_HOST}/realms/${REALM_NAME}/protocol/openid-connect/auth
|
||||||
|
OIDC_OP_TOKEN_ENDPOINT=https://${KEYCLOAK_HOST}/realms/${REALM_NAME}/protocol/openid-connect/token
|
||||||
|
OIDC_OP_USER_ENDPOINT=https://${KEYCLOAK_HOST}/realms/${REALM_NAME}/protocol/openid-connect/userinfo
|
||||||
|
OIDC_OP_LOGOUT_ENDPOINT=https://${KEYCLOAK_HOST}/realms/${REALM_NAME}/protocol/openid-connect/logout
|
||||||
|
OIDC_RP_CLIENT_ID=<client_id>
|
||||||
|
OIDC_RP_CLIENT_SECRET=<client secret>
|
||||||
|
OIDC_RP_SIGN_ALGO=RS256
|
||||||
|
OIDC_RP_SCOPES="openid email"
|
||||||
|
#USER_OIDC_FIELD_TO_SHORTNAME
|
||||||
|
#USER_OIDC_FIELDS_TO_FULLNAME
|
||||||
|
|
||||||
|
LOGIN_REDIRECT_URL=https://${DOCS_HOST}
|
||||||
|
LOGIN_REDIRECT_URL_FAILURE=https://${DOCS_HOST}
|
||||||
|
LOGOUT_REDIRECT_URL=https://${DOCS_HOST}
|
||||||
|
|
||||||
|
OIDC_REDIRECT_ALLOWED_HOSTS=["https://${DOCS_HOST}"]
|
||||||
|
|
||||||
|
# AI
|
||||||
|
#AI_FEATURE_ENABLED=true # is false by default
|
||||||
|
#AI_BASE_URL=https://openaiendpoint.com
|
||||||
|
#AI_API_KEY=<API key>
|
||||||
|
#AI_MODEL=<model used> e.g. llama
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
#FRONTEND_THEME=mytheme
|
||||||
|
#FRONTEND_CSS_URL=https://storage.yourdomain.tld/themes/custom.css
|
||||||
|
#FRONTEND_FOOTER_FEATURE_ENABLED=true
|
||||||
|
#FRONTEND_URL_JSON_FOOTER=https://docs.domain.tld/contents/footer-demo.json
|
||||||
9
env.d/production.dist/common
Normal file
9
env.d/production.dist/common
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
DOCS_HOST=docs.domain.tld
|
||||||
|
KEYCLOAK_HOST=id.domain.tld
|
||||||
|
S3_HOST=storage.domain.tld
|
||||||
|
BACKEND_HOST=backend
|
||||||
|
FRONTEND_HOST=frontend
|
||||||
|
YPROVIDER_HOST=y-provider
|
||||||
|
BUCKET_NAME=docs-media-storage
|
||||||
|
REALM_NAME=docs
|
||||||
|
#COLLABORATION_WS_URL=wss://${DOCS_HOST}/collaboration/ws/
|
||||||
13
env.d/production.dist/kc_postgresql
Normal file
13
env.d/production.dist/kc_postgresql
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Postgresql db container configuration
|
||||||
|
POSTGRES_DB=keycloak
|
||||||
|
POSTGRES_USER=keycloak
|
||||||
|
POSTGRES_PASSWORD=<generate postgres password>
|
||||||
|
PGDATA=/var/lib/postgresql/data/pgdata
|
||||||
|
|
||||||
|
# Keycloak postgresql configuration
|
||||||
|
KC_DB=postgres
|
||||||
|
KC_DB_SCHEMA=public
|
||||||
|
KC_DB_HOST=postgresql
|
||||||
|
KC_DB_NAME=${POSTGRES_DB}
|
||||||
|
KC_DB_USER=${POSTGRES_USER}
|
||||||
|
KC_DB_PASSWORD=${POSTGRES_PASSWORD}
|
||||||
8
env.d/production.dist/keycloak
Normal file
8
env.d/production.dist/keycloak
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# Keycloak admin user
|
||||||
|
KC_BOOTSTRAP_ADMIN_USERNAME=admin
|
||||||
|
KC_BOOTSTRAP_ADMIN_PASSWORD=<generate your password>
|
||||||
|
|
||||||
|
# Keycloak configuration
|
||||||
|
KC_HOSTNAME=https://id.yourdomain.tld # Change with your own URL
|
||||||
|
KC_PROXY_HEADERS=xforwarded # in this example we are running behind an nginx proxy
|
||||||
|
KC_HTTP_ENABLED=true # in this example we are running behind an nginx proxy
|
||||||
11
env.d/production.dist/postgresql
Normal file
11
env.d/production.dist/postgresql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# App database configuration
|
||||||
|
DB_HOST=postgresql
|
||||||
|
DB_NAME=docs
|
||||||
|
DB_USER=docs
|
||||||
|
DB_PASSWORD=<generate a secure password>
|
||||||
|
DB_PORT=5432
|
||||||
|
|
||||||
|
# Postgresql db container configuration
|
||||||
|
POSTGRES_DB=docs
|
||||||
|
POSTGRES_USER=docs
|
||||||
|
POSTGRES_PASSWORD=${DB_PASSWORD}
|
||||||
7
env.d/production.dist/yprovider
Normal file
7
env.d/production.dist/yprovider
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
Y_PROVIDER_API_BASE_URL=http://${YPROVIDER_HOST}:4444/api/
|
||||||
|
Y_PROVIDER_API_KEY=<generate a random key>
|
||||||
|
COLLABORATION_SERVER_SECRET=<generate a random key>
|
||||||
|
COLLABORATION_SERVER_ORIGIN=https://${DOCS_HOST}
|
||||||
|
COLLABORATION_API_URL=https://${DOCS_HOST}/collaboration/api/
|
||||||
|
COLLABORATION_BACKEND_BASE_URL=https://${DOCS_HOST}
|
||||||
|
COLLABORATION_LOGGING=true
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"extends": ["github>numerique-gouv/renovate-configuration"],
|
"extends": ["github>numerique-gouv/renovate-configuration"],
|
||||||
"dependencyDashboard": true,
|
"dependencyDashboard": true,
|
||||||
"labels": ["dependencies", "noChangeLog"],
|
"labels": ["dependencies", "noChangeLog", "automated"],
|
||||||
"packageRules": [
|
"packageRules": [
|
||||||
{
|
{
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
@@ -9,12 +9,6 @@
|
|||||||
"matchManagers": ["pep621"],
|
"matchManagers": ["pep621"],
|
||||||
"matchPackageNames": []
|
"matchPackageNames": []
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"groupName": "allowed django versions",
|
|
||||||
"matchManagers": ["pep621"],
|
|
||||||
"matchPackageNames": ["Django"],
|
|
||||||
"allowedVersions": "<5.2"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"groupName": "allowed redis versions",
|
"groupName": "allowed redis versions",
|
||||||
"matchManagers": ["pep621"],
|
"matchManagers": ["pep621"],
|
||||||
@@ -28,6 +22,7 @@
|
|||||||
"matchPackageNames": [
|
"matchPackageNames": [
|
||||||
"@hocuspocus/provider",
|
"@hocuspocus/provider",
|
||||||
"@hocuspocus/server",
|
"@hocuspocus/server",
|
||||||
|
"docx",
|
||||||
"eslint",
|
"eslint",
|
||||||
"fetch-mock",
|
"fetch-mock",
|
||||||
"node",
|
"node",
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ class ListDocumentFilter(DocumentFilter):
|
|||||||
is_creator_me = django_filters.BooleanFilter(
|
is_creator_me = django_filters.BooleanFilter(
|
||||||
method="filter_is_creator_me", label=_("Creator is me")
|
method="filter_is_creator_me", label=_("Creator is me")
|
||||||
)
|
)
|
||||||
|
is_masked = django_filters.BooleanFilter(
|
||||||
|
method="filter_is_masked", label=_("Masked")
|
||||||
|
)
|
||||||
is_favorite = django_filters.BooleanFilter(
|
is_favorite = django_filters.BooleanFilter(
|
||||||
method="filter_is_favorite", label=_("Favorite")
|
method="filter_is_favorite", label=_("Favorite")
|
||||||
)
|
)
|
||||||
@@ -106,3 +109,22 @@ class ListDocumentFilter(DocumentFilter):
|
|||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
return queryset.filter(is_favorite=bool(value))
|
return queryset.filter(is_favorite=bool(value))
|
||||||
|
|
||||||
|
# pylint: disable=unused-argument
|
||||||
|
def filter_is_masked(self, queryset, name, value):
|
||||||
|
"""
|
||||||
|
Filter documents based on whether they are masked by the current user.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
- /api/v1.0/documents/?is_masked=true
|
||||||
|
→ Filters documents marked as masked by the logged-in user
|
||||||
|
- /api/v1.0/documents/?is_masked=false
|
||||||
|
→ Filters documents not marked as masked by the logged-in user
|
||||||
|
"""
|
||||||
|
user = self.request.user
|
||||||
|
|
||||||
|
if not user.is_authenticated:
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
queryset_method = queryset.filter if bool(value) else queryset.exclude
|
||||||
|
return queryset_method(link_traces__user=user, link_traces__is_masked=True)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from django.http import Http404
|
|||||||
|
|
||||||
from rest_framework import permissions
|
from rest_framework import permissions
|
||||||
|
|
||||||
|
from core import choices
|
||||||
from core.models import DocumentAccess, RoleChoices, get_trashbin_cutoff
|
from core.models import DocumentAccess, RoleChoices, get_trashbin_cutoff
|
||||||
|
|
||||||
ACTION_FOR_METHOD_TO_PERMISSION = {
|
ACTION_FOR_METHOD_TO_PERMISSION = {
|
||||||
@@ -96,26 +97,27 @@ class CanCreateInvitationPermission(permissions.BasePermission):
|
|||||||
).exists()
|
).exists()
|
||||||
|
|
||||||
|
|
||||||
class AccessPermission(permissions.BasePermission):
|
class ResourceWithAccessPermission(permissions.BasePermission):
|
||||||
"""Permission class for access objects."""
|
"""A permission class for templates and invitations."""
|
||||||
|
|
||||||
def has_permission(self, request, view):
|
def has_permission(self, request, view):
|
||||||
|
"""check create permission for templates."""
|
||||||
return request.user.is_authenticated or view.action != "create"
|
return request.user.is_authenticated or view.action != "create"
|
||||||
|
|
||||||
def has_object_permission(self, request, view, obj):
|
def has_object_permission(self, request, view, obj):
|
||||||
"""Check permission for a given object."""
|
"""Check permission for a given object."""
|
||||||
abilities = obj.get_abilities(request.user)
|
abilities = obj.get_abilities(request.user)
|
||||||
action = view.action
|
action = view.action
|
||||||
try:
|
|
||||||
action = ACTION_FOR_METHOD_TO_PERMISSION[view.action][request.method]
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
return abilities.get(action, False)
|
return abilities.get(action, False)
|
||||||
|
|
||||||
|
|
||||||
class DocumentAccessPermission(AccessPermission):
|
class DocumentPermission(permissions.BasePermission):
|
||||||
"""Subclass to handle soft deletion specificities."""
|
"""Subclass to handle soft deletion specificities."""
|
||||||
|
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
"""check create permission for documents."""
|
||||||
|
return request.user.is_authenticated or view.action != "create"
|
||||||
|
|
||||||
def has_object_permission(self, request, view, obj):
|
def has_object_permission(self, request, view, obj):
|
||||||
"""
|
"""
|
||||||
Return a 404 on deleted documents
|
Return a 404 on deleted documents
|
||||||
@@ -127,10 +129,45 @@ class DocumentAccessPermission(AccessPermission):
|
|||||||
) and deleted_at < get_trashbin_cutoff():
|
) and deleted_at < get_trashbin_cutoff():
|
||||||
raise Http404
|
raise Http404
|
||||||
|
|
||||||
# Compute permission first to ensure the "user_roles" attribute is set
|
abilities = obj.get_abilities(request.user)
|
||||||
has_permission = super().has_object_permission(request, view, obj)
|
action = view.action
|
||||||
|
try:
|
||||||
|
action = ACTION_FOR_METHOD_TO_PERMISSION[view.action][request.method]
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
has_permission = abilities.get(action, False)
|
||||||
|
|
||||||
if obj.ancestors_deleted_at and not RoleChoices.OWNER in obj.user_roles:
|
if obj.ancestors_deleted_at and not RoleChoices.OWNER in obj.user_roles:
|
||||||
raise Http404
|
raise Http404
|
||||||
|
|
||||||
return has_permission
|
return has_permission
|
||||||
|
|
||||||
|
|
||||||
|
class ResourceAccessPermission(IsAuthenticated):
|
||||||
|
"""Permission class for document access objects."""
|
||||||
|
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
"""check create permission for accesses in documents tree."""
|
||||||
|
if super().has_permission(request, view) is False:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if view.action == "create":
|
||||||
|
role = getattr(view, view.resource_field_name).get_role(request.user)
|
||||||
|
if role not in choices.PRIVILEGED_ROLES:
|
||||||
|
raise exceptions.PermissionDenied(
|
||||||
|
"You are not allowed to manage accesses for this resource."
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def has_object_permission(self, request, view, obj):
|
||||||
|
"""Check permission for a given object."""
|
||||||
|
abilities = obj.get_abilities(request.user)
|
||||||
|
|
||||||
|
requested_role = request.data.get("role")
|
||||||
|
if requested_role and requested_role not in abilities.get("set_role_to", []):
|
||||||
|
return False
|
||||||
|
|
||||||
|
action = view.action
|
||||||
|
return abilities.get(action, False)
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ from django.utils.functional import lazy
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
import magic
|
import magic
|
||||||
from rest_framework import exceptions, serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from core import enums, models, utils
|
from core import choices, enums, models, utils
|
||||||
from core.services.ai_services import AI_ACTIONS
|
from core.services.ai_services import AI_ACTIONS
|
||||||
from core.services.converter_services import (
|
from core.services.converter_services import (
|
||||||
ConversionError,
|
ConversionError,
|
||||||
@@ -32,134 +32,35 @@ class UserSerializer(serializers.ModelSerializer):
|
|||||||
class UserLightSerializer(UserSerializer):
|
class UserLightSerializer(UserSerializer):
|
||||||
"""Serialize users with limited fields."""
|
"""Serialize users with limited fields."""
|
||||||
|
|
||||||
id = serializers.SerializerMethodField(read_only=True)
|
|
||||||
email = serializers.SerializerMethodField(read_only=True)
|
|
||||||
|
|
||||||
def get_id(self, _user):
|
|
||||||
"""Return always None. Here to have the same fields than in UserSerializer."""
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_email(self, _user):
|
|
||||||
"""Return always None. Here to have the same fields than in UserSerializer."""
|
|
||||||
return None
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.User
|
model = models.User
|
||||||
fields = ["id", "email", "full_name", "short_name"]
|
fields = ["full_name", "short_name"]
|
||||||
read_only_fields = ["id", "email", "full_name", "short_name"]
|
read_only_fields = ["full_name", "short_name"]
|
||||||
|
|
||||||
|
|
||||||
class BaseAccessSerializer(serializers.ModelSerializer):
|
class TemplateAccessSerializer(serializers.ModelSerializer):
|
||||||
"""Serialize template accesses."""
|
"""Serialize template accesses."""
|
||||||
|
|
||||||
abilities = serializers.SerializerMethodField(read_only=True)
|
abilities = serializers.SerializerMethodField(read_only=True)
|
||||||
|
|
||||||
def update(self, instance, validated_data):
|
|
||||||
"""Make "user" field is readonly but only on update."""
|
|
||||||
validated_data.pop("user", None)
|
|
||||||
return super().update(instance, validated_data)
|
|
||||||
|
|
||||||
def get_abilities(self, access) -> dict:
|
|
||||||
"""Return abilities of the logged-in user on the instance."""
|
|
||||||
request = self.context.get("request")
|
|
||||||
if request:
|
|
||||||
return access.get_abilities(request.user)
|
|
||||||
return {}
|
|
||||||
|
|
||||||
def validate(self, attrs):
|
|
||||||
"""
|
|
||||||
Check access rights specific to writing (create/update)
|
|
||||||
"""
|
|
||||||
request = self.context.get("request")
|
|
||||||
user = getattr(request, "user", None)
|
|
||||||
role = attrs.get("role")
|
|
||||||
|
|
||||||
# Update
|
|
||||||
if self.instance:
|
|
||||||
can_set_role_to = self.instance.get_abilities(user)["set_role_to"]
|
|
||||||
|
|
||||||
if role and role not in can_set_role_to:
|
|
||||||
message = (
|
|
||||||
f"You are only allowed to set role to {', '.join(can_set_role_to)}"
|
|
||||||
if can_set_role_to
|
|
||||||
else "You are not allowed to set this role for this template."
|
|
||||||
)
|
|
||||||
raise exceptions.PermissionDenied(message)
|
|
||||||
|
|
||||||
# Create
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
resource_id = self.context["resource_id"]
|
|
||||||
except KeyError as exc:
|
|
||||||
raise exceptions.ValidationError(
|
|
||||||
"You must set a resource ID in kwargs to create a new access."
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
if not self.Meta.model.objects.filter( # pylint: disable=no-member
|
|
||||||
Q(user=user) | Q(team__in=user.teams),
|
|
||||||
role__in=[models.RoleChoices.OWNER, models.RoleChoices.ADMIN],
|
|
||||||
**{self.Meta.resource_field_name: resource_id}, # pylint: disable=no-member
|
|
||||||
).exists():
|
|
||||||
raise exceptions.PermissionDenied(
|
|
||||||
"You are not allowed to manage accesses for this resource."
|
|
||||||
)
|
|
||||||
|
|
||||||
if (
|
|
||||||
role == models.RoleChoices.OWNER
|
|
||||||
and not self.Meta.model.objects.filter( # pylint: disable=no-member
|
|
||||||
Q(user=user) | Q(team__in=user.teams),
|
|
||||||
role=models.RoleChoices.OWNER,
|
|
||||||
**{self.Meta.resource_field_name: resource_id}, # pylint: disable=no-member
|
|
||||||
).exists()
|
|
||||||
):
|
|
||||||
raise exceptions.PermissionDenied(
|
|
||||||
"Only owners of a resource can assign other users as owners."
|
|
||||||
)
|
|
||||||
|
|
||||||
# pylint: disable=no-member
|
|
||||||
attrs[f"{self.Meta.resource_field_name}_id"] = self.context["resource_id"]
|
|
||||||
return attrs
|
|
||||||
|
|
||||||
|
|
||||||
class DocumentAccessSerializer(BaseAccessSerializer):
|
|
||||||
"""Serialize document accesses."""
|
|
||||||
|
|
||||||
user_id = serializers.PrimaryKeyRelatedField(
|
|
||||||
queryset=models.User.objects.all(),
|
|
||||||
write_only=True,
|
|
||||||
source="user",
|
|
||||||
required=False,
|
|
||||||
allow_null=True,
|
|
||||||
)
|
|
||||||
user = UserSerializer(read_only=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = models.DocumentAccess
|
|
||||||
resource_field_name = "document"
|
|
||||||
fields = ["id", "user", "user_id", "team", "role", "abilities"]
|
|
||||||
read_only_fields = ["id", "abilities"]
|
|
||||||
|
|
||||||
|
|
||||||
class DocumentAccessLightSerializer(DocumentAccessSerializer):
|
|
||||||
"""Serialize document accesses with limited fields."""
|
|
||||||
|
|
||||||
user = UserLightSerializer(read_only=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = models.DocumentAccess
|
|
||||||
fields = ["id", "user", "team", "role", "abilities"]
|
|
||||||
read_only_fields = ["id", "team", "role", "abilities"]
|
|
||||||
|
|
||||||
|
|
||||||
class TemplateAccessSerializer(BaseAccessSerializer):
|
|
||||||
"""Serialize template accesses."""
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.TemplateAccess
|
model = models.TemplateAccess
|
||||||
resource_field_name = "template"
|
resource_field_name = "template"
|
||||||
fields = ["id", "user", "team", "role", "abilities"]
|
fields = ["id", "user", "team", "role", "abilities"]
|
||||||
read_only_fields = ["id", "abilities"]
|
read_only_fields = ["id", "abilities"]
|
||||||
|
|
||||||
|
def get_abilities(self, instance) -> dict:
|
||||||
|
"""Return abilities of the logged-in user on the instance."""
|
||||||
|
request = self.context.get("request")
|
||||||
|
if request:
|
||||||
|
return instance.get_abilities(request.user)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def update(self, instance, validated_data):
|
||||||
|
"""Make "user" field is readonly but only on update."""
|
||||||
|
validated_data.pop("user", None)
|
||||||
|
return super().update(instance, validated_data)
|
||||||
|
|
||||||
|
|
||||||
class ListDocumentSerializer(serializers.ModelSerializer):
|
class ListDocumentSerializer(serializers.ModelSerializer):
|
||||||
"""Serialize documents with limited fields for display in lists."""
|
"""Serialize documents with limited fields for display in lists."""
|
||||||
@@ -167,7 +68,7 @@ class ListDocumentSerializer(serializers.ModelSerializer):
|
|||||||
is_favorite = serializers.BooleanField(read_only=True)
|
is_favorite = serializers.BooleanField(read_only=True)
|
||||||
nb_accesses_ancestors = serializers.IntegerField(read_only=True)
|
nb_accesses_ancestors = serializers.IntegerField(read_only=True)
|
||||||
nb_accesses_direct = serializers.IntegerField(read_only=True)
|
nb_accesses_direct = serializers.IntegerField(read_only=True)
|
||||||
user_roles = serializers.SerializerMethodField(read_only=True)
|
user_role = serializers.SerializerMethodField(read_only=True)
|
||||||
abilities = serializers.SerializerMethodField(read_only=True)
|
abilities = serializers.SerializerMethodField(read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@@ -175,6 +76,10 @@ class ListDocumentSerializer(serializers.ModelSerializer):
|
|||||||
fields = [
|
fields = [
|
||||||
"id",
|
"id",
|
||||||
"abilities",
|
"abilities",
|
||||||
|
"ancestors_link_reach",
|
||||||
|
"ancestors_link_role",
|
||||||
|
"computed_link_reach",
|
||||||
|
"computed_link_role",
|
||||||
"created_at",
|
"created_at",
|
||||||
"creator",
|
"creator",
|
||||||
"depth",
|
"depth",
|
||||||
@@ -188,11 +93,15 @@ class ListDocumentSerializer(serializers.ModelSerializer):
|
|||||||
"path",
|
"path",
|
||||||
"title",
|
"title",
|
||||||
"updated_at",
|
"updated_at",
|
||||||
"user_roles",
|
"user_role",
|
||||||
]
|
]
|
||||||
read_only_fields = [
|
read_only_fields = [
|
||||||
"id",
|
"id",
|
||||||
"abilities",
|
"abilities",
|
||||||
|
"ancestors_link_reach",
|
||||||
|
"ancestors_link_role",
|
||||||
|
"computed_link_reach",
|
||||||
|
"computed_link_role",
|
||||||
"created_at",
|
"created_at",
|
||||||
"creator",
|
"creator",
|
||||||
"depth",
|
"depth",
|
||||||
@@ -205,46 +114,62 @@ class ListDocumentSerializer(serializers.ModelSerializer):
|
|||||||
"numchild",
|
"numchild",
|
||||||
"path",
|
"path",
|
||||||
"updated_at",
|
"updated_at",
|
||||||
"user_roles",
|
"user_role",
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_abilities(self, document) -> dict:
|
def to_representation(self, instance):
|
||||||
|
"""Precompute once per instance"""
|
||||||
|
paths_links_mapping = self.context.get("paths_links_mapping")
|
||||||
|
|
||||||
|
if paths_links_mapping is not None:
|
||||||
|
links = paths_links_mapping.get(instance.path[: -instance.steplen], [])
|
||||||
|
instance.ancestors_link_definition = choices.get_equivalent_link_definition(
|
||||||
|
links
|
||||||
|
)
|
||||||
|
|
||||||
|
return super().to_representation(instance)
|
||||||
|
|
||||||
|
def get_abilities(self, instance) -> dict:
|
||||||
"""Return abilities of the logged-in user on the instance."""
|
"""Return abilities of the logged-in user on the instance."""
|
||||||
request = self.context.get("request")
|
request = self.context.get("request")
|
||||||
|
if not request:
|
||||||
|
return {}
|
||||||
|
|
||||||
if request:
|
return instance.get_abilities(request.user)
|
||||||
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_role(self, instance):
|
||||||
|
|
||||||
def get_user_roles(self, document):
|
|
||||||
"""
|
"""
|
||||||
Return roles of the logged-in user for the current document,
|
Return roles of the logged-in user for the current document,
|
||||||
taking into account ancestors.
|
taking into account ancestors.
|
||||||
"""
|
"""
|
||||||
request = self.context.get("request")
|
request = self.context.get("request")
|
||||||
if request:
|
return instance.get_role(request.user) if request else None
|
||||||
return document.get_roles(request.user)
|
|
||||||
return []
|
|
||||||
|
class DocumentLightSerializer(serializers.ModelSerializer):
|
||||||
|
"""Minial document serializer for nesting in document accesses."""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = models.Document
|
||||||
|
fields = ["id", "path", "depth"]
|
||||||
|
read_only_fields = ["id", "path", "depth"]
|
||||||
|
|
||||||
|
|
||||||
class DocumentSerializer(ListDocumentSerializer):
|
class DocumentSerializer(ListDocumentSerializer):
|
||||||
"""Serialize documents with all fields for display in detail views."""
|
"""Serialize documents with all fields for display in detail views."""
|
||||||
|
|
||||||
content = serializers.CharField(required=False)
|
content = serializers.CharField(required=False)
|
||||||
|
websocket = serializers.BooleanField(required=False, write_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.Document
|
model = models.Document
|
||||||
fields = [
|
fields = [
|
||||||
"id",
|
"id",
|
||||||
"abilities",
|
"abilities",
|
||||||
|
"ancestors_link_reach",
|
||||||
|
"ancestors_link_role",
|
||||||
|
"computed_link_reach",
|
||||||
|
"computed_link_role",
|
||||||
"content",
|
"content",
|
||||||
"created_at",
|
"created_at",
|
||||||
"creator",
|
"creator",
|
||||||
@@ -259,11 +184,16 @@ class DocumentSerializer(ListDocumentSerializer):
|
|||||||
"path",
|
"path",
|
||||||
"title",
|
"title",
|
||||||
"updated_at",
|
"updated_at",
|
||||||
"user_roles",
|
"user_role",
|
||||||
|
"websocket",
|
||||||
]
|
]
|
||||||
read_only_fields = [
|
read_only_fields = [
|
||||||
"id",
|
"id",
|
||||||
"abilities",
|
"abilities",
|
||||||
|
"ancestors_link_reach",
|
||||||
|
"ancestors_link_role",
|
||||||
|
"computed_link_reach",
|
||||||
|
"computed_link_role",
|
||||||
"created_at",
|
"created_at",
|
||||||
"creator",
|
"creator",
|
||||||
"depth",
|
"depth",
|
||||||
@@ -275,7 +205,7 @@ class DocumentSerializer(ListDocumentSerializer):
|
|||||||
"numchild",
|
"numchild",
|
||||||
"path",
|
"path",
|
||||||
"updated_at",
|
"updated_at",
|
||||||
"user_roles",
|
"user_role",
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_fields(self):
|
def get_fields(self):
|
||||||
@@ -361,6 +291,99 @@ class DocumentSerializer(ListDocumentSerializer):
|
|||||||
return super().save(**kwargs)
|
return super().save(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentAccessSerializer(serializers.ModelSerializer):
|
||||||
|
"""Serialize document accesses."""
|
||||||
|
|
||||||
|
document = DocumentLightSerializer(read_only=True)
|
||||||
|
user_id = serializers.PrimaryKeyRelatedField(
|
||||||
|
queryset=models.User.objects.all(),
|
||||||
|
write_only=True,
|
||||||
|
source="user",
|
||||||
|
required=False,
|
||||||
|
allow_null=True,
|
||||||
|
)
|
||||||
|
user = UserSerializer(read_only=True)
|
||||||
|
team = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
abilities = serializers.SerializerMethodField(read_only=True)
|
||||||
|
max_ancestors_role = serializers.SerializerMethodField(read_only=True)
|
||||||
|
max_role = serializers.SerializerMethodField(read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = models.DocumentAccess
|
||||||
|
resource_field_name = "document"
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"document",
|
||||||
|
"user",
|
||||||
|
"user_id",
|
||||||
|
"team",
|
||||||
|
"role",
|
||||||
|
"abilities",
|
||||||
|
"max_ancestors_role",
|
||||||
|
"max_role",
|
||||||
|
]
|
||||||
|
read_only_fields = [
|
||||||
|
"id",
|
||||||
|
"document",
|
||||||
|
"abilities",
|
||||||
|
"max_ancestors_role",
|
||||||
|
"max_role",
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_abilities(self, instance) -> dict:
|
||||||
|
"""Return abilities of the logged-in user on the instance."""
|
||||||
|
request = self.context.get("request")
|
||||||
|
if request:
|
||||||
|
return instance.get_abilities(request.user)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def get_max_ancestors_role(self, instance):
|
||||||
|
"""Return max_ancestors_role if annotated; else None."""
|
||||||
|
return getattr(instance, "max_ancestors_role", None)
|
||||||
|
|
||||||
|
def get_max_role(self, instance):
|
||||||
|
"""Return max_ancestors_role if annotated; else None."""
|
||||||
|
return choices.RoleChoices.max(
|
||||||
|
getattr(instance, "max_ancestors_role", None),
|
||||||
|
instance.role,
|
||||||
|
)
|
||||||
|
|
||||||
|
def update(self, instance, validated_data):
|
||||||
|
"""Make "user" field readonly but only on update."""
|
||||||
|
validated_data.pop("team", None)
|
||||||
|
validated_data.pop("user", None)
|
||||||
|
return super().update(instance, validated_data)
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentAccessLightSerializer(DocumentAccessSerializer):
|
||||||
|
"""Serialize document accesses with limited fields."""
|
||||||
|
|
||||||
|
user = UserLightSerializer(read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = models.DocumentAccess
|
||||||
|
resource_field_name = "document"
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"document",
|
||||||
|
"user",
|
||||||
|
"team",
|
||||||
|
"role",
|
||||||
|
"abilities",
|
||||||
|
"max_ancestors_role",
|
||||||
|
"max_role",
|
||||||
|
]
|
||||||
|
read_only_fields = [
|
||||||
|
"id",
|
||||||
|
"document",
|
||||||
|
"team",
|
||||||
|
"role",
|
||||||
|
"abilities",
|
||||||
|
"max_ancestors_role",
|
||||||
|
"max_role",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class ServerCreateDocumentSerializer(serializers.Serializer):
|
class ServerCreateDocumentSerializer(serializers.Serializer):
|
||||||
"""
|
"""
|
||||||
Serializer for creating a document from a server-to-server request.
|
Serializer for creating a document from a server-to-server request.
|
||||||
@@ -408,9 +431,7 @@ class ServerCreateDocumentSerializer(serializers.Serializer):
|
|||||||
language = user.language or language
|
language = user.language or language
|
||||||
|
|
||||||
try:
|
try:
|
||||||
document_content = YdocConverter().convert_markdown(
|
document_content = YdocConverter().convert(validated_data["content"])
|
||||||
validated_data["content"]
|
|
||||||
)
|
|
||||||
except ConversionError as err:
|
except ConversionError as err:
|
||||||
raise serializers.ValidationError(
|
raise serializers.ValidationError(
|
||||||
{"content": ["Could not convert content"]}
|
{"content": ["Could not convert content"]}
|
||||||
@@ -517,16 +538,17 @@ class FileUploadSerializer(serializers.Serializer):
|
|||||||
mime = magic.Magic(mime=True)
|
mime = magic.Magic(mime=True)
|
||||||
magic_mime_type = mime.from_buffer(file.read(1024))
|
magic_mime_type = mime.from_buffer(file.read(1024))
|
||||||
file.seek(0) # Reset file pointer to the beginning after reading
|
file.seek(0) # Reset file pointer to the beginning after reading
|
||||||
|
self.context["is_unsafe"] = False
|
||||||
|
if settings.DOCUMENT_ATTACHMENT_CHECK_UNSAFE_MIME_TYPES_ENABLED:
|
||||||
|
self.context["is_unsafe"] = (
|
||||||
|
magic_mime_type in settings.DOCUMENT_UNSAFE_MIME_TYPES
|
||||||
|
)
|
||||||
|
|
||||||
self.context["is_unsafe"] = (
|
extension_mime_type, _ = mimetypes.guess_type(file.name)
|
||||||
magic_mime_type in settings.DOCUMENT_UNSAFE_MIME_TYPES
|
|
||||||
)
|
|
||||||
|
|
||||||
extension_mime_type, _ = mimetypes.guess_type(file.name)
|
# Try guessing a coherent extension from the mimetype
|
||||||
|
if extension_mime_type != magic_mime_type:
|
||||||
# Try guessing a coherent extension from the mimetype
|
self.context["is_unsafe"] = True
|
||||||
if extension_mime_type != magic_mime_type:
|
|
||||||
self.context["is_unsafe"] = True
|
|
||||||
|
|
||||||
guessed_ext = mimetypes.guess_extension(magic_mime_type)
|
guessed_ext = mimetypes.guess_extension(magic_mime_type)
|
||||||
# Missing extensions or extensions longer than 5 characters (it's as long as an extension
|
# Missing extensions or extensions longer than 5 characters (it's as long as an extension
|
||||||
@@ -664,6 +686,50 @@ class InvitationSerializer(serializers.ModelSerializer):
|
|||||||
return role
|
return role
|
||||||
|
|
||||||
|
|
||||||
|
class RoleSerializer(serializers.Serializer):
|
||||||
|
"""Serializer validating role choices."""
|
||||||
|
|
||||||
|
role = serializers.ChoiceField(
|
||||||
|
choices=models.RoleChoices.choices, required=False, allow_null=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentAskForAccessCreateSerializer(serializers.Serializer):
|
||||||
|
"""Serializer for creating a document ask for access."""
|
||||||
|
|
||||||
|
role = serializers.ChoiceField(
|
||||||
|
choices=models.RoleChoices.choices,
|
||||||
|
required=False,
|
||||||
|
default=models.RoleChoices.READER,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentAskForAccessSerializer(serializers.ModelSerializer):
|
||||||
|
"""Serializer for document ask for access model"""
|
||||||
|
|
||||||
|
abilities = serializers.SerializerMethodField(read_only=True)
|
||||||
|
user = UserSerializer(read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = models.DocumentAskForAccess
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"document",
|
||||||
|
"user",
|
||||||
|
"role",
|
||||||
|
"created_at",
|
||||||
|
"abilities",
|
||||||
|
]
|
||||||
|
read_only_fields = ["id", "document", "user", "role", "created_at", "abilities"]
|
||||||
|
|
||||||
|
def get_abilities(self, invitation) -> dict:
|
||||||
|
"""Return abilities of the logged-in user on the instance."""
|
||||||
|
request = self.context.get("request")
|
||||||
|
if request:
|
||||||
|
return invitation.get_abilities(request.user)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
class VersionFilterSerializer(serializers.Serializer):
|
class VersionFilterSerializer(serializers.Serializer):
|
||||||
"""Validate version filters applied to the list endpoint."""
|
"""Validate version filters applied to the list endpoint."""
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
115
src/backend/core/choices.py
Normal file
115
src/backend/core/choices.py
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
"""Declare and configure choices for Docs' core application."""
|
||||||
|
|
||||||
|
from django.db.models import TextChoices
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
|
||||||
|
class PriorityTextChoices(TextChoices):
|
||||||
|
"""
|
||||||
|
This class inherits from Django's TextChoices and provides a method to get the priority
|
||||||
|
of a given value based on its position in the class.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_priority(cls, role):
|
||||||
|
"""Returns the priority of the given role based on its order in the class."""
|
||||||
|
|
||||||
|
members = list(cls.__members__.values())
|
||||||
|
return members.index(role) + 1 if role in members else 0
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def max(cls, *roles):
|
||||||
|
"""
|
||||||
|
Return the highest-priority role among the given roles, using get_priority().
|
||||||
|
If no valid roles are provided, returns None.
|
||||||
|
"""
|
||||||
|
valid_roles = [role for role in roles if cls.get_priority(role) is not None]
|
||||||
|
if not valid_roles:
|
||||||
|
return None
|
||||||
|
return max(valid_roles, key=cls.get_priority)
|
||||||
|
|
||||||
|
|
||||||
|
class LinkRoleChoices(PriorityTextChoices):
|
||||||
|
"""Defines the possible roles a link can offer on a document."""
|
||||||
|
|
||||||
|
READER = "reader", _("Reader") # Can read
|
||||||
|
EDITOR = "editor", _("Editor") # Can read and edit
|
||||||
|
|
||||||
|
|
||||||
|
class RoleChoices(PriorityTextChoices):
|
||||||
|
"""Defines the possible roles a user can have in a resource."""
|
||||||
|
|
||||||
|
READER = "reader", _("Reader") # Can read
|
||||||
|
EDITOR = "editor", _("Editor") # Can read and edit
|
||||||
|
ADMIN = "administrator", _("Administrator") # Can read, edit, delete and share
|
||||||
|
OWNER = "owner", _("Owner")
|
||||||
|
|
||||||
|
|
||||||
|
PRIVILEGED_ROLES = [RoleChoices.ADMIN, RoleChoices.OWNER]
|
||||||
|
|
||||||
|
|
||||||
|
class LinkReachChoices(PriorityTextChoices):
|
||||||
|
"""Defines types of access for links"""
|
||||||
|
|
||||||
|
RESTRICTED = (
|
||||||
|
"restricted",
|
||||||
|
_("Restricted"),
|
||||||
|
) # Only users with a specific access can read/edit the document
|
||||||
|
AUTHENTICATED = (
|
||||||
|
"authenticated",
|
||||||
|
_("Authenticated"),
|
||||||
|
) # Any authenticated user can access the document
|
||||||
|
PUBLIC = "public", _("Public") # Even anonymous users can access the document
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_select_options(cls, link_reach, link_role):
|
||||||
|
"""
|
||||||
|
Determines the valid select options for link reach and link role depending on the
|
||||||
|
ancestors' link reach/role given as arguments.
|
||||||
|
Returns:
|
||||||
|
Dictionary mapping possible reach levels to their corresponding possible roles.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
reach: [
|
||||||
|
role
|
||||||
|
for role in LinkRoleChoices.values
|
||||||
|
if LinkRoleChoices.get_priority(role)
|
||||||
|
>= LinkRoleChoices.get_priority(link_role)
|
||||||
|
]
|
||||||
|
if reach != cls.RESTRICTED
|
||||||
|
else None
|
||||||
|
for reach in cls.values
|
||||||
|
if LinkReachChoices.get_priority(reach)
|
||||||
|
>= LinkReachChoices.get_priority(link_reach)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_equivalent_link_definition(ancestors_links):
|
||||||
|
"""
|
||||||
|
Return the (reach, role) pair with:
|
||||||
|
1. Highest reach
|
||||||
|
2. Highest role among links having that reach
|
||||||
|
"""
|
||||||
|
if not ancestors_links:
|
||||||
|
return {"link_reach": None, "link_role": None}
|
||||||
|
|
||||||
|
# 1) Find the highest reach
|
||||||
|
max_reach = max(
|
||||||
|
ancestors_links,
|
||||||
|
key=lambda link: LinkReachChoices.get_priority(link["link_reach"]),
|
||||||
|
)["link_reach"]
|
||||||
|
|
||||||
|
# 2) Among those, find the highest role (ignore role if RESTRICTED)
|
||||||
|
if max_reach == LinkReachChoices.RESTRICTED:
|
||||||
|
max_role = None
|
||||||
|
else:
|
||||||
|
max_role = max(
|
||||||
|
(
|
||||||
|
link["link_role"]
|
||||||
|
for link in ancestors_links
|
||||||
|
if link["link_reach"] == max_reach
|
||||||
|
),
|
||||||
|
key=LinkRoleChoices.get_priority,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"link_reach": max_reach, "link_role": max_role}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
# ruff: noqa: S311
|
|
||||||
"""
|
"""
|
||||||
Core application factories
|
Core application factories
|
||||||
"""
|
"""
|
||||||
@@ -35,6 +34,8 @@ class UserFactory(factory.django.DjangoModelFactory):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.User
|
model = models.User
|
||||||
|
# Skip postgeneration save, no save is made in the postgeneration methods.
|
||||||
|
skip_postgeneration_save = True
|
||||||
|
|
||||||
sub = factory.Sequence(lambda n: f"user{n!s}")
|
sub = factory.Sequence(lambda n: f"user{n!s}")
|
||||||
email = factory.Faker("email")
|
email = factory.Faker("email")
|
||||||
@@ -149,7 +150,7 @@ class DocumentFactory(factory.django.DjangoModelFactory):
|
|||||||
"""Add link traces to document from a given list of users."""
|
"""Add link traces to document from a given list of users."""
|
||||||
if create and extracted:
|
if create and extracted:
|
||||||
for item in extracted:
|
for item in extracted:
|
||||||
models.LinkTrace.objects.create(document=self, user=item)
|
models.LinkTrace.objects.update_or_create(document=self, user=item)
|
||||||
|
|
||||||
@factory.post_generation
|
@factory.post_generation
|
||||||
def favorited_by(self, create, extracted, **kwargs):
|
def favorited_by(self, create, extracted, **kwargs):
|
||||||
@@ -158,6 +159,15 @@ class DocumentFactory(factory.django.DjangoModelFactory):
|
|||||||
for item in extracted:
|
for item in extracted:
|
||||||
models.DocumentFavorite.objects.create(document=self, user=item)
|
models.DocumentFavorite.objects.create(document=self, user=item)
|
||||||
|
|
||||||
|
@factory.post_generation
|
||||||
|
def masked_by(self, create, extracted, **kwargs):
|
||||||
|
"""Mark document as masked by a list of users."""
|
||||||
|
if create and extracted:
|
||||||
|
for item in extracted:
|
||||||
|
models.LinkTrace.objects.update_or_create(
|
||||||
|
document=self, user=item, defaults={"is_masked": True}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class UserDocumentAccessFactory(factory.django.DjangoModelFactory):
|
class UserDocumentAccessFactory(factory.django.DjangoModelFactory):
|
||||||
"""Create fake document user accesses for testing."""
|
"""Create fake document user accesses for testing."""
|
||||||
@@ -181,6 +191,17 @@ class TeamDocumentAccessFactory(factory.django.DjangoModelFactory):
|
|||||||
role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices])
|
role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices])
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentAskForAccessFactory(factory.django.DjangoModelFactory):
|
||||||
|
"""Create fake document ask for access for testing."""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = models.DocumentAskForAccess
|
||||||
|
|
||||||
|
document = factory.SubFactory(DocumentFactory)
|
||||||
|
user = factory.SubFactory(UserFactory)
|
||||||
|
role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices])
|
||||||
|
|
||||||
|
|
||||||
class TemplateFactory(factory.django.DjangoModelFactory):
|
class TemplateFactory(factory.django.DjangoModelFactory):
|
||||||
"""A factory to create templates"""
|
"""A factory to create templates"""
|
||||||
|
|
||||||
|
|||||||
21
src/backend/core/middleware.py
Normal file
21
src/backend/core/middleware.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
"""Force session creation for all requests."""
|
||||||
|
|
||||||
|
|
||||||
|
class ForceSessionMiddleware:
|
||||||
|
"""
|
||||||
|
Force session creation for unauthenticated users.
|
||||||
|
Must be used after Authentication middleware.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, get_response):
|
||||||
|
"""Initialize the middleware."""
|
||||||
|
self.get_response = get_response
|
||||||
|
|
||||||
|
def __call__(self, request):
|
||||||
|
"""Force session creation for unauthenticated users."""
|
||||||
|
|
||||||
|
if not request.user.is_authenticated and request.session.session_key is None:
|
||||||
|
request.session.create()
|
||||||
|
|
||||||
|
response = self.get_response(request)
|
||||||
|
return response
|
||||||
@@ -504,7 +504,7 @@ class Migration(migrations.Migration):
|
|||||||
migrations.AddConstraint(
|
migrations.AddConstraint(
|
||||||
model_name="documentaccess",
|
model_name="documentaccess",
|
||||||
constraint=models.CheckConstraint(
|
constraint=models.CheckConstraint(
|
||||||
check=models.Q(
|
condition=models.Q(
|
||||||
models.Q(("team", ""), ("user__isnull", False)),
|
models.Q(("team", ""), ("user__isnull", False)),
|
||||||
models.Q(("team__gt", ""), ("user__isnull", True)),
|
models.Q(("team__gt", ""), ("user__isnull", True)),
|
||||||
_connector="OR",
|
_connector="OR",
|
||||||
@@ -540,7 +540,7 @@ class Migration(migrations.Migration):
|
|||||||
migrations.AddConstraint(
|
migrations.AddConstraint(
|
||||||
model_name="templateaccess",
|
model_name="templateaccess",
|
||||||
constraint=models.CheckConstraint(
|
constraint=models.CheckConstraint(
|
||||||
check=models.Q(
|
condition=models.Q(
|
||||||
models.Q(("team", ""), ("user__isnull", False)),
|
models.Q(("team", ""), ("user__isnull", False)),
|
||||||
models.Q(("team__gt", ""), ("user__isnull", True)),
|
models.Q(("team__gt", ""), ("user__isnull", True)),
|
||||||
_connector="OR",
|
_connector="OR",
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# Generated by Django 5.2.3 on 2025-06-18 10:02
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("core", "0021_activate_unaccent_extension"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="DocumentAskForAccess",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.UUIDField(
|
||||||
|
default=uuid.uuid4,
|
||||||
|
editable=False,
|
||||||
|
help_text="primary key for the record as UUID",
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="id",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"created_at",
|
||||||
|
models.DateTimeField(
|
||||||
|
auto_now_add=True,
|
||||||
|
help_text="date and time at which a record was created",
|
||||||
|
verbose_name="created on",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"updated_at",
|
||||||
|
models.DateTimeField(
|
||||||
|
auto_now=True,
|
||||||
|
help_text="date and time at which a record was last updated",
|
||||||
|
verbose_name="updated on",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"role",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("reader", "Reader"),
|
||||||
|
("editor", "Editor"),
|
||||||
|
("administrator", "Administrator"),
|
||||||
|
("owner", "Owner"),
|
||||||
|
],
|
||||||
|
default="reader",
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"document",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="ask_for_accesses",
|
||||||
|
to="core.document",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"user",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="ask_for_accesses",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Document ask for access",
|
||||||
|
"verbose_name_plural": "Document ask for accesses",
|
||||||
|
"db_table": "impress_document_ask_for_access",
|
||||||
|
"constraints": [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=("user", "document"),
|
||||||
|
name="unique_document_ask_for_access_user",
|
||||||
|
violation_error_message="This user has already asked for access to this document.",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 5.1.7 on 2025-03-14 14:03
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("core", "0022_alter_user_language_documentaskforaccess"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="document",
|
||||||
|
name="has_deleted_children",
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Generated by Django 5.2.3 on 2025-07-13 08:22
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("core", "0023_remove_document_is_public_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="linktrace",
|
||||||
|
name="is_masked",
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="user",
|
||||||
|
name="language",
|
||||||
|
field=models.CharField(
|
||||||
|
blank=True,
|
||||||
|
choices=[
|
||||||
|
("en-us", "English"),
|
||||||
|
("fr-fr", "Français"),
|
||||||
|
("de-de", "Deutsch"),
|
||||||
|
("nl-nl", "Nederlands"),
|
||||||
|
("es-es", "Español"),
|
||||||
|
],
|
||||||
|
default=None,
|
||||||
|
help_text="The language in which the user wants to see the interface.",
|
||||||
|
max_length=10,
|
||||||
|
null=True,
|
||||||
|
verbose_name="language",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -6,7 +6,6 @@ Declare and configure the models for the impress core application
|
|||||||
import hashlib
|
import hashlib
|
||||||
import smtplib
|
import smtplib
|
||||||
import uuid
|
import uuid
|
||||||
from collections import defaultdict
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
|
|
||||||
@@ -33,6 +32,14 @@ from rest_framework.exceptions import ValidationError
|
|||||||
from timezone_field import TimeZoneField
|
from timezone_field import TimeZoneField
|
||||||
from treebeard.mp_tree import MP_Node, MP_NodeManager, MP_NodeQuerySet
|
from treebeard.mp_tree import MP_Node, MP_NodeManager, MP_NodeQuerySet
|
||||||
|
|
||||||
|
from .choices import (
|
||||||
|
PRIVILEGED_ROLES,
|
||||||
|
LinkReachChoices,
|
||||||
|
LinkRoleChoices,
|
||||||
|
RoleChoices,
|
||||||
|
get_equivalent_link_definition,
|
||||||
|
)
|
||||||
|
|
||||||
logger = getLogger(__name__)
|
logger = getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -50,88 +57,6 @@ def get_trashbin_cutoff():
|
|||||||
return timezone.now() - timedelta(days=settings.TRASHBIN_CUTOFF_DAYS)
|
return timezone.now() - timedelta(days=settings.TRASHBIN_CUTOFF_DAYS)
|
||||||
|
|
||||||
|
|
||||||
class LinkRoleChoices(models.TextChoices):
|
|
||||||
"""Defines the possible roles a link can offer on a document."""
|
|
||||||
|
|
||||||
READER = "reader", _("Reader") # Can read
|
|
||||||
EDITOR = "editor", _("Editor") # Can read and edit
|
|
||||||
|
|
||||||
|
|
||||||
class RoleChoices(models.TextChoices):
|
|
||||||
"""Defines the possible roles a user can have in a resource."""
|
|
||||||
|
|
||||||
READER = "reader", _("Reader") # Can read
|
|
||||||
EDITOR = "editor", _("Editor") # Can read and edit
|
|
||||||
ADMIN = "administrator", _("Administrator") # Can read, edit, delete and share
|
|
||||||
OWNER = "owner", _("Owner")
|
|
||||||
|
|
||||||
|
|
||||||
PRIVILEGED_ROLES = [RoleChoices.ADMIN, RoleChoices.OWNER]
|
|
||||||
|
|
||||||
|
|
||||||
class LinkReachChoices(models.TextChoices):
|
|
||||||
"""Defines types of access for links"""
|
|
||||||
|
|
||||||
RESTRICTED = (
|
|
||||||
"restricted",
|
|
||||||
_("Restricted"),
|
|
||||||
) # Only users with a specific access can read/edit the document
|
|
||||||
AUTHENTICATED = (
|
|
||||||
"authenticated",
|
|
||||||
_("Authenticated"),
|
|
||||||
) # Any authenticated user can access the document
|
|
||||||
PUBLIC = "public", _("Public") # Even anonymous users can access the document
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_select_options(cls, 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):
|
class DuplicateEmailError(Exception):
|
||||||
"""Raised when an email is already associated with a pre-existing user."""
|
"""Raised when an email is already associated with a pre-existing user."""
|
||||||
|
|
||||||
@@ -364,69 +289,6 @@ class BaseAccess(BaseModel):
|
|||||||
class Meta:
|
class Meta:
|
||||||
abstract = True
|
abstract = True
|
||||||
|
|
||||||
def _get_roles(self, resource, user):
|
|
||||||
"""
|
|
||||||
Get the roles a user has on a resource.
|
|
||||||
"""
|
|
||||||
roles = []
|
|
||||||
if user.is_authenticated:
|
|
||||||
teams = user.teams
|
|
||||||
try:
|
|
||||||
roles = self.user_roles or []
|
|
||||||
except AttributeError:
|
|
||||||
try:
|
|
||||||
roles = resource.accesses.filter(
|
|
||||||
models.Q(user=user) | models.Q(team__in=teams),
|
|
||||||
).values_list("role", flat=True)
|
|
||||||
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})
|
|
||||||
)
|
|
||||||
if self.role == RoleChoices.OWNER:
|
|
||||||
can_delete = (
|
|
||||||
RoleChoices.OWNER in roles
|
|
||||||
and resource.accesses.filter(role=RoleChoices.OWNER).count() > 1
|
|
||||||
)
|
|
||||||
set_role_to = (
|
|
||||||
[RoleChoices.ADMIN, RoleChoices.EDITOR, RoleChoices.READER]
|
|
||||||
if can_delete
|
|
||||||
else []
|
|
||||||
)
|
|
||||||
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),
|
|
||||||
"partial_update": bool(set_role_to),
|
|
||||||
"retrieve": bool(roles),
|
|
||||||
"set_role_to": set_role_to,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class DocumentQuerySet(MP_NodeQuerySet):
|
class DocumentQuerySet(MP_NodeQuerySet):
|
||||||
"""
|
"""
|
||||||
@@ -452,6 +314,41 @@ class DocumentQuerySet(MP_NodeQuerySet):
|
|||||||
|
|
||||||
return self.filter(link_reach=LinkReachChoices.PUBLIC)
|
return self.filter(link_reach=LinkReachChoices.PUBLIC)
|
||||||
|
|
||||||
|
def annotate_is_favorite(self, user):
|
||||||
|
"""
|
||||||
|
Annotate document queryset with the favorite status for the current user.
|
||||||
|
"""
|
||||||
|
if user.is_authenticated:
|
||||||
|
favorite_exists_subquery = DocumentFavorite.objects.filter(
|
||||||
|
document_id=models.OuterRef("pk"), user=user
|
||||||
|
)
|
||||||
|
return self.annotate(is_favorite=models.Exists(favorite_exists_subquery))
|
||||||
|
|
||||||
|
return self.annotate(is_favorite=models.Value(False))
|
||||||
|
|
||||||
|
def annotate_user_roles(self, user):
|
||||||
|
"""
|
||||||
|
Annotate document queryset with the roles of the current user
|
||||||
|
on the document or its ancestors.
|
||||||
|
"""
|
||||||
|
output_field = ArrayField(base_field=models.CharField())
|
||||||
|
|
||||||
|
if user.is_authenticated:
|
||||||
|
user_roles_subquery = DocumentAccess.objects.filter(
|
||||||
|
models.Q(user=user) | models.Q(team__in=user.teams),
|
||||||
|
document__path=Left(models.OuterRef("path"), Length("document__path")),
|
||||||
|
).values_list("role", flat=True)
|
||||||
|
|
||||||
|
return self.annotate(
|
||||||
|
user_roles=models.Func(
|
||||||
|
user_roles_subquery, function="ARRAY", output_field=output_field
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.annotate(
|
||||||
|
user_roles=models.Value([], output_field=output_field),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class DocumentManager(MP_NodeManager.from_queryset(DocumentQuerySet)):
|
class DocumentManager(MP_NodeManager.from_queryset(DocumentQuerySet)):
|
||||||
"""
|
"""
|
||||||
@@ -464,6 +361,7 @@ class DocumentManager(MP_NodeManager.from_queryset(DocumentQuerySet)):
|
|||||||
return self._queryset_class(self.model).order_by("path")
|
return self._queryset_class(self.model).order_by("path")
|
||||||
|
|
||||||
|
|
||||||
|
# pylint: disable=too-many-public-methods
|
||||||
class Document(MP_Node, BaseModel):
|
class Document(MP_Node, BaseModel):
|
||||||
"""Pad document carrying the content."""
|
"""Pad document carrying the content."""
|
||||||
|
|
||||||
@@ -486,6 +384,7 @@ class Document(MP_Node, BaseModel):
|
|||||||
)
|
)
|
||||||
deleted_at = models.DateTimeField(null=True, blank=True)
|
deleted_at = models.DateTimeField(null=True, blank=True)
|
||||||
ancestors_deleted_at = models.DateTimeField(null=True, blank=True)
|
ancestors_deleted_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
has_deleted_children = models.BooleanField(default=False)
|
||||||
duplicated_from = models.ForeignKey(
|
duplicated_from = models.ForeignKey(
|
||||||
"self",
|
"self",
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
@@ -520,7 +419,7 @@ class Document(MP_Node, BaseModel):
|
|||||||
verbose_name_plural = _("Documents")
|
verbose_name_plural = _("Documents")
|
||||||
constraints = [
|
constraints = [
|
||||||
models.CheckConstraint(
|
models.CheckConstraint(
|
||||||
check=(
|
condition=(
|
||||||
models.Q(deleted_at__isnull=True)
|
models.Q(deleted_at__isnull=True)
|
||||||
| models.Q(deleted_at=models.F("ancestors_deleted_at"))
|
| models.Q(deleted_at=models.F("ancestors_deleted_at"))
|
||||||
),
|
),
|
||||||
@@ -531,6 +430,12 @@ class Document(MP_Node, BaseModel):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return str(self.title) if self.title else str(_("Untitled Document"))
|
return str(self.title) if self.title else str(_("Untitled Document"))
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
"""Initialize cache property."""
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self._ancestors_link_definition = None
|
||||||
|
self._computed_link_definition = None
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
"""Write content to object storage only if _content has changed."""
|
"""Write content to object storage only if _content has changed."""
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
@@ -561,6 +466,12 @@ class Document(MP_Node, BaseModel):
|
|||||||
content_file = ContentFile(bytes_content)
|
content_file = ContentFile(bytes_content)
|
||||||
default_storage.save(file_key, content_file)
|
default_storage.save(file_key, content_file)
|
||||||
|
|
||||||
|
def is_leaf(self):
|
||||||
|
"""
|
||||||
|
:returns: True if the node is has no children
|
||||||
|
"""
|
||||||
|
return not self.has_deleted_children and self.numchild == 0
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def key_base(self):
|
def key_base(self):
|
||||||
"""Key base of the location where the document is stored in object storage."""
|
"""Key base of the location where the document is stored in object storage."""
|
||||||
@@ -718,38 +629,22 @@ class Document(MP_Node, BaseModel):
|
|||||||
cache_key = document.get_nb_accesses_cache_key()
|
cache_key = document.get_nb_accesses_cache_key()
|
||||||
cache.delete(cache_key)
|
cache.delete(cache_key)
|
||||||
|
|
||||||
def get_roles(self, user):
|
def get_role(self, user):
|
||||||
"""Return the roles a user has on a document."""
|
"""Return the roles a user has on a document."""
|
||||||
if not user.is_authenticated:
|
if not user.is_authenticated:
|
||||||
return []
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
roles = self.user_roles or []
|
roles = self.user_roles or []
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
try:
|
roles = DocumentAccess.objects.filter(
|
||||||
roles = DocumentAccess.objects.filter(
|
models.Q(user=user) | models.Q(team__in=user.teams),
|
||||||
models.Q(user=user) | models.Q(team__in=user.teams),
|
document__path=Left(models.Value(self.path), Length("document__path")),
|
||||||
document__path=Left(
|
).values_list("role", flat=True)
|
||||||
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):
|
return RoleChoices.max(*roles)
|
||||||
"""Get links reach/role definitions for the current document and its ancestors."""
|
|
||||||
|
|
||||||
links_definitions = defaultdict(set)
|
def compute_ancestors_links_paths_mapping(self):
|
||||||
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.
|
Compute the ancestors links for the current document up to the highest readable ancestor.
|
||||||
"""
|
"""
|
||||||
@@ -758,63 +653,114 @@ class Document(MP_Node, BaseModel):
|
|||||||
.filter(ancestors_deleted_at__isnull=True)
|
.filter(ancestors_deleted_at__isnull=True)
|
||||||
.order_by("path")
|
.order_by("path")
|
||||||
)
|
)
|
||||||
highest_readable = ancestors.readable_per_se(user).only("depth").first()
|
|
||||||
|
|
||||||
if highest_readable is None:
|
|
||||||
return []
|
|
||||||
|
|
||||||
ancestors_links = []
|
ancestors_links = []
|
||||||
paths_links_mapping = {}
|
paths_links_mapping = {}
|
||||||
for ancestor in ancestors.filter(depth__gte=highest_readable.depth):
|
|
||||||
|
for ancestor in ancestors:
|
||||||
ancestors_links.append(
|
ancestors_links.append(
|
||||||
{"link_reach": ancestor.link_reach, "link_role": ancestor.link_role}
|
{"link_reach": ancestor.link_reach, "link_role": ancestor.link_role}
|
||||||
)
|
)
|
||||||
paths_links_mapping[ancestor.path] = ancestors_links.copy()
|
paths_links_mapping[ancestor.path] = ancestors_links.copy()
|
||||||
|
|
||||||
ancestors_links = paths_links_mapping.get(self.path[: -self.steplen], [])
|
return paths_links_mapping
|
||||||
|
|
||||||
return ancestors_links
|
@property
|
||||||
|
def link_definition(self):
|
||||||
|
"""Returns link reach/role as a definition in dictionary format."""
|
||||||
|
return {"link_reach": self.link_reach, "link_role": self.link_role}
|
||||||
|
|
||||||
def get_abilities(self, user, ancestors_links=None):
|
@property
|
||||||
|
def ancestors_link_definition(self):
|
||||||
|
"""Link definition equivalent to all document's ancestors."""
|
||||||
|
if getattr(self, "_ancestors_link_definition", None) is None:
|
||||||
|
if self.depth <= 1:
|
||||||
|
ancestors_links = []
|
||||||
|
else:
|
||||||
|
mapping = self.compute_ancestors_links_paths_mapping()
|
||||||
|
ancestors_links = mapping.get(self.path[: -self.steplen], [])
|
||||||
|
self._ancestors_link_definition = get_equivalent_link_definition(
|
||||||
|
ancestors_links
|
||||||
|
)
|
||||||
|
|
||||||
|
return self._ancestors_link_definition
|
||||||
|
|
||||||
|
@ancestors_link_definition.setter
|
||||||
|
def ancestors_link_definition(self, definition):
|
||||||
|
"""Cache the ancestors_link_definition."""
|
||||||
|
self._ancestors_link_definition = definition
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ancestors_link_reach(self):
|
||||||
|
"""Link reach equivalent to all document's ancestors."""
|
||||||
|
return self.ancestors_link_definition["link_reach"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ancestors_link_role(self):
|
||||||
|
"""Link role equivalent to all document's ancestors."""
|
||||||
|
return self.ancestors_link_definition["link_role"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def computed_link_definition(self):
|
||||||
|
"""
|
||||||
|
Link reach/role on the document, combining inherited ancestors' link
|
||||||
|
definitions and the document's own link definition.
|
||||||
|
"""
|
||||||
|
if getattr(self, "_computed_link_definition", None) is None:
|
||||||
|
self._computed_link_definition = get_equivalent_link_definition(
|
||||||
|
[self.ancestors_link_definition, self.link_definition]
|
||||||
|
)
|
||||||
|
return self._computed_link_definition
|
||||||
|
|
||||||
|
@property
|
||||||
|
def computed_link_reach(self):
|
||||||
|
"""Actual link reach on the document."""
|
||||||
|
return self.computed_link_definition["link_reach"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def computed_link_role(self):
|
||||||
|
"""Actual link role on the document."""
|
||||||
|
return self.computed_link_definition["link_role"]
|
||||||
|
|
||||||
|
def get_abilities(self, user):
|
||||||
"""
|
"""
|
||||||
Compute and return abilities for a given user on the document.
|
Compute and return abilities for a given user on the document.
|
||||||
"""
|
"""
|
||||||
if self.depth <= 1 or getattr(self, "is_highest_ancestor_for_user", False):
|
# First get the role based on specific access
|
||||||
ancestors_links = []
|
role = self.get_role(user)
|
||||||
elif ancestors_links is None:
|
|
||||||
ancestors_links = self.compute_ancestors_links(user=user)
|
|
||||||
|
|
||||||
roles = set(
|
|
||||||
self.get_roles(user)
|
|
||||||
) # at this point only roles based on specific access
|
|
||||||
|
|
||||||
# Characteristics that are based only on specific access
|
# Characteristics that are based only on specific access
|
||||||
is_owner = RoleChoices.OWNER in roles
|
is_owner = role == RoleChoices.OWNER
|
||||||
is_deleted = self.ancestors_deleted_at and not is_owner
|
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
|
is_owner_or_admin = (is_owner or role == RoleChoices.ADMIN) and not is_deleted
|
||||||
|
|
||||||
# Compute access roles before adding link roles because we don't
|
# Compute access roles before adding link roles because we don't
|
||||||
# want anonymous users to access versions (we wouldn't know from
|
# want anonymous users to access versions (we wouldn't know from
|
||||||
# which date to allow them anyway)
|
# which date to allow them anyway)
|
||||||
# Anonymous users should also not see document accesses
|
# Anonymous users should also not see document accesses
|
||||||
has_access_role = bool(roles) and not is_deleted
|
has_access_role = bool(role) and not is_deleted
|
||||||
can_update_from_access = (
|
can_update_from_access = (
|
||||||
is_owner_or_admin or RoleChoices.EDITOR in roles
|
is_owner_or_admin or role == RoleChoices.EDITOR
|
||||||
) and not is_deleted
|
) and not is_deleted
|
||||||
|
|
||||||
# Add roles provided by the document link, taking into account its ancestors
|
link_select_options = LinkReachChoices.get_select_options(
|
||||||
links_definitions = self.get_links_definitions(ancestors_links)
|
**self.ancestors_link_definition
|
||||||
public_roles = links_definitions.get(LinkReachChoices.PUBLIC, set())
|
)
|
||||||
authenticated_roles = (
|
link_definition = get_equivalent_link_definition(
|
||||||
links_definitions.get(LinkReachChoices.AUTHENTICATED, set())
|
[
|
||||||
if user.is_authenticated
|
self.ancestors_link_definition,
|
||||||
else set()
|
{"link_reach": self.link_reach, "link_role": self.link_role},
|
||||||
|
]
|
||||||
)
|
)
|
||||||
roles = roles | public_roles | authenticated_roles
|
|
||||||
|
|
||||||
can_get = bool(roles) and not is_deleted
|
link_reach = link_definition["link_reach"]
|
||||||
|
if link_reach == LinkReachChoices.PUBLIC or (
|
||||||
|
link_reach == LinkReachChoices.AUTHENTICATED and user.is_authenticated
|
||||||
|
):
|
||||||
|
role = RoleChoices.max(role, link_definition["link_role"])
|
||||||
|
|
||||||
|
can_get = bool(role) and not is_deleted
|
||||||
can_update = (
|
can_update = (
|
||||||
is_owner_or_admin or RoleChoices.EDITOR in roles
|
is_owner_or_admin or role == RoleChoices.EDITOR
|
||||||
) and not is_deleted
|
) and not is_deleted
|
||||||
|
|
||||||
ai_allow_reach_from = settings.AI_ALLOW_REACH_FROM
|
ai_allow_reach_from = settings.AI_ALLOW_REACH_FROM
|
||||||
@@ -836,22 +782,25 @@ class Document(MP_Node, BaseModel):
|
|||||||
"ai_translate": ai_access,
|
"ai_translate": ai_access,
|
||||||
"attachment_upload": can_update,
|
"attachment_upload": can_update,
|
||||||
"media_check": can_get,
|
"media_check": can_get,
|
||||||
|
"can_edit": can_update,
|
||||||
"children_list": can_get,
|
"children_list": can_get,
|
||||||
"children_create": can_update and user.is_authenticated,
|
"children_create": can_update and user.is_authenticated,
|
||||||
"collaboration_auth": can_get,
|
"collaboration_auth": can_get,
|
||||||
|
"content": can_get,
|
||||||
"cors_proxy": can_get,
|
"cors_proxy": can_get,
|
||||||
"descendants": can_get,
|
"descendants": can_get,
|
||||||
"destroy": is_owner,
|
"destroy": is_owner,
|
||||||
"duplicate": can_get,
|
"duplicate": can_get and user.is_authenticated,
|
||||||
"favorite": can_get and user.is_authenticated,
|
"favorite": can_get and user.is_authenticated,
|
||||||
"link_configuration": is_owner_or_admin,
|
"link_configuration": is_owner_or_admin,
|
||||||
"invite_owner": is_owner,
|
"invite_owner": is_owner,
|
||||||
|
"mask": can_get and user.is_authenticated,
|
||||||
"move": is_owner_or_admin and not self.ancestors_deleted_at,
|
"move": is_owner_or_admin and not self.ancestors_deleted_at,
|
||||||
"partial_update": can_update,
|
"partial_update": can_update,
|
||||||
"restore": is_owner,
|
"restore": is_owner,
|
||||||
"retrieve": can_get,
|
"retrieve": can_get,
|
||||||
"media_auth": can_get,
|
"media_auth": can_get,
|
||||||
"link_select_options": LinkReachChoices.get_select_options(ancestors_links),
|
"link_select_options": link_select_options,
|
||||||
"tree": can_get,
|
"tree": can_get,
|
||||||
"update": can_update,
|
"update": can_update,
|
||||||
"versions_destroy": is_owner_or_admin,
|
"versions_destroy": is_owner_or_admin,
|
||||||
@@ -876,8 +825,8 @@ class Document(MP_Node, BaseModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
with override(language):
|
with override(language):
|
||||||
msg_html = render_to_string("mail/html/invitation.html", context)
|
msg_html = render_to_string("mail/html/template.html", context)
|
||||||
msg_plain = render_to_string("mail/text/invitation.txt", context)
|
msg_plain = render_to_string("mail/text/template.txt", context)
|
||||||
subject = str(subject) # Force translation
|
subject = str(subject) # Force translation
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -946,7 +895,8 @@ class Document(MP_Node, BaseModel):
|
|||||||
|
|
||||||
if self.depth > 1:
|
if self.depth > 1:
|
||||||
self._meta.model.objects.filter(pk=self.get_parent().pk).update(
|
self._meta.model.objects.filter(pk=self.get_parent().pk).update(
|
||||||
numchild=models.F("numchild") - 1
|
numchild=models.F("numchild") - 1,
|
||||||
|
has_deleted_children=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Mark all descendants as soft deleted
|
# Mark all descendants as soft deleted
|
||||||
@@ -1010,6 +960,7 @@ class LinkTrace(BaseModel):
|
|||||||
related_name="link_traces",
|
related_name="link_traces",
|
||||||
)
|
)
|
||||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="link_traces")
|
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="link_traces")
|
||||||
|
is_masked = models.BooleanField(default=False)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = "impress_link_trace"
|
db_table = "impress_link_trace"
|
||||||
@@ -1088,7 +1039,7 @@ class DocumentAccess(BaseAccess):
|
|||||||
violation_error_message=_("This team is already in this document."),
|
violation_error_message=_("This team is already in this document."),
|
||||||
),
|
),
|
||||||
models.CheckConstraint(
|
models.CheckConstraint(
|
||||||
check=models.Q(user__isnull=False, team="")
|
condition=models.Q(user__isnull=False, team="")
|
||||||
| models.Q(user__isnull=True, team__gt=""),
|
| models.Q(user__isnull=True, team__gt=""),
|
||||||
name="check_document_access_either_user_or_team",
|
name="check_document_access_either_user_or_team",
|
||||||
violation_error_message=_("Either user or team must be set, not both."),
|
violation_error_message=_("Either user or team must be set, not both."),
|
||||||
@@ -1103,52 +1054,230 @@ class DocumentAccess(BaseAccess):
|
|||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
self.document.invalidate_nb_accesses_cache()
|
self.document.invalidate_nb_accesses_cache()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def target_key(self):
|
||||||
|
"""Get a unique key for the actor targeted by the access, without possible conflict."""
|
||||||
|
return f"user:{self.user_id!s}" if self.user_id else f"team:{self.team:s}"
|
||||||
|
|
||||||
def delete(self, *args, **kwargs):
|
def delete(self, *args, **kwargs):
|
||||||
"""Override delete to clear the document's cache for number of accesses."""
|
"""Override delete to clear the document's cache for number of accesses."""
|
||||||
super().delete(*args, **kwargs)
|
super().delete(*args, **kwargs)
|
||||||
self.document.invalidate_nb_accesses_cache()
|
self.document.invalidate_nb_accesses_cache()
|
||||||
|
|
||||||
|
def set_user_roles_tuple(self, ancestors_role, current_role):
|
||||||
|
"""
|
||||||
|
Set a precomputed (ancestor_role, current_role) tuple for this instance.
|
||||||
|
|
||||||
|
This avoids querying the database in `get_roles_tuple()` and is useful
|
||||||
|
when roles are already known, such as in bulk serialization.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ancestor_role (str | None): Highest role on any ancestor document.
|
||||||
|
current_role (str | None): Role on the current document.
|
||||||
|
"""
|
||||||
|
# pylint: disable=attribute-defined-outside-init
|
||||||
|
self._prefetched_user_roles_tuple = (ancestors_role, current_role)
|
||||||
|
|
||||||
|
def get_user_roles_tuple(self, user):
|
||||||
|
"""
|
||||||
|
Return a tuple of:
|
||||||
|
- the highest role the user has on any ancestor of the document
|
||||||
|
- the role the user has on the current document
|
||||||
|
|
||||||
|
If roles have been explicitly set using `set_user_roles_tuple()`,
|
||||||
|
those will be returned instead of querying the database.
|
||||||
|
|
||||||
|
This allows viewsets or serializers to precompute roles for performance
|
||||||
|
when handling multiple documents at once.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user (User): The user whose roles are being evaluated.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple[str | None, str | None]: (max_ancestor_role, current_document_role)
|
||||||
|
"""
|
||||||
|
if not user.is_authenticated:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
try:
|
||||||
|
return self._prefetched_user_roles_tuple
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
ancestors = (
|
||||||
|
self.document.get_ancestors() | Document.objects.filter(pk=self.document_id)
|
||||||
|
).filter(ancestors_deleted_at__isnull=True)
|
||||||
|
|
||||||
|
access_tuples = DocumentAccess.objects.filter(
|
||||||
|
models.Q(user=user) | models.Q(team__in=user.teams),
|
||||||
|
document__in=ancestors,
|
||||||
|
).values_list("document_id", "role")
|
||||||
|
|
||||||
|
ancestors_roles = []
|
||||||
|
current_roles = []
|
||||||
|
for doc_id, role in access_tuples:
|
||||||
|
if doc_id == self.document_id:
|
||||||
|
current_roles.append(role)
|
||||||
|
else:
|
||||||
|
ancestors_roles.append(role)
|
||||||
|
|
||||||
|
return RoleChoices.max(*ancestors_roles), RoleChoices.max(*current_roles)
|
||||||
|
|
||||||
def get_abilities(self, user):
|
def get_abilities(self, user):
|
||||||
"""
|
"""
|
||||||
Compute and return abilities for a given user on the document access.
|
Compute and return abilities for a given user on the document access.
|
||||||
"""
|
"""
|
||||||
roles = self._get_roles(self.document, user)
|
ancestors_role, current_role = self.get_user_roles_tuple(user)
|
||||||
is_owner_or_admin = bool(set(roles).intersection(set(PRIVILEGED_ROLES)))
|
role = RoleChoices.max(ancestors_role, current_role)
|
||||||
|
is_owner_or_admin = role in PRIVILEGED_ROLES
|
||||||
|
|
||||||
if self.role == RoleChoices.OWNER:
|
if self.role == RoleChoices.OWNER:
|
||||||
can_delete = (
|
can_delete = role == RoleChoices.OWNER and (
|
||||||
RoleChoices.OWNER in roles
|
# check if document is not root trying to avoid an extra query
|
||||||
and self.document.accesses.filter(role=RoleChoices.OWNER).count() > 1
|
self.document.depth > 1
|
||||||
)
|
or DocumentAccess.objects.filter(
|
||||||
set_role_to = (
|
document_id=self.document_id, role=RoleChoices.OWNER
|
||||||
[RoleChoices.ADMIN, RoleChoices.EDITOR, RoleChoices.READER]
|
).count()
|
||||||
if can_delete
|
> 1
|
||||||
else []
|
|
||||||
)
|
)
|
||||||
|
set_role_to = RoleChoices.values if can_delete else []
|
||||||
else:
|
else:
|
||||||
can_delete = is_owner_or_admin
|
can_delete = is_owner_or_admin
|
||||||
set_role_to = []
|
set_role_to = []
|
||||||
if RoleChoices.OWNER in roles:
|
|
||||||
set_role_to.append(RoleChoices.OWNER)
|
|
||||||
if is_owner_or_admin:
|
if is_owner_or_admin:
|
||||||
set_role_to.extend(
|
set_role_to.extend(
|
||||||
[RoleChoices.ADMIN, RoleChoices.EDITOR, RoleChoices.READER]
|
[RoleChoices.READER, RoleChoices.EDITOR, RoleChoices.ADMIN]
|
||||||
)
|
)
|
||||||
|
if role == RoleChoices.OWNER:
|
||||||
|
set_role_to.append(RoleChoices.OWNER)
|
||||||
|
|
||||||
# Remove the current role as we don't want to propose it as an option
|
# Filter out roles that would be lower than the one the user already has
|
||||||
try:
|
ancestors_role_priority = RoleChoices.get_priority(
|
||||||
set_role_to.remove(self.role)
|
getattr(self, "max_ancestors_role", None)
|
||||||
except ValueError:
|
)
|
||||||
pass
|
set_role_to = [
|
||||||
|
candidate_role
|
||||||
|
for candidate_role in set_role_to
|
||||||
|
if RoleChoices.get_priority(candidate_role) >= ancestors_role_priority
|
||||||
|
]
|
||||||
|
if len(set_role_to) == 1:
|
||||||
|
set_role_to = []
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"destroy": can_delete,
|
"destroy": can_delete,
|
||||||
"update": bool(set_role_to) and is_owner_or_admin,
|
"update": bool(set_role_to) and is_owner_or_admin,
|
||||||
"partial_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,
|
"retrieve": (self.user and self.user.id == user.id) or is_owner_or_admin,
|
||||||
"set_role_to": set_role_to,
|
"set_role_to": set_role_to,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentAskForAccess(BaseModel):
|
||||||
|
"""Relation model to ask for access to a document."""
|
||||||
|
|
||||||
|
document = models.ForeignKey(
|
||||||
|
Document, on_delete=models.CASCADE, related_name="ask_for_accesses"
|
||||||
|
)
|
||||||
|
user = models.ForeignKey(
|
||||||
|
User, on_delete=models.CASCADE, related_name="ask_for_accesses"
|
||||||
|
)
|
||||||
|
|
||||||
|
role = models.CharField(
|
||||||
|
max_length=20, choices=RoleChoices.choices, default=RoleChoices.READER
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = "impress_document_ask_for_access"
|
||||||
|
verbose_name = _("Document ask for access")
|
||||||
|
verbose_name_plural = _("Document ask for accesses")
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["user", "document"],
|
||||||
|
name="unique_document_ask_for_access_user",
|
||||||
|
violation_error_message=_(
|
||||||
|
"This user has already asked for access to this document."
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.user!s} asked for access to document {self.document!s}"
|
||||||
|
|
||||||
|
def get_abilities(self, user):
|
||||||
|
"""Compute and return abilities for a given user."""
|
||||||
|
roles = []
|
||||||
|
|
||||||
|
if user.is_authenticated:
|
||||||
|
teams = user.teams
|
||||||
|
try:
|
||||||
|
roles = self.user_roles or []
|
||||||
|
except AttributeError:
|
||||||
|
try:
|
||||||
|
roles = self.document.accesses.filter(
|
||||||
|
models.Q(user=user) | models.Q(team__in=teams),
|
||||||
|
).values_list("role", flat=True)
|
||||||
|
except (self._meta.model.DoesNotExist, IndexError):
|
||||||
|
roles = []
|
||||||
|
|
||||||
|
is_admin_or_owner = bool(
|
||||||
|
set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"destroy": is_admin_or_owner,
|
||||||
|
"update": is_admin_or_owner,
|
||||||
|
"partial_update": is_admin_or_owner,
|
||||||
|
"retrieve": is_admin_or_owner,
|
||||||
|
"accept": is_admin_or_owner,
|
||||||
|
}
|
||||||
|
|
||||||
|
def accept(self, role=None):
|
||||||
|
"""Accept a document ask for access resource."""
|
||||||
|
if role is None:
|
||||||
|
role = self.role
|
||||||
|
|
||||||
|
DocumentAccess.objects.update_or_create(
|
||||||
|
document=self.document,
|
||||||
|
user=self.user,
|
||||||
|
defaults={"role": role},
|
||||||
|
create_defaults={"role": role},
|
||||||
|
)
|
||||||
|
self.delete()
|
||||||
|
|
||||||
|
def send_ask_for_access_email(self, email, language=None):
|
||||||
|
"""
|
||||||
|
Method allowing a user to send an email notification when asking for access to a document.
|
||||||
|
"""
|
||||||
|
|
||||||
|
language = language or get_language()
|
||||||
|
sender = self.user
|
||||||
|
sender_name = sender.full_name or sender.email
|
||||||
|
sender_name_email = (
|
||||||
|
f"{sender.full_name:s} ({sender.email})"
|
||||||
|
if sender.full_name
|
||||||
|
else sender.email
|
||||||
|
)
|
||||||
|
|
||||||
|
with override(language):
|
||||||
|
context = {
|
||||||
|
"title": _("{name} would like access to a document!").format(
|
||||||
|
name=sender_name
|
||||||
|
),
|
||||||
|
"message": _(
|
||||||
|
"{name} would like access to the following document:"
|
||||||
|
).format(name=sender_name_email),
|
||||||
|
}
|
||||||
|
subject = (
|
||||||
|
context["title"]
|
||||||
|
if not self.document.title
|
||||||
|
else _("{name} is asking for access to the document: {title}").format(
|
||||||
|
name=sender_name, title=self.document.title
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.document.send_email(subject, [email], context, language)
|
||||||
|
|
||||||
|
|
||||||
class Template(BaseModel):
|
class Template(BaseModel):
|
||||||
"""HTML and CSS code used for formatting the print around the MarkDown body."""
|
"""HTML and CSS code used for formatting the print around the MarkDown body."""
|
||||||
|
|
||||||
@@ -1171,10 +1300,10 @@ class Template(BaseModel):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.title
|
return self.title
|
||||||
|
|
||||||
def get_roles(self, user):
|
def get_role(self, user):
|
||||||
"""Return the roles a user has on a resource as an iterable."""
|
"""Return the roles a user has on a resource as an iterable."""
|
||||||
if not user.is_authenticated:
|
if not user.is_authenticated:
|
||||||
return []
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
roles = self.user_roles or []
|
roles = self.user_roles or []
|
||||||
@@ -1185,21 +1314,20 @@ class Template(BaseModel):
|
|||||||
).values_list("role", flat=True)
|
).values_list("role", flat=True)
|
||||||
except (models.ObjectDoesNotExist, IndexError):
|
except (models.ObjectDoesNotExist, IndexError):
|
||||||
roles = []
|
roles = []
|
||||||
return roles
|
|
||||||
|
return RoleChoices.max(*roles)
|
||||||
|
|
||||||
def get_abilities(self, user):
|
def get_abilities(self, user):
|
||||||
"""
|
"""
|
||||||
Compute and return abilities for a given user on the template.
|
Compute and return abilities for a given user on the template.
|
||||||
"""
|
"""
|
||||||
roles = self.get_roles(user)
|
role = self.get_role(user)
|
||||||
is_owner_or_admin = bool(
|
is_owner_or_admin = role in PRIVILEGED_ROLES
|
||||||
set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
|
can_get = self.is_public or bool(role)
|
||||||
)
|
can_update = is_owner_or_admin or role == RoleChoices.EDITOR
|
||||||
can_get = self.is_public or bool(roles)
|
|
||||||
can_update = is_owner_or_admin or RoleChoices.EDITOR in roles
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"destroy": RoleChoices.OWNER in roles,
|
"destroy": role == RoleChoices.OWNER,
|
||||||
"generate_document": can_get,
|
"generate_document": can_get,
|
||||||
"accesses_manage": is_owner_or_admin,
|
"accesses_manage": is_owner_or_admin,
|
||||||
"update": can_update,
|
"update": can_update,
|
||||||
@@ -1236,7 +1364,7 @@ class TemplateAccess(BaseAccess):
|
|||||||
violation_error_message=_("This team is already in this template."),
|
violation_error_message=_("This team is already in this template."),
|
||||||
),
|
),
|
||||||
models.CheckConstraint(
|
models.CheckConstraint(
|
||||||
check=models.Q(user__isnull=False, team="")
|
condition=models.Q(user__isnull=False, team="")
|
||||||
| models.Q(user__isnull=True, team__gt=""),
|
| models.Q(user__isnull=True, team__gt=""),
|
||||||
name="check_template_access_either_user_or_team",
|
name="check_template_access_either_user_or_team",
|
||||||
violation_error_message=_("Either user or team must be set, not both."),
|
violation_error_message=_("Either user or team must be set, not both."),
|
||||||
@@ -1246,11 +1374,65 @@ class TemplateAccess(BaseAccess):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.user!s} is {self.role:s} in template {self.template!s}"
|
return f"{self.user!s} is {self.role:s} in template {self.template!s}"
|
||||||
|
|
||||||
|
def get_role(self, user):
|
||||||
|
"""
|
||||||
|
Get the role a user has on a resource.
|
||||||
|
"""
|
||||||
|
if not user.is_authenticated:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
roles = self.user_roles or []
|
||||||
|
except AttributeError:
|
||||||
|
teams = user.teams
|
||||||
|
try:
|
||||||
|
roles = self.template.accesses.filter(
|
||||||
|
models.Q(user=user) | models.Q(team__in=teams),
|
||||||
|
).values_list("role", flat=True)
|
||||||
|
except (Template.DoesNotExist, IndexError):
|
||||||
|
roles = []
|
||||||
|
|
||||||
|
return RoleChoices.max(*roles)
|
||||||
|
|
||||||
def get_abilities(self, user):
|
def get_abilities(self, user):
|
||||||
"""
|
"""
|
||||||
Compute and return abilities for a given user on the template access.
|
Compute and return abilities for a given user on the template access.
|
||||||
"""
|
"""
|
||||||
return self._get_abilities(self.template, user)
|
role = self.get_role(user)
|
||||||
|
is_owner_or_admin = role in PRIVILEGED_ROLES
|
||||||
|
|
||||||
|
if self.role == RoleChoices.OWNER:
|
||||||
|
can_delete = (role == RoleChoices.OWNER) and self.template.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 role == RoleChoices.OWNER:
|
||||||
|
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),
|
||||||
|
"partial_update": bool(set_role_to),
|
||||||
|
"retrieve": bool(role),
|
||||||
|
"set_role_to": set_role_to,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class Invitation(BaseModel):
|
class Invitation(BaseModel):
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ from core import enums
|
|||||||
|
|
||||||
AI_ACTIONS = {
|
AI_ACTIONS = {
|
||||||
"prompt": (
|
"prompt": (
|
||||||
"Answer the prompt in markdown format. "
|
"Answer the prompt using markdown formatting for structure and emphasis. "
|
||||||
|
"Return the content directly without wrapping it in code blocks or markdown delimiters. "
|
||||||
"Preserve the language and markdown formatting. "
|
"Preserve the language and markdown formatting. "
|
||||||
"Do not provide any other information. "
|
"Do not provide any other information. "
|
||||||
"Preserve the language."
|
"Preserve the language."
|
||||||
|
|||||||
@@ -41,3 +41,35 @@ class CollaborationService:
|
|||||||
f"Failed to notify WebSocket server. Status code: {response.status_code}, "
|
f"Failed to notify WebSocket server. Status code: {response.status_code}, "
|
||||||
f"Response: {response.text}"
|
f"Response: {response.text}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def get_document_connection_info(self, room, session_key):
|
||||||
|
"""
|
||||||
|
Get the connection info for a document.
|
||||||
|
"""
|
||||||
|
endpoint = "get-connections"
|
||||||
|
querystring = {
|
||||||
|
"room": room,
|
||||||
|
"sessionKey": session_key,
|
||||||
|
}
|
||||||
|
endpoint_url = f"{settings.COLLABORATION_API_URL}{endpoint}/"
|
||||||
|
|
||||||
|
headers = {"Authorization": settings.COLLABORATION_SERVER_SECRET}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.get(
|
||||||
|
endpoint_url, headers=headers, params=querystring, timeout=10
|
||||||
|
)
|
||||||
|
except requests.RequestException as e:
|
||||||
|
raise requests.HTTPError("Failed to get document connection info.") from e
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
result = response.json()
|
||||||
|
return result.get("count", 0), result.get("exists", False)
|
||||||
|
|
||||||
|
if response.status_code == 404:
|
||||||
|
return 0, False
|
||||||
|
|
||||||
|
raise requests.HTTPError(
|
||||||
|
f"Failed to get document connection info. Status code: {response.status_code}, "
|
||||||
|
f"Response: {response.text}"
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
"""Converter services."""
|
"""Y-Provider API services."""
|
||||||
|
|
||||||
|
from base64 import b64encode
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
@@ -17,14 +19,6 @@ class ServiceUnavailableError(ConversionError):
|
|||||||
"""Raised when the conversion service is unavailable."""
|
"""Raised when the conversion service is unavailable."""
|
||||||
|
|
||||||
|
|
||||||
class InvalidResponseError(ConversionError):
|
|
||||||
"""Raised when the conversion service returns an invalid response."""
|
|
||||||
|
|
||||||
|
|
||||||
class MissingContentError(ConversionError):
|
|
||||||
"""Raised when the response is missing required content."""
|
|
||||||
|
|
||||||
|
|
||||||
class YdocConverter:
|
class YdocConverter:
|
||||||
"""Service class for conversion-related operations."""
|
"""Service class for conversion-related operations."""
|
||||||
|
|
||||||
@@ -32,47 +26,47 @@ class YdocConverter:
|
|||||||
def auth_header(self):
|
def auth_header(self):
|
||||||
"""Build microservice authentication header."""
|
"""Build microservice authentication header."""
|
||||||
# Note: Yprovider microservice accepts only raw token, which is not recommended
|
# Note: Yprovider microservice accepts only raw token, which is not recommended
|
||||||
return settings.Y_PROVIDER_API_KEY
|
return f"Bearer {settings.Y_PROVIDER_API_KEY}"
|
||||||
|
|
||||||
def convert_markdown(self, text):
|
def _request(self, url, data, content_type, accept):
|
||||||
|
"""Make a request to the Y-Provider API."""
|
||||||
|
response = requests.post(
|
||||||
|
url,
|
||||||
|
data=data,
|
||||||
|
headers={
|
||||||
|
"Authorization": self.auth_header,
|
||||||
|
"Content-Type": content_type,
|
||||||
|
"Accept": accept,
|
||||||
|
},
|
||||||
|
timeout=settings.CONVERSION_API_TIMEOUT,
|
||||||
|
verify=settings.CONVERSION_API_SECURE,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response
|
||||||
|
|
||||||
|
def convert(
|
||||||
|
self, text, content_type="text/markdown", accept="application/vnd.yjs.doc"
|
||||||
|
):
|
||||||
"""Convert a Markdown text into our internal format using an external microservice."""
|
"""Convert a Markdown text into our internal format using an external microservice."""
|
||||||
|
|
||||||
if not text:
|
if not text:
|
||||||
raise ValidationError("Input text cannot be empty")
|
raise ValidationError("Input text cannot be empty")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = requests.post(
|
response = self._request(
|
||||||
f"{settings.Y_PROVIDER_API_BASE_URL}{settings.CONVERSION_API_ENDPOINT}/",
|
f"{settings.Y_PROVIDER_API_BASE_URL}{settings.CONVERSION_API_ENDPOINT}/",
|
||||||
json={
|
text,
|
||||||
"content": text,
|
content_type,
|
||||||
},
|
accept,
|
||||||
headers={
|
|
||||||
"Authorization": self.auth_header,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
timeout=settings.CONVERSION_API_TIMEOUT,
|
|
||||||
verify=settings.CONVERSION_API_SECURE,
|
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
if accept == "application/vnd.yjs.doc":
|
||||||
conversion_response = response.json()
|
return b64encode(response.content).decode("utf-8")
|
||||||
|
if accept in {"text/markdown", "text/html"}:
|
||||||
|
return response.text
|
||||||
|
if accept == "application/json":
|
||||||
|
return response.json()
|
||||||
|
raise ValidationError("Unsupported format")
|
||||||
except requests.RequestException as err:
|
except requests.RequestException as err:
|
||||||
raise ServiceUnavailableError(
|
raise ServiceUnavailableError(
|
||||||
"Failed to connect to conversion service",
|
"Failed to connect to conversion service",
|
||||||
) from err
|
) from err
|
||||||
|
|
||||||
except ValueError as err:
|
|
||||||
raise InvalidResponseError(
|
|
||||||
"Could not parse conversion service response"
|
|
||||||
) from err
|
|
||||||
|
|
||||||
try:
|
|
||||||
document_content = conversion_response[
|
|
||||||
settings.CONVERSION_API_CONTENT_FIELD
|
|
||||||
]
|
|
||||||
except KeyError as err:
|
|
||||||
raise MissingContentError(
|
|
||||||
f"Response missing required field: {settings.CONVERSION_API_CONTENT_FIELD}"
|
|
||||||
) from err
|
|
||||||
|
|
||||||
return document_content
|
|
||||||
|
|||||||
0
src/backend/core/tasks/__init__.py
Normal file
0
src/backend/core/tasks/__init__.py
Normal file
24
src/backend/core/tasks/mail.py
Normal file
24
src/backend/core/tasks/mail.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"""Send mail using celery task."""
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
from core import models
|
||||||
|
|
||||||
|
from impress.celery_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.task
|
||||||
|
def send_ask_for_access_mail(ask_for_access_id):
|
||||||
|
"""Send mail using celery task."""
|
||||||
|
# Send email to document owners/admins
|
||||||
|
ask_for_access = models.DocumentAskForAccess.objects.get(id=ask_for_access_id)
|
||||||
|
owner_admin_accesses = models.DocumentAccess.objects.filter(
|
||||||
|
document=ask_for_access.document, role__in=models.PRIVILEGED_ROLES
|
||||||
|
).select_related("user")
|
||||||
|
|
||||||
|
for access in owner_admin_accesses:
|
||||||
|
if access.user and access.user.email:
|
||||||
|
ask_for_access.send_ask_for_access_email(
|
||||||
|
access.user.email,
|
||||||
|
access.user.language or settings.LANGUAGE_CODE,
|
||||||
|
)
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Test document accesses API endpoints for users in impress's core app.
|
Test document accesses API endpoints for users in impress's core app.
|
||||||
"""
|
"""
|
||||||
|
# pylint: disable=too-many-lines
|
||||||
|
|
||||||
import random
|
import random
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
@@ -8,7 +9,7 @@ from uuid import uuid4
|
|||||||
import pytest
|
import pytest
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from core import factories, models
|
from core import choices, factories, models
|
||||||
from core.api import serializers
|
from core.api import serializers
|
||||||
from core.tests.conftest import TEAM, USER, VIA
|
from core.tests.conftest import TEAM, USER, VIA
|
||||||
from core.tests.test_services_collaboration_services import ( # pylint: disable=unused-import
|
from core.tests.test_services_collaboration_services import ( # pylint: disable=unused-import
|
||||||
@@ -51,12 +52,7 @@ def test_api_document_accesses_list_authenticated_unrelated():
|
|||||||
f"/api/v1.0/documents/{document.id!s}/accesses/",
|
f"/api/v1.0/documents/{document.id!s}/accesses/",
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == {
|
assert response.json() == []
|
||||||
"count": 0,
|
|
||||||
"next": None,
|
|
||||||
"previous": None,
|
|
||||||
"results": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_document_accesses_list_unexisting_document():
|
def test_api_document_accesses_list_unexisting_document():
|
||||||
@@ -69,39 +65,46 @@ def test_api_document_accesses_list_unexisting_document():
|
|||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
|
|
||||||
response = client.get(f"/api/v1.0/documents/{uuid4()!s}/accesses/")
|
response = client.get(f"/api/v1.0/documents/{uuid4()!s}/accesses/")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 404
|
||||||
assert response.json() == {
|
assert response.json() == {"detail": "Not found."}
|
||||||
"count": 0,
|
|
||||||
"next": None,
|
|
||||||
"previous": None,
|
|
||||||
"results": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("via", VIA)
|
@pytest.mark.parametrize("via", VIA)
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"role", [role for role in models.RoleChoices if role not in models.PRIVILEGED_ROLES]
|
"role",
|
||||||
|
[role for role in choices.RoleChoices if role not in choices.PRIVILEGED_ROLES],
|
||||||
)
|
)
|
||||||
def test_api_document_accesses_list_authenticated_related_non_privileged(
|
def test_api_document_accesses_list_authenticated_related_non_privileged(
|
||||||
via, role, mock_user_teams
|
via, role, mock_user_teams, django_assert_num_queries
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Authenticated users should be able to list document accesses for a document
|
Authenticated users with no privileged role should only be able to list document
|
||||||
to which they are directly related, whatever their role in the document.
|
accesses associated with privileged roles for a document, including from ancestors.
|
||||||
"""
|
"""
|
||||||
user = factories.UserFactory()
|
user = factories.UserFactory()
|
||||||
|
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
|
|
||||||
owner = factories.UserFactory()
|
# Create documents structured as a tree
|
||||||
accesses = []
|
unreadable_ancestor = factories.DocumentFactory(link_reach="restricted")
|
||||||
|
# make all documents below the grand parent readable without a specific access for the user
|
||||||
document_access = factories.UserDocumentAccessFactory(
|
grand_parent = factories.DocumentFactory(
|
||||||
user=owner, role=models.RoleChoices.OWNER
|
parent=unreadable_ancestor, link_reach="authenticated"
|
||||||
)
|
)
|
||||||
accesses.append(document_access)
|
parent = factories.DocumentFactory(parent=grand_parent)
|
||||||
document = document_access.document
|
document = factories.DocumentFactory(parent=parent)
|
||||||
|
child = factories.DocumentFactory(parent=document)
|
||||||
|
|
||||||
|
# Create accesses related to each document
|
||||||
|
accesses = (
|
||||||
|
factories.UserDocumentAccessFactory(document=unreadable_ancestor),
|
||||||
|
factories.UserDocumentAccessFactory(document=grand_parent),
|
||||||
|
factories.UserDocumentAccessFactory(document=parent),
|
||||||
|
factories.UserDocumentAccessFactory(document=document),
|
||||||
|
factories.TeamDocumentAccessFactory(document=document),
|
||||||
|
)
|
||||||
|
factories.UserDocumentAccessFactory(document=child)
|
||||||
|
|
||||||
if via == USER:
|
if via == USER:
|
||||||
models.DocumentAccess.objects.create(
|
models.DocumentAccess.objects.create(
|
||||||
document=document,
|
document=document,
|
||||||
@@ -116,33 +119,32 @@ def test_api_document_accesses_list_authenticated_related_non_privileged(
|
|||||||
role=role,
|
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
|
# Accesses for other documents to which the user is related should not be listed either
|
||||||
other_access = factories.UserDocumentAccessFactory(user=user)
|
other_access = factories.UserDocumentAccessFactory(user=user)
|
||||||
factories.UserDocumentAccessFactory(document=other_access.document)
|
factories.UserDocumentAccessFactory(document=other_access.document)
|
||||||
|
|
||||||
response = client.get(
|
with django_assert_num_queries(3):
|
||||||
f"/api/v1.0/documents/{document.id!s}/accesses/",
|
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
|
assert response.status_code == 200
|
||||||
content = response.json()
|
content = response.json()
|
||||||
assert content["count"] == len(owners_accesses)
|
|
||||||
assert sorted(content["results"], key=lambda x: x["id"]) == sorted(
|
# Make sure only privileged roles are returned
|
||||||
|
privileged_accesses = [
|
||||||
|
acc for acc in accesses if acc.role in choices.PRIVILEGED_ROLES
|
||||||
|
]
|
||||||
|
assert len(content) == len(privileged_accesses)
|
||||||
|
|
||||||
|
assert sorted(content, key=lambda x: x["id"]) == sorted(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"id": str(access.id),
|
"id": str(access.id),
|
||||||
|
"document": {
|
||||||
|
"id": str(access.document_id),
|
||||||
|
"path": access.document.path,
|
||||||
|
"depth": access.document.depth,
|
||||||
|
},
|
||||||
"user": {
|
"user": {
|
||||||
"id": None,
|
|
||||||
"email": None,
|
|
||||||
"full_name": access.user.full_name,
|
"full_name": access.user.full_name,
|
||||||
"short_name": access.user.short_name,
|
"short_name": access.user.short_name,
|
||||||
}
|
}
|
||||||
@@ -150,40 +152,47 @@ def test_api_document_accesses_list_authenticated_related_non_privileged(
|
|||||||
else None,
|
else None,
|
||||||
"team": access.team,
|
"team": access.team,
|
||||||
"role": access.role,
|
"role": access.role,
|
||||||
"abilities": access.get_abilities(user),
|
"max_ancestors_role": None,
|
||||||
|
"max_role": access.role,
|
||||||
|
"abilities": {
|
||||||
|
"destroy": False,
|
||||||
|
"partial_update": False,
|
||||||
|
"retrieve": False,
|
||||||
|
"set_role_to": [],
|
||||||
|
"update": False,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for access in owners_accesses
|
for access in privileged_accesses
|
||||||
],
|
],
|
||||||
key=lambda x: x["id"],
|
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("via", VIA)
|
||||||
@pytest.mark.parametrize("role", models.PRIVILEGED_ROLES)
|
@pytest.mark.parametrize(
|
||||||
def test_api_document_accesses_list_authenticated_related_privileged_roles(
|
"role", [role for role in choices.RoleChoices if role in choices.PRIVILEGED_ROLES]
|
||||||
via, role, mock_user_teams
|
)
|
||||||
|
def test_api_document_accesses_list_authenticated_related_privileged(
|
||||||
|
via, role, mock_user_teams, django_assert_num_queries
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Authenticated users should be able to list document accesses for a document
|
Authenticated users with a privileged role should be able to list all
|
||||||
to which they are directly related, whatever their role in the document.
|
document accesses whatever the role, including from ancestors.
|
||||||
"""
|
"""
|
||||||
user = factories.UserFactory()
|
user = factories.UserFactory()
|
||||||
|
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
|
|
||||||
owner = factories.UserFactory()
|
# Create documents structured as a tree
|
||||||
accesses = []
|
unreadable_ancestor = factories.DocumentFactory(link_reach="restricted")
|
||||||
|
# make all documents below the grand parent readable without a specific access for the user
|
||||||
document_access = factories.UserDocumentAccessFactory(
|
grand_parent = factories.DocumentFactory(
|
||||||
user=owner, role=models.RoleChoices.OWNER
|
parent=unreadable_ancestor, link_reach="authenticated"
|
||||||
)
|
)
|
||||||
accesses.append(document_access)
|
parent = factories.DocumentFactory(parent=grand_parent)
|
||||||
document = document_access.document
|
document = factories.DocumentFactory(parent=parent)
|
||||||
user_access = None
|
child = factories.DocumentFactory(parent=document)
|
||||||
|
|
||||||
if via == USER:
|
if via == USER:
|
||||||
user_access = models.DocumentAccess.objects.create(
|
user_access = models.DocumentAccess.objects.create(
|
||||||
document=document,
|
document=document,
|
||||||
@@ -197,61 +206,319 @@ def test_api_document_accesses_list_authenticated_related_privileged_roles(
|
|||||||
team="lasuite",
|
team="lasuite",
|
||||||
role=role,
|
role=role,
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
raise RuntimeError()
|
||||||
|
|
||||||
access1 = factories.TeamDocumentAccessFactory(document=document)
|
# Create accesses related to each document
|
||||||
access2 = factories.UserDocumentAccessFactory(document=document)
|
ancestors_accesses = [
|
||||||
accesses.append(access1)
|
# Access on unreadable ancestor should still be listed
|
||||||
accesses.append(access2)
|
# as the related user gains access to our document
|
||||||
|
factories.UserDocumentAccessFactory(document=unreadable_ancestor),
|
||||||
|
factories.UserDocumentAccessFactory(document=grand_parent),
|
||||||
|
factories.UserDocumentAccessFactory(document=parent),
|
||||||
|
]
|
||||||
|
document_accesses = [
|
||||||
|
factories.UserDocumentAccessFactory(document=document),
|
||||||
|
factories.TeamDocumentAccessFactory(document=document),
|
||||||
|
factories.UserDocumentAccessFactory(document=document),
|
||||||
|
user_access,
|
||||||
|
]
|
||||||
|
factories.UserDocumentAccessFactory(document=child)
|
||||||
|
|
||||||
# Accesses for other documents to which the user is related should not be listed either
|
# Accesses for other documents to which the user is related should not be listed either
|
||||||
other_access = factories.UserDocumentAccessFactory(user=user)
|
other_access = factories.UserDocumentAccessFactory(user=user)
|
||||||
factories.UserDocumentAccessFactory(document=other_access.document)
|
factories.UserDocumentAccessFactory(document=other_access.document)
|
||||||
|
|
||||||
response = client.get(
|
with django_assert_num_queries(3):
|
||||||
f"/api/v1.0/documents/{document.id!s}/accesses/",
|
response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/")
|
||||||
)
|
|
||||||
|
|
||||||
access2_user = serializers.UserSerializer(instance=access2.user).data
|
|
||||||
base_user = serializers.UserSerializer(instance=user).data
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
content = response.json()
|
content = response.json()
|
||||||
assert len(content["results"]) == 4
|
assert len(content) == 7
|
||||||
assert sorted(content["results"], key=lambda x: x["id"]) == sorted(
|
assert sorted(content, key=lambda x: x["id"]) == sorted(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"id": str(user_access.id),
|
"id": str(access.id),
|
||||||
"user": base_user if via == "user" else None,
|
"document": {
|
||||||
"team": "lasuite" if via == "team" else "",
|
"id": str(access.document_id),
|
||||||
"role": user_access.role,
|
"path": access.document.path,
|
||||||
"abilities": user_access.get_abilities(user),
|
"depth": access.document.depth,
|
||||||
},
|
},
|
||||||
{
|
"user": {
|
||||||
"id": str(access1.id),
|
"id": str(access.user.id),
|
||||||
"user": None,
|
"email": access.user.email,
|
||||||
"team": access1.team,
|
"language": access.user.language,
|
||||||
"role": access1.role,
|
"full_name": access.user.full_name,
|
||||||
"abilities": access1.get_abilities(user),
|
"short_name": access.user.short_name,
|
||||||
},
|
}
|
||||||
{
|
if access.user
|
||||||
"id": str(access2.id),
|
else None,
|
||||||
"user": access2_user,
|
"max_ancestors_role": None,
|
||||||
"team": "",
|
"max_role": access.role,
|
||||||
"role": access2.role,
|
"team": access.team,
|
||||||
"abilities": access2.get_abilities(user),
|
"role": access.role,
|
||||||
},
|
"abilities": access.get_abilities(user),
|
||||||
{
|
}
|
||||||
"id": str(document_access.id),
|
for access in ancestors_accesses + document_accesses
|
||||||
"user": serializers.UserSerializer(instance=owner).data,
|
|
||||||
"team": "",
|
|
||||||
"role": models.RoleChoices.OWNER,
|
|
||||||
"abilities": document_access.get_abilities(user),
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
key=lambda x: x["id"],
|
key=lambda x: x["id"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_document_accesses_retrieve_set_role_to_child():
|
||||||
|
"""Check set_role_to for an access with no access on the ancestor."""
|
||||||
|
user, other_user = factories.UserFactory.create_batch(2)
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
parent = factories.DocumentFactory()
|
||||||
|
parent_access = factories.UserDocumentAccessFactory(
|
||||||
|
document=parent, user=user, role="owner"
|
||||||
|
)
|
||||||
|
|
||||||
|
document = factories.DocumentFactory(parent=parent)
|
||||||
|
document_access_other_user = factories.UserDocumentAccessFactory(
|
||||||
|
document=document, user=other_user, role="editor"
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
content = response.json()
|
||||||
|
assert len(content) == 2
|
||||||
|
|
||||||
|
result_dict = {
|
||||||
|
result["id"]: result["abilities"]["set_role_to"] for result in content
|
||||||
|
}
|
||||||
|
assert result_dict[str(document_access_other_user.id)] == [
|
||||||
|
"reader",
|
||||||
|
"editor",
|
||||||
|
"administrator",
|
||||||
|
"owner",
|
||||||
|
]
|
||||||
|
assert result_dict[str(parent_access.id)] == []
|
||||||
|
|
||||||
|
# Add an access for the other user on the parent
|
||||||
|
parent_access_other_user = factories.UserDocumentAccessFactory(
|
||||||
|
document=parent, user=other_user, role="editor"
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
content = response.json()
|
||||||
|
assert len(content) == 3
|
||||||
|
|
||||||
|
result_dict = {
|
||||||
|
result["id"]: result["abilities"]["set_role_to"] for result in content
|
||||||
|
}
|
||||||
|
assert result_dict[str(document_access_other_user.id)] == [
|
||||||
|
"editor",
|
||||||
|
"administrator",
|
||||||
|
"owner",
|
||||||
|
]
|
||||||
|
assert result_dict[str(parent_access.id)] == []
|
||||||
|
assert result_dict[str(parent_access_other_user.id)] == [
|
||||||
|
"reader",
|
||||||
|
"editor",
|
||||||
|
"administrator",
|
||||||
|
"owner",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"roles,results",
|
||||||
|
[
|
||||||
|
[
|
||||||
|
["administrator", "reader", "reader", "reader"],
|
||||||
|
[
|
||||||
|
["reader", "editor", "administrator"],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
["reader", "editor", "administrator"],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
["owner", "reader", "reader", "reader"],
|
||||||
|
[
|
||||||
|
["reader", "editor", "administrator", "owner"],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
["reader", "editor", "administrator", "owner"],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
["owner", "reader", "reader", "owner"],
|
||||||
|
[
|
||||||
|
["reader", "editor", "administrator", "owner"],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
["reader", "editor", "administrator", "owner"],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_api_document_accesses_list_authenticated_related_same_user(roles, results):
|
||||||
|
"""
|
||||||
|
The maximum role across ancestor documents and set_role_to optionsfor
|
||||||
|
a given user should be filled as expected.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
# Create documents structured as a tree
|
||||||
|
grand_parent = factories.DocumentFactory(link_reach="authenticated")
|
||||||
|
parent = factories.DocumentFactory(parent=grand_parent)
|
||||||
|
document = factories.DocumentFactory(parent=parent)
|
||||||
|
|
||||||
|
# Create accesses for another user
|
||||||
|
other_user = factories.UserFactory()
|
||||||
|
accesses = [
|
||||||
|
factories.UserDocumentAccessFactory(
|
||||||
|
document=document, user=user, role=roles[0]
|
||||||
|
),
|
||||||
|
factories.UserDocumentAccessFactory(
|
||||||
|
document=grand_parent, user=other_user, role=roles[1]
|
||||||
|
),
|
||||||
|
factories.UserDocumentAccessFactory(
|
||||||
|
document=parent, user=other_user, role=roles[2]
|
||||||
|
),
|
||||||
|
factories.UserDocumentAccessFactory(
|
||||||
|
document=document, user=other_user, role=roles[3]
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
content = response.json()
|
||||||
|
assert len(content) == 4
|
||||||
|
|
||||||
|
for result in content:
|
||||||
|
assert (
|
||||||
|
result["max_ancestors_role"] is None
|
||||||
|
if result["user"]["id"] == str(user.id)
|
||||||
|
else choices.RoleChoices.max(roles[1], roles[2])
|
||||||
|
)
|
||||||
|
|
||||||
|
result_dict = {
|
||||||
|
result["id"]: result["abilities"]["set_role_to"] for result in content
|
||||||
|
}
|
||||||
|
assert [result_dict[str(access.id)] for access in accesses] == results
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"roles,results",
|
||||||
|
[
|
||||||
|
[
|
||||||
|
["administrator", "reader", "reader", "reader"],
|
||||||
|
[
|
||||||
|
["reader", "editor", "administrator"],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
["reader", "editor", "administrator"],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
["owner", "reader", "reader", "reader"],
|
||||||
|
[
|
||||||
|
["reader", "editor", "administrator", "owner"],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
["reader", "editor", "administrator", "owner"],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
["owner", "reader", "reader", "owner"],
|
||||||
|
[
|
||||||
|
["reader", "editor", "administrator", "owner"],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
["reader", "editor", "administrator", "owner"],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
["reader", "reader", "reader", "owner"],
|
||||||
|
[
|
||||||
|
["reader", "editor", "administrator", "owner"],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
["reader", "editor", "administrator", "owner"],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
["reader", "administrator", "reader", "editor"],
|
||||||
|
[
|
||||||
|
["reader", "editor", "administrator"],
|
||||||
|
["reader", "editor", "administrator"],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
["editor", "editor", "administrator", "editor"],
|
||||||
|
[
|
||||||
|
["reader", "editor", "administrator"],
|
||||||
|
[],
|
||||||
|
["editor", "administrator"],
|
||||||
|
[],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_api_document_accesses_list_authenticated_related_same_team(
|
||||||
|
roles, results, mock_user_teams
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
The maximum role across ancestor documents and set_role_to optionsfor
|
||||||
|
a given team should be filled as expected.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
# Create documents structured as a tree
|
||||||
|
grand_parent = factories.DocumentFactory(link_reach="authenticated")
|
||||||
|
parent = factories.DocumentFactory(parent=grand_parent)
|
||||||
|
document = factories.DocumentFactory(parent=parent)
|
||||||
|
|
||||||
|
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||||
|
accesses = [
|
||||||
|
factories.UserDocumentAccessFactory(
|
||||||
|
document=document, user=user, role=roles[0]
|
||||||
|
),
|
||||||
|
# Create accesses for a team
|
||||||
|
factories.TeamDocumentAccessFactory(
|
||||||
|
document=grand_parent, team="lasuite", role=roles[1]
|
||||||
|
),
|
||||||
|
factories.TeamDocumentAccessFactory(
|
||||||
|
document=parent, team="lasuite", role=roles[2]
|
||||||
|
),
|
||||||
|
factories.TeamDocumentAccessFactory(
|
||||||
|
document=document, team="lasuite", role=roles[3]
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
content = response.json()
|
||||||
|
assert len(content) == 4
|
||||||
|
|
||||||
|
for result in content:
|
||||||
|
assert (
|
||||||
|
result["max_ancestors_role"] is None
|
||||||
|
if result["user"] and result["user"]["id"] == str(user.id)
|
||||||
|
else choices.RoleChoices.max(roles[1], roles[2])
|
||||||
|
)
|
||||||
|
|
||||||
|
result_dict = {
|
||||||
|
result["id"]: result["abilities"]["set_role_to"] for result in content
|
||||||
|
}
|
||||||
|
assert [result_dict[str(access.id)] for access in accesses] == results
|
||||||
|
|
||||||
|
|
||||||
def test_api_document_accesses_retrieve_anonymous():
|
def test_api_document_accesses_retrieve_anonymous():
|
||||||
"""
|
"""
|
||||||
Anonymous users should not be allowed to retrieve a document access.
|
Anonymous users should not be allowed to retrieve a document access.
|
||||||
@@ -307,7 +574,9 @@ def test_api_document_accesses_retrieve_authenticated_unrelated():
|
|||||||
@pytest.mark.parametrize("via", VIA)
|
@pytest.mark.parametrize("via", VIA)
|
||||||
@pytest.mark.parametrize("role", models.RoleChoices)
|
@pytest.mark.parametrize("role", models.RoleChoices)
|
||||||
def test_api_document_accesses_retrieve_authenticated_related(
|
def test_api_document_accesses_retrieve_authenticated_related(
|
||||||
via, role, mock_user_teams
|
via,
|
||||||
|
role,
|
||||||
|
mock_user_teams,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
A user who is related to a document should be allowed to retrieve the
|
A user who is related to a document should be allowed to retrieve the
|
||||||
@@ -333,7 +602,7 @@ def test_api_document_accesses_retrieve_authenticated_related(
|
|||||||
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||||
)
|
)
|
||||||
|
|
||||||
if not role in models.PRIVILEGED_ROLES:
|
if not role in choices.PRIVILEGED_ROLES:
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
else:
|
else:
|
||||||
access_user = serializers.UserSerializer(instance=access.user).data
|
access_user = serializers.UserSerializer(instance=access.user).data
|
||||||
@@ -341,9 +610,16 @@ def test_api_document_accesses_retrieve_authenticated_related(
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"id": str(access.id),
|
"id": str(access.id),
|
||||||
|
"document": {
|
||||||
|
"id": str(access.document_id),
|
||||||
|
"path": access.document.path,
|
||||||
|
"depth": access.document.depth,
|
||||||
|
},
|
||||||
"user": access_user,
|
"user": access_user,
|
||||||
"team": "",
|
"team": "",
|
||||||
"role": access.role,
|
"role": access.role,
|
||||||
|
"max_ancestors_role": None,
|
||||||
|
"max_role": access.role,
|
||||||
"abilities": access.get_abilities(user),
|
"abilities": access.get_abilities(user),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -448,7 +724,9 @@ def test_api_document_accesses_update_authenticated_reader_or_editor(
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("via", VIA)
|
@pytest.mark.parametrize("via", VIA)
|
||||||
|
@pytest.mark.parametrize("create_for", VIA)
|
||||||
def test_api_document_accesses_update_administrator_except_owner(
|
def test_api_document_accesses_update_administrator_except_owner(
|
||||||
|
create_for,
|
||||||
via,
|
via,
|
||||||
mock_user_teams,
|
mock_user_teams,
|
||||||
mock_reset_connections, # pylint: disable=redefined-outer-name
|
mock_reset_connections, # pylint: disable=redefined-outer-name
|
||||||
@@ -481,32 +759,31 @@ def test_api_document_accesses_update_administrator_except_owner(
|
|||||||
|
|
||||||
new_values = {
|
new_values = {
|
||||||
"id": uuid4(),
|
"id": uuid4(),
|
||||||
"user_id": factories.UserFactory().id,
|
|
||||||
"role": random.choice(["administrator", "editor", "reader"]),
|
"role": random.choice(["administrator", "editor", "reader"]),
|
||||||
}
|
}
|
||||||
|
if create_for == USER:
|
||||||
|
new_values["user_id"] = factories.UserFactory().id
|
||||||
|
elif create_for == TEAM:
|
||||||
|
new_values["team"] = "new-team"
|
||||||
|
|
||||||
for field, value in new_values.items():
|
for field, value in new_values.items():
|
||||||
new_data = {**old_values, field: value}
|
new_data = {**old_values, field: value}
|
||||||
if new_data["role"] == old_values["role"]:
|
with mock_reset_connections(document.id, str(access.user_id)):
|
||||||
response = client.put(
|
response = client.put(
|
||||||
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||||
data=new_data,
|
data=new_data,
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
assert response.status_code == 403
|
assert response.status_code == 200
|
||||||
else:
|
|
||||||
with mock_reset_connections(document.id, str(access.user_id)):
|
|
||||||
response = client.put(
|
|
||||||
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
|
||||||
data=new_data,
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
access.refresh_from_db()
|
access.refresh_from_db()
|
||||||
updated_values = serializers.DocumentAccessSerializer(instance=access).data
|
updated_values = serializers.DocumentAccessSerializer(instance=access).data
|
||||||
if field == "role":
|
if field in ["role", "max_role"]:
|
||||||
assert updated_values == {**old_values, "role": new_values["role"]}
|
assert updated_values == {
|
||||||
|
**old_values,
|
||||||
|
"role": new_values["role"],
|
||||||
|
"max_role": new_values["role"],
|
||||||
|
}
|
||||||
else:
|
else:
|
||||||
assert updated_values == old_values
|
assert updated_values == old_values
|
||||||
|
|
||||||
@@ -601,7 +878,7 @@ def test_api_document_accesses_update_administrator_to_owner(
|
|||||||
for field, value in new_values.items():
|
for field, value in new_values.items():
|
||||||
new_data = {**old_values, field: value}
|
new_data = {**old_values, field: value}
|
||||||
# We are not allowed or not really updating the role
|
# We are not allowed or not really updating the role
|
||||||
if field == "role" or new_data["role"] == old_values["role"]:
|
if field == "role":
|
||||||
response = client.put(
|
response = client.put(
|
||||||
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||||
data=new_data,
|
data=new_data,
|
||||||
@@ -624,7 +901,9 @@ def test_api_document_accesses_update_administrator_to_owner(
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("via", VIA)
|
@pytest.mark.parametrize("via", VIA)
|
||||||
|
@pytest.mark.parametrize("create_for", VIA)
|
||||||
def test_api_document_accesses_update_owner(
|
def test_api_document_accesses_update_owner(
|
||||||
|
create_for,
|
||||||
via,
|
via,
|
||||||
mock_user_teams,
|
mock_user_teams,
|
||||||
mock_reset_connections, # pylint: disable=redefined-outer-name
|
mock_reset_connections, # pylint: disable=redefined-outer-name
|
||||||
@@ -655,42 +934,39 @@ def test_api_document_accesses_update_owner(
|
|||||||
|
|
||||||
new_values = {
|
new_values = {
|
||||||
"id": uuid4(),
|
"id": uuid4(),
|
||||||
"user_id": factories.UserFactory().id,
|
|
||||||
"role": random.choice(models.RoleChoices.values),
|
"role": random.choice(models.RoleChoices.values),
|
||||||
}
|
}
|
||||||
|
if create_for == USER:
|
||||||
|
new_values["user_id"] = factories.UserFactory().id
|
||||||
|
elif create_for == TEAM:
|
||||||
|
new_values["team"] = "new-team"
|
||||||
|
|
||||||
for field, value in new_values.items():
|
for field, value in new_values.items():
|
||||||
new_data = {**old_values, field: value}
|
new_data = {**old_values, field: value}
|
||||||
if (
|
with mock_reset_connections(document.id, str(access.user_id)):
|
||||||
new_data["role"] == old_values["role"]
|
|
||||||
): # we are not really updating the role
|
|
||||||
response = client.put(
|
response = client.put(
|
||||||
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||||
data=new_data,
|
data=new_data,
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
assert response.status_code == 403
|
|
||||||
else:
|
|
||||||
with mock_reset_connections(document.id, str(access.user_id)):
|
|
||||||
response = client.put(
|
|
||||||
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
|
||||||
data=new_data,
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
access.refresh_from_db()
|
access.refresh_from_db()
|
||||||
updated_values = serializers.DocumentAccessSerializer(instance=access).data
|
updated_values = serializers.DocumentAccessSerializer(instance=access).data
|
||||||
|
|
||||||
if field == "role":
|
if field in ["role", "max_role"]:
|
||||||
assert updated_values == {**old_values, "role": new_values["role"]}
|
assert updated_values == {
|
||||||
|
**old_values,
|
||||||
|
"role": new_values["role"],
|
||||||
|
"max_role": new_values["role"],
|
||||||
|
}
|
||||||
else:
|
else:
|
||||||
assert updated_values == old_values
|
assert updated_values == old_values
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("via", VIA)
|
@pytest.mark.parametrize("via", VIA)
|
||||||
def test_api_document_accesses_update_owner_self(
|
def test_api_document_accesses_update_owner_self_root(
|
||||||
via,
|
via,
|
||||||
mock_user_teams,
|
mock_user_teams,
|
||||||
mock_reset_connections, # pylint: disable=redefined-outer-name
|
mock_reset_connections, # pylint: disable=redefined-outer-name
|
||||||
@@ -751,6 +1027,51 @@ def test_api_document_accesses_update_owner_self(
|
|||||||
assert access.role == new_role
|
assert access.role == new_role
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("via", VIA)
|
||||||
|
def test_api_document_accesses_update_owner_self_child(
|
||||||
|
via,
|
||||||
|
mock_user_teams,
|
||||||
|
mock_reset_connections, # pylint: disable=redefined-outer-name
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
A user who is owner of a document should be allowed to update
|
||||||
|
their own user access even if they are the only owner in the document,
|
||||||
|
provided the document is not a root.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory(with_owned_document=True)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
parent = factories.DocumentFactory()
|
||||||
|
document = factories.DocumentFactory(parent=parent)
|
||||||
|
access = None
|
||||||
|
if via == USER:
|
||||||
|
access = factories.UserDocumentAccessFactory(
|
||||||
|
document=document, user=user, role="owner"
|
||||||
|
)
|
||||||
|
elif via == TEAM:
|
||||||
|
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||||
|
access = factories.TeamDocumentAccessFactory(
|
||||||
|
document=document, team="lasuite", role="owner"
|
||||||
|
)
|
||||||
|
|
||||||
|
old_values = serializers.DocumentAccessSerializer(instance=access).data
|
||||||
|
new_role = random.choice(["administrator", "editor", "reader"])
|
||||||
|
|
||||||
|
user_id = str(access.user_id) if via == USER else None
|
||||||
|
with mock_reset_connections(document.id, user_id):
|
||||||
|
response = client.put(
|
||||||
|
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||||
|
data={**old_values, "role": new_role},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
access.refresh_from_db()
|
||||||
|
assert access.role == new_role
|
||||||
|
|
||||||
|
|
||||||
# Delete
|
# Delete
|
||||||
|
|
||||||
|
|
||||||
@@ -931,17 +1252,16 @@ def test_api_document_accesses_delete_owners(
|
|||||||
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 204
|
assert response.status_code == 204
|
||||||
assert models.DocumentAccess.objects.count() == 1
|
assert models.DocumentAccess.objects.count() == 1
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("via", VIA)
|
@pytest.mark.parametrize("via", VIA)
|
||||||
def test_api_document_accesses_delete_owners_last_owner(via, mock_user_teams):
|
def test_api_document_accesses_delete_owners_last_owner_root(via, mock_user_teams):
|
||||||
"""
|
"""
|
||||||
It should not be possible to delete the last owner access from a document
|
It should not be possible to delete the last owner access from a root document
|
||||||
"""
|
"""
|
||||||
user = factories.UserFactory(with_owned_document=True)
|
user = factories.UserFactory(with_owned_document=True)
|
||||||
|
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
|
|
||||||
@@ -964,3 +1284,63 @@ def test_api_document_accesses_delete_owners_last_owner(via, mock_user_teams):
|
|||||||
|
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
assert models.DocumentAccess.objects.count() == 2
|
assert models.DocumentAccess.objects.count() == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_document_accesses_delete_owners_last_owner_child_user(
|
||||||
|
mock_reset_connections, # pylint: disable=redefined-outer-name
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
It should be possible to delete the last owner access from a document that is not a root.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory(with_owned_document=True)
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
parent = factories.DocumentFactory()
|
||||||
|
document = factories.DocumentFactory(parent=parent)
|
||||||
|
access = None
|
||||||
|
access = factories.UserDocumentAccessFactory(
|
||||||
|
document=document, user=user, role="owner"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert models.DocumentAccess.objects.count() == 2
|
||||||
|
with mock_reset_connections(document.id, str(access.user_id)):
|
||||||
|
response = client.delete(
|
||||||
|
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 204
|
||||||
|
assert models.DocumentAccess.objects.count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skip(
|
||||||
|
reason="Pending fix on https://github.com/suitenumerique/docs/issues/969"
|
||||||
|
)
|
||||||
|
def test_api_document_accesses_delete_owners_last_owner_child_team(
|
||||||
|
mock_user_teams,
|
||||||
|
mock_reset_connections, # pylint: disable=redefined-outer-name
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
It should be possible to delete the last owner access from a document that
|
||||||
|
is not a root.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory(with_owned_document=True)
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
parent = factories.DocumentFactory()
|
||||||
|
document = factories.DocumentFactory(parent=parent)
|
||||||
|
access = None
|
||||||
|
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||||
|
access = factories.TeamDocumentAccessFactory(
|
||||||
|
document=document, team="lasuite", role="owner"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert models.DocumentAccess.objects.count() == 2
|
||||||
|
with mock_reset_connections(document.id, str(access.user_id)):
|
||||||
|
response = client.delete(
|
||||||
|
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 204
|
||||||
|
assert models.DocumentAccess.objects.count() == 1
|
||||||
|
|||||||
@@ -103,32 +103,37 @@ def test_api_document_accesses_create_authenticated_reader_or_editor(
|
|||||||
assert not models.DocumentAccess.objects.filter(user=other_user).exists()
|
assert not models.DocumentAccess.objects.filter(user=other_user).exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("depth", [1, 2, 3])
|
||||||
@pytest.mark.parametrize("via", VIA)
|
@pytest.mark.parametrize("via", VIA)
|
||||||
def test_api_document_accesses_create_authenticated_administrator(via, mock_user_teams):
|
def test_api_document_accesses_create_authenticated_administrator_share_to_user(
|
||||||
|
via, depth, mock_user_teams
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Administrators of a document should be able to create document accesses
|
Administrators of a document (direct or by heritage) should be able to create
|
||||||
except for the "owner" role.
|
document accesses except for the "owner" role.
|
||||||
An email should be sent to the accesses to notify them of the adding.
|
An email should be sent to the accesses to notify them of the adding.
|
||||||
"""
|
"""
|
||||||
user = factories.UserFactory(with_owned_document=True)
|
user = factories.UserFactory(with_owned_document=True)
|
||||||
|
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
|
|
||||||
document = factories.DocumentFactory()
|
documents = []
|
||||||
|
for i in range(depth):
|
||||||
|
parent = documents[i - 1] if i > 0 else None
|
||||||
|
documents.append(factories.DocumentFactory(parent=parent))
|
||||||
|
|
||||||
if via == USER:
|
if via == USER:
|
||||||
factories.UserDocumentAccessFactory(
|
factories.UserDocumentAccessFactory(
|
||||||
document=document, user=user, role="administrator"
|
document=documents[0], user=user, role="administrator"
|
||||||
)
|
)
|
||||||
elif via == TEAM:
|
elif via == TEAM:
|
||||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||||
factories.TeamDocumentAccessFactory(
|
factories.TeamDocumentAccessFactory(
|
||||||
document=document, team="lasuite", role="administrator"
|
document=documents[0], team="lasuite", role="administrator"
|
||||||
)
|
)
|
||||||
|
|
||||||
other_user = factories.UserFactory(language="en-us")
|
other_user = factories.UserFactory(language="en-us")
|
||||||
|
document = documents[-1]
|
||||||
# It should not be allowed to create an owner access
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
f"/api/v1.0/documents/{document.id!s}/accesses/",
|
f"/api/v1.0/documents/{document.id!s}/accesses/",
|
||||||
{
|
{
|
||||||
@@ -140,7 +145,7 @@ def test_api_document_accesses_create_authenticated_administrator(via, mock_user
|
|||||||
|
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"detail": "Only owners of a resource can assign other users as owners."
|
"detail": "Only owners of a document can assign other users as owners."
|
||||||
}
|
}
|
||||||
|
|
||||||
# It should be allowed to create a lower access
|
# It should be allowed to create a lower access
|
||||||
@@ -165,9 +170,16 @@ def test_api_document_accesses_create_authenticated_administrator(via, mock_user
|
|||||||
other_user = serializers.UserSerializer(instance=other_user).data
|
other_user = serializers.UserSerializer(instance=other_user).data
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"abilities": new_document_access.get_abilities(user),
|
"abilities": new_document_access.get_abilities(user),
|
||||||
|
"document": {
|
||||||
|
"id": str(new_document_access.document_id),
|
||||||
|
"depth": new_document_access.document.depth,
|
||||||
|
"path": new_document_access.document.path,
|
||||||
|
},
|
||||||
"id": str(new_document_access.id),
|
"id": str(new_document_access.id),
|
||||||
"team": "",
|
"max_ancestors_role": None,
|
||||||
|
"max_role": role,
|
||||||
"role": role,
|
"role": role,
|
||||||
|
"team": "",
|
||||||
"user": other_user,
|
"user": other_user,
|
||||||
}
|
}
|
||||||
assert len(mail.outbox) == 1
|
assert len(mail.outbox) == 1
|
||||||
@@ -182,28 +194,119 @@ def test_api_document_accesses_create_authenticated_administrator(via, mock_user
|
|||||||
assert "docs/" + str(document.id) + "/" in email_content
|
assert "docs/" + str(document.id) + "/" in email_content
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("depth", [1, 2, 3])
|
||||||
@pytest.mark.parametrize("via", VIA)
|
@pytest.mark.parametrize("via", VIA)
|
||||||
def test_api_document_accesses_create_authenticated_owner(via, mock_user_teams):
|
def test_api_document_accesses_create_authenticated_administrator_share_to_team(
|
||||||
|
via, depth, mock_user_teams
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Owners of a document should be able to create document accesses whatever the role.
|
Administrators of a document (direct or by heritage) should be able to create
|
||||||
|
document accesses except for the "owner" role.
|
||||||
An email should be sent to the accesses to notify them of the adding.
|
An email should be sent to the accesses to notify them of the adding.
|
||||||
"""
|
"""
|
||||||
|
user = factories.UserFactory(with_owned_document=True)
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
documents = []
|
||||||
|
for i in range(depth):
|
||||||
|
parent = documents[i - 1] if i > 0 else None
|
||||||
|
documents.append(factories.DocumentFactory(parent=parent))
|
||||||
|
|
||||||
|
if via == USER:
|
||||||
|
factories.UserDocumentAccessFactory(
|
||||||
|
document=documents[0], user=user, role="administrator"
|
||||||
|
)
|
||||||
|
elif via == TEAM:
|
||||||
|
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||||
|
factories.TeamDocumentAccessFactory(
|
||||||
|
document=documents[0], team="lasuite", role="administrator"
|
||||||
|
)
|
||||||
|
|
||||||
|
other_user = factories.UserFactory(language="en-us")
|
||||||
|
document = documents[-1]
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/documents/{document.id!s}/accesses/",
|
||||||
|
{
|
||||||
|
"team": "new-team",
|
||||||
|
"role": "owner",
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert response.json() == {
|
||||||
|
"detail": "Only owners of a document can assign other users as owners."
|
||||||
|
}
|
||||||
|
|
||||||
|
# It should be allowed to create a lower access
|
||||||
|
role = random.choice(
|
||||||
|
[role[0] for role in models.RoleChoices.choices if role[0] != "owner"]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(mail.outbox) == 0
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/documents/{document.id!s}/accesses/",
|
||||||
|
{
|
||||||
|
"team": "new-team",
|
||||||
|
"role": role,
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
assert models.DocumentAccess.objects.filter(team="new-team").count() == 1
|
||||||
|
new_document_access = models.DocumentAccess.objects.filter(team="new-team").get()
|
||||||
|
other_user = serializers.UserSerializer(instance=other_user).data
|
||||||
|
assert response.json() == {
|
||||||
|
"abilities": new_document_access.get_abilities(user),
|
||||||
|
"document": {
|
||||||
|
"id": str(new_document_access.document_id),
|
||||||
|
"depth": new_document_access.document.depth,
|
||||||
|
"path": new_document_access.document.path,
|
||||||
|
},
|
||||||
|
"id": str(new_document_access.id),
|
||||||
|
"max_ancestors_role": None,
|
||||||
|
"max_role": role,
|
||||||
|
"role": role,
|
||||||
|
"team": "new-team",
|
||||||
|
"user": None,
|
||||||
|
}
|
||||||
|
assert len(mail.outbox) == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("depth", [1, 2, 3])
|
||||||
|
@pytest.mark.parametrize("via", VIA)
|
||||||
|
def test_api_document_accesses_create_authenticated_owner_share_to_user(
|
||||||
|
via, depth, mock_user_teams
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Owners of a document (direct or by heritage) should be able to create document accesses
|
||||||
|
whatever the role. An email should be sent to the accesses to notify them of the adding.
|
||||||
|
"""
|
||||||
user = factories.UserFactory()
|
user = factories.UserFactory()
|
||||||
|
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
|
|
||||||
document = factories.DocumentFactory()
|
documents = []
|
||||||
|
for i in range(depth):
|
||||||
|
parent = documents[i - 1] if i > 0 else None
|
||||||
|
documents.append(factories.DocumentFactory(parent=parent))
|
||||||
|
|
||||||
if via == USER:
|
if via == USER:
|
||||||
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
|
factories.UserDocumentAccessFactory(
|
||||||
|
document=documents[0], user=user, role="owner"
|
||||||
|
)
|
||||||
elif via == TEAM:
|
elif via == TEAM:
|
||||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||||
factories.TeamDocumentAccessFactory(
|
factories.TeamDocumentAccessFactory(
|
||||||
document=document, team="lasuite", role="owner"
|
document=documents[0], team="lasuite", role="owner"
|
||||||
)
|
)
|
||||||
|
|
||||||
other_user = factories.UserFactory(language="en-us")
|
other_user = factories.UserFactory(language="en-us")
|
||||||
|
document = documents[-1]
|
||||||
role = random.choice([role[0] for role in models.RoleChoices.choices])
|
role = random.choice([role[0] for role in models.RoleChoices.choices])
|
||||||
|
|
||||||
assert len(mail.outbox) == 0
|
assert len(mail.outbox) == 0
|
||||||
@@ -222,11 +325,18 @@ def test_api_document_accesses_create_authenticated_owner(via, mock_user_teams):
|
|||||||
new_document_access = models.DocumentAccess.objects.filter(user=other_user).get()
|
new_document_access = models.DocumentAccess.objects.filter(user=other_user).get()
|
||||||
other_user = serializers.UserSerializer(instance=other_user).data
|
other_user = serializers.UserSerializer(instance=other_user).data
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"id": str(new_document_access.id),
|
|
||||||
"user": other_user,
|
|
||||||
"team": "",
|
|
||||||
"role": role,
|
|
||||||
"abilities": new_document_access.get_abilities(user),
|
"abilities": new_document_access.get_abilities(user),
|
||||||
|
"document": {
|
||||||
|
"id": str(new_document_access.document_id),
|
||||||
|
"depth": new_document_access.document.depth,
|
||||||
|
"path": new_document_access.document.path,
|
||||||
|
},
|
||||||
|
"id": str(new_document_access.id),
|
||||||
|
"max_ancestors_role": None,
|
||||||
|
"max_role": role,
|
||||||
|
"role": role,
|
||||||
|
"team": "",
|
||||||
|
"user": other_user,
|
||||||
}
|
}
|
||||||
assert len(mail.outbox) == 1
|
assert len(mail.outbox) == 1
|
||||||
email = mail.outbox[0]
|
email = mail.outbox[0]
|
||||||
@@ -240,6 +350,71 @@ def test_api_document_accesses_create_authenticated_owner(via, mock_user_teams):
|
|||||||
assert "docs/" + str(document.id) + "/" in email_content
|
assert "docs/" + str(document.id) + "/" in email_content
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("depth", [1, 2, 3])
|
||||||
|
@pytest.mark.parametrize("via", VIA)
|
||||||
|
def test_api_document_accesses_create_authenticated_owner_share_to_team(
|
||||||
|
via, depth, mock_user_teams
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Owners of a document (direct or by heritage) should be able to create document accesses
|
||||||
|
whatever the role. An email should be sent to the accesses to notify them of the adding.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
documents = []
|
||||||
|
for i in range(depth):
|
||||||
|
parent = documents[i - 1] if i > 0 else None
|
||||||
|
documents.append(factories.DocumentFactory(parent=parent))
|
||||||
|
|
||||||
|
if via == USER:
|
||||||
|
factories.UserDocumentAccessFactory(
|
||||||
|
document=documents[0], user=user, role="owner"
|
||||||
|
)
|
||||||
|
elif via == TEAM:
|
||||||
|
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||||
|
factories.TeamDocumentAccessFactory(
|
||||||
|
document=documents[0], team="lasuite", role="owner"
|
||||||
|
)
|
||||||
|
|
||||||
|
other_user = factories.UserFactory(language="en-us")
|
||||||
|
document = documents[-1]
|
||||||
|
role = random.choice([role[0] for role in models.RoleChoices.choices])
|
||||||
|
|
||||||
|
assert len(mail.outbox) == 0
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/documents/{document.id!s}/accesses/",
|
||||||
|
{
|
||||||
|
"team": "new-team",
|
||||||
|
"role": role,
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
assert models.DocumentAccess.objects.filter(team="new-team").count() == 1
|
||||||
|
new_document_access = models.DocumentAccess.objects.filter(team="new-team").get()
|
||||||
|
other_user = serializers.UserSerializer(instance=other_user).data
|
||||||
|
assert response.json() == {
|
||||||
|
"abilities": new_document_access.get_abilities(user),
|
||||||
|
"document": {
|
||||||
|
"id": str(new_document_access.document_id),
|
||||||
|
"path": new_document_access.document.path,
|
||||||
|
"depth": new_document_access.document.depth,
|
||||||
|
},
|
||||||
|
"id": str(new_document_access.id),
|
||||||
|
"max_ancestors_role": None,
|
||||||
|
"max_role": role,
|
||||||
|
"role": role,
|
||||||
|
"team": "new-team",
|
||||||
|
"user": None,
|
||||||
|
}
|
||||||
|
assert len(mail.outbox) == 0
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("via", VIA)
|
@pytest.mark.parametrize("via", VIA)
|
||||||
def test_api_document_accesses_create_email_in_receivers_language(via, mock_user_teams):
|
def test_api_document_accesses_create_email_in_receivers_language(via, mock_user_teams):
|
||||||
"""
|
"""
|
||||||
@@ -286,11 +461,18 @@ def test_api_document_accesses_create_email_in_receivers_language(via, mock_user
|
|||||||
).get()
|
).get()
|
||||||
other_user_data = serializers.UserSerializer(instance=other_user).data
|
other_user_data = serializers.UserSerializer(instance=other_user).data
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"id": str(new_document_access.id),
|
|
||||||
"user": other_user_data,
|
|
||||||
"team": "",
|
|
||||||
"role": role,
|
|
||||||
"abilities": new_document_access.get_abilities(user),
|
"abilities": new_document_access.get_abilities(user),
|
||||||
|
"document": {
|
||||||
|
"id": str(new_document_access.document_id),
|
||||||
|
"path": new_document_access.document.path,
|
||||||
|
"depth": new_document_access.document.depth,
|
||||||
|
},
|
||||||
|
"id": str(new_document_access.id),
|
||||||
|
"max_ancestors_role": None,
|
||||||
|
"max_role": role,
|
||||||
|
"role": role,
|
||||||
|
"team": "",
|
||||||
|
"user": other_user_data,
|
||||||
}
|
}
|
||||||
assert len(mail.outbox) == index + 1
|
assert len(mail.outbox) == index + 1
|
||||||
email = mail.outbox[index]
|
email = mail.outbox[index]
|
||||||
|
|||||||
@@ -175,8 +175,11 @@ def test_api_documents_ai_transform_authenticated_success(mock_create, reach, ro
|
|||||||
{
|
{
|
||||||
"role": "system",
|
"role": "system",
|
||||||
"content": (
|
"content": (
|
||||||
"Answer the prompt in markdown format. Preserve the language and markdown "
|
"Answer the prompt using markdown formatting for structure and emphasis. "
|
||||||
"formatting. Do not provide any other information. Preserve the language."
|
"Return the content directly without wrapping it in code blocks or markdown delimiters. "
|
||||||
|
"Preserve the language and markdown formatting. "
|
||||||
|
"Do not provide any other information. "
|
||||||
|
"Preserve the language."
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{"role": "user", "content": "Hello"},
|
{"role": "user", "content": "Hello"},
|
||||||
@@ -249,8 +252,11 @@ def test_api_documents_ai_transform_success(mock_create, via, role, mock_user_te
|
|||||||
{
|
{
|
||||||
"role": "system",
|
"role": "system",
|
||||||
"content": (
|
"content": (
|
||||||
"Answer the prompt in markdown format. Preserve the language and markdown "
|
"Answer the prompt using markdown formatting for structure and emphasis. "
|
||||||
"formatting. Do not provide any other information. Preserve the language."
|
"Return the content directly without wrapping it in code blocks or markdown delimiters. "
|
||||||
|
"Preserve the language and markdown formatting. "
|
||||||
|
"Do not provide any other information. "
|
||||||
|
"Preserve the language."
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{"role": "user", "content": "Hello"},
|
{"role": "user", "content": "Hello"},
|
||||||
|
|||||||
@@ -0,0 +1,770 @@
|
|||||||
|
"""Test API for document ask for access."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from django.core import mail
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from core.api.serializers import UserSerializer
|
||||||
|
from core.factories import (
|
||||||
|
DocumentAskForAccessFactory,
|
||||||
|
DocumentFactory,
|
||||||
|
UserDocumentAccessFactory,
|
||||||
|
UserFactory,
|
||||||
|
)
|
||||||
|
from core.models import DocumentAccess, DocumentAskForAccess, RoleChoices
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
## Create
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_documents_ask_for_access_create_anonymous():
|
||||||
|
"""Anonymous users should not be able to create a document ask for access."""
|
||||||
|
document = DocumentFactory()
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
response = client.post(f"/api/v1.0/documents/{document.id}/ask-for-access/")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_documents_ask_for_access_create_invalid_document_id():
|
||||||
|
"""Invalid document ID should return a 404 error."""
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
response = client.post(f"/api/v1.0/documents/{uuid.uuid4()}/ask-for-access/")
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_documents_ask_for_access_create_authenticated():
|
||||||
|
"""
|
||||||
|
Authenticated users should be able to create a document ask for access.
|
||||||
|
An email should be sent to document owners and admins to notify them.
|
||||||
|
"""
|
||||||
|
owner_user = UserFactory(language="en-us")
|
||||||
|
admin_user = UserFactory(language="en-us")
|
||||||
|
document = DocumentFactory(
|
||||||
|
users=[
|
||||||
|
(owner_user, RoleChoices.OWNER),
|
||||||
|
(admin_user, RoleChoices.ADMIN),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
assert len(mail.outbox) == 0
|
||||||
|
|
||||||
|
response = client.post(f"/api/v1.0/documents/{document.id}/ask-for-access/")
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
assert DocumentAskForAccess.objects.filter(
|
||||||
|
document=document,
|
||||||
|
user=user,
|
||||||
|
role=RoleChoices.READER,
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
# Verify emails were sent to both owner and admin
|
||||||
|
assert len(mail.outbox) == 2
|
||||||
|
|
||||||
|
# Check that emails were sent to the right recipients
|
||||||
|
email_recipients = [email.to[0] for email in mail.outbox]
|
||||||
|
assert owner_user.email in email_recipients
|
||||||
|
assert admin_user.email in email_recipients
|
||||||
|
|
||||||
|
# Check email content for both users
|
||||||
|
for email in mail.outbox:
|
||||||
|
email_content = " ".join(email.body.split())
|
||||||
|
email_subject = " ".join(email.subject.split())
|
||||||
|
|
||||||
|
# Check that the requesting user's name is in the email
|
||||||
|
user_name = user.full_name or user.email
|
||||||
|
assert user_name.lower() in email_content.lower()
|
||||||
|
|
||||||
|
# Check that the subject mentions access request
|
||||||
|
assert "access" in email_subject.lower()
|
||||||
|
|
||||||
|
# Check that the document title is mentioned if it exists
|
||||||
|
if document.title:
|
||||||
|
assert document.title.lower() in email_subject.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_documents_ask_for_access_create_authenticated_non_root_document():
|
||||||
|
"""
|
||||||
|
Authenticated users should not be able to create a document ask for access on a non-root
|
||||||
|
document.
|
||||||
|
"""
|
||||||
|
parent = DocumentFactory()
|
||||||
|
child = DocumentFactory(parent=parent)
|
||||||
|
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(f"/api/v1.0/documents/{child.id}/ask-for-access/")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_documents_ask_for_access_create_authenticated_specific_role():
|
||||||
|
"""
|
||||||
|
Authenticated users should be able to create a document ask for access with a specific role.
|
||||||
|
"""
|
||||||
|
document = DocumentFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/documents/{document.id}/ask-for-access/",
|
||||||
|
data={"role": RoleChoices.EDITOR},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
assert DocumentAskForAccess.objects.filter(
|
||||||
|
document=document,
|
||||||
|
user=user,
|
||||||
|
role=RoleChoices.EDITOR,
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_documents_ask_for_access_create_authenticated_already_has_access():
|
||||||
|
"""Authenticated users with existing access can ask for access with a different role."""
|
||||||
|
user = UserFactory()
|
||||||
|
document = DocumentFactory(users=[(user, RoleChoices.READER)])
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/documents/{document.id}/ask-for-access/",
|
||||||
|
data={"role": RoleChoices.EDITOR},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
assert DocumentAskForAccess.objects.filter(
|
||||||
|
document=document,
|
||||||
|
user=user,
|
||||||
|
role=RoleChoices.EDITOR,
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_documents_ask_for_access_create_authenticated_already_has_ask_for_access():
|
||||||
|
"""
|
||||||
|
Authenticated users with existing ask for access can not ask for a new access on this document.
|
||||||
|
"""
|
||||||
|
user = UserFactory()
|
||||||
|
document = DocumentFactory(users=[(user, RoleChoices.READER)])
|
||||||
|
DocumentAskForAccessFactory(document=document, user=user, role=RoleChoices.READER)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/documents/{document.id}/ask-for-access/",
|
||||||
|
data={"role": RoleChoices.EDITOR},
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response.json() == {"detail": "You already ask to access to this document."}
|
||||||
|
|
||||||
|
|
||||||
|
## List
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_documents_ask_for_access_list_anonymous():
|
||||||
|
"""Anonymous users should not be able to list document ask for access."""
|
||||||
|
document = DocumentFactory()
|
||||||
|
DocumentAskForAccessFactory.create_batch(
|
||||||
|
3, document=document, role=RoleChoices.READER
|
||||||
|
)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
response = client.get(f"/api/v1.0/documents/{document.id}/ask-for-access/")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_documents_ask_for_access_list_authenticated():
|
||||||
|
"""Authenticated users should be able to list document ask for access."""
|
||||||
|
document = DocumentFactory()
|
||||||
|
DocumentAskForAccessFactory.create_batch(
|
||||||
|
3, document=document, role=RoleChoices.READER
|
||||||
|
)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(UserFactory())
|
||||||
|
|
||||||
|
response = client.get(f"/api/v1.0/documents/{document.id}/ask-for-access/")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {
|
||||||
|
"count": 0,
|
||||||
|
"next": None,
|
||||||
|
"previous": None,
|
||||||
|
"results": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_documents_ask_for_access_list_authenticated_non_root_document():
|
||||||
|
"""
|
||||||
|
Authenticated users should not be able to list document ask for access on a non-root document.
|
||||||
|
"""
|
||||||
|
parent = DocumentFactory()
|
||||||
|
child = DocumentFactory(parent=parent)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(UserFactory())
|
||||||
|
|
||||||
|
response = client.get(f"/api/v1.0/documents/{child.id}/ask-for-access/")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_documents_ask_for_access_list_authenticated_own_request():
|
||||||
|
"""Authenticated users should be able to list their own document ask for access."""
|
||||||
|
document = DocumentFactory()
|
||||||
|
DocumentAskForAccessFactory.create_batch(
|
||||||
|
3, document=document, role=RoleChoices.READER
|
||||||
|
)
|
||||||
|
|
||||||
|
user = UserFactory()
|
||||||
|
user_data = UserSerializer(instance=user).data
|
||||||
|
|
||||||
|
document_ask_for_access = DocumentAskForAccessFactory(
|
||||||
|
document=document, user=user, role=RoleChoices.READER
|
||||||
|
)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.get(f"/api/v1.0/documents/{document.id}/ask-for-access/")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {
|
||||||
|
"count": 1,
|
||||||
|
"next": None,
|
||||||
|
"previous": None,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": str(document_ask_for_access.id),
|
||||||
|
"document": str(document.id),
|
||||||
|
"user": user_data,
|
||||||
|
"role": RoleChoices.READER,
|
||||||
|
"created_at": document_ask_for_access.created_at.isoformat().replace(
|
||||||
|
"+00:00", "Z"
|
||||||
|
),
|
||||||
|
"abilities": {
|
||||||
|
"accept": False,
|
||||||
|
"destroy": False,
|
||||||
|
"update": False,
|
||||||
|
"partial_update": False,
|
||||||
|
"retrieve": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_documents_ask_for_access_list_authenticated_other_document():
|
||||||
|
"""Authenticated users should not be able to list document ask for access of other documents."""
|
||||||
|
document = DocumentFactory()
|
||||||
|
DocumentAskForAccessFactory.create_batch(
|
||||||
|
3, document=document, role=RoleChoices.READER
|
||||||
|
)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(UserFactory())
|
||||||
|
|
||||||
|
other_document = DocumentFactory()
|
||||||
|
DocumentAskForAccessFactory.create_batch(
|
||||||
|
3, document=other_document, role=RoleChoices.READER
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.get(f"/api/v1.0/documents/{other_document.id}/ask-for-access/")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {
|
||||||
|
"count": 0,
|
||||||
|
"next": None,
|
||||||
|
"previous": None,
|
||||||
|
"results": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("role", [RoleChoices.READER, RoleChoices.EDITOR])
|
||||||
|
def test_api_documents_ask_for_access_list_non_owner_or_admin(role):
|
||||||
|
"""Non owner or admin users should not be able to list document ask for access."""
|
||||||
|
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
document = DocumentFactory(users=[(user, role)])
|
||||||
|
DocumentAskForAccessFactory.create_batch(
|
||||||
|
3, document=document, role=RoleChoices.READER
|
||||||
|
)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.get(f"/api/v1.0/documents/{document.id}/ask-for-access/")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {
|
||||||
|
"count": 0,
|
||||||
|
"next": None,
|
||||||
|
"previous": None,
|
||||||
|
"results": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("role", [RoleChoices.OWNER, RoleChoices.ADMIN])
|
||||||
|
def test_api_documents_ask_for_access_list_owner_or_admin(role):
|
||||||
|
"""Owner or admin users should be able to list document ask for access."""
|
||||||
|
user = UserFactory()
|
||||||
|
document = DocumentFactory(users=[(user, role)])
|
||||||
|
document_ask_for_accesses = DocumentAskForAccessFactory.create_batch(
|
||||||
|
3, document=document, role=RoleChoices.READER
|
||||||
|
)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.get(f"/api/v1.0/documents/{document.id}/ask-for-access/")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {
|
||||||
|
"count": 3,
|
||||||
|
"next": None,
|
||||||
|
"previous": None,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": str(document_ask_for_access.id),
|
||||||
|
"document": str(document.id),
|
||||||
|
"user": UserSerializer(instance=document_ask_for_access.user).data,
|
||||||
|
"role": RoleChoices.READER,
|
||||||
|
"created_at": document_ask_for_access.created_at.isoformat().replace(
|
||||||
|
"+00:00", "Z"
|
||||||
|
),
|
||||||
|
"abilities": {
|
||||||
|
"accept": True,
|
||||||
|
"destroy": True,
|
||||||
|
"update": True,
|
||||||
|
"partial_update": True,
|
||||||
|
"retrieve": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for document_ask_for_access in document_ask_for_accesses
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("role", [RoleChoices.OWNER, RoleChoices.ADMIN])
|
||||||
|
def test_api_documents_ask_for_access_list_admin_non_root_document(role):
|
||||||
|
"""
|
||||||
|
Authenticated users should not be able to list document ask for access on a non-root document.
|
||||||
|
"""
|
||||||
|
user = UserFactory()
|
||||||
|
parent = DocumentFactory(users=[(user, role)])
|
||||||
|
child = DocumentFactory(parent=parent, users=[(user, role)])
|
||||||
|
DocumentAskForAccessFactory.create_batch(3, document=child, role=RoleChoices.READER)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.get(f"/api/v1.0/documents/{child.id}/ask-for-access/")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
## Retrieve
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_documents_ask_for_access_retrieve_anonymous():
|
||||||
|
"""Anonymous users should not be able to retrieve document ask for access."""
|
||||||
|
document = DocumentFactory()
|
||||||
|
document_ask_for_access = DocumentAskForAccessFactory(
|
||||||
|
document=document, role=RoleChoices.READER
|
||||||
|
)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
response = client.get(
|
||||||
|
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/"
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_documents_ask_for_access_retrieve_authenticated():
|
||||||
|
"""Authenticated users should not be able to retrieve document ask for access."""
|
||||||
|
document = DocumentFactory()
|
||||||
|
document_ask_for_access = DocumentAskForAccessFactory(
|
||||||
|
document=document, role=RoleChoices.READER
|
||||||
|
)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(UserFactory())
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/"
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("role", [RoleChoices.READER, RoleChoices.EDITOR])
|
||||||
|
def test_api_documents_ask_for_access_retrieve_authenticated_non_owner_or_admin(role):
|
||||||
|
"""Non owner or admin users should not be able to retrieve document ask for access."""
|
||||||
|
user = UserFactory()
|
||||||
|
document = DocumentFactory(users=[(user, role)])
|
||||||
|
document_ask_for_access = DocumentAskForAccessFactory(
|
||||||
|
document=document, role=RoleChoices.READER
|
||||||
|
)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/"
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("role", [RoleChoices.OWNER, RoleChoices.ADMIN])
|
||||||
|
def test_api_documents_ask_for_access_retrieve_owner_or_admin(role):
|
||||||
|
"""Owner or admin users should be able to retrieve document ask for access."""
|
||||||
|
user = UserFactory()
|
||||||
|
document = DocumentFactory(users=[(user, role)])
|
||||||
|
document_ask_for_access = DocumentAskForAccessFactory(
|
||||||
|
document=document, role=RoleChoices.READER
|
||||||
|
)
|
||||||
|
user_data = UserSerializer(instance=document_ask_for_access.user).data
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/"
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {
|
||||||
|
"id": str(document_ask_for_access.id),
|
||||||
|
"document": str(document.id),
|
||||||
|
"user": user_data,
|
||||||
|
"role": RoleChoices.READER,
|
||||||
|
"created_at": document_ask_for_access.created_at.isoformat().replace(
|
||||||
|
"+00:00", "Z"
|
||||||
|
),
|
||||||
|
"abilities": {
|
||||||
|
"accept": True,
|
||||||
|
"destroy": True,
|
||||||
|
"update": True,
|
||||||
|
"partial_update": True,
|
||||||
|
"retrieve": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("role", [RoleChoices.OWNER, RoleChoices.ADMIN])
|
||||||
|
def test_api_documents_ask_for_access_retrieve_authenticated_non_root_document(role):
|
||||||
|
"""
|
||||||
|
Authenticated users should not be able to retrieve document ask for access on a non-root
|
||||||
|
document.
|
||||||
|
"""
|
||||||
|
user = UserFactory()
|
||||||
|
parent = DocumentFactory(users=[(user, role)])
|
||||||
|
child = DocumentFactory(parent=parent, users=[(user, role)])
|
||||||
|
document_ask_for_access = DocumentAskForAccessFactory(
|
||||||
|
document=child, role=RoleChoices.READER
|
||||||
|
)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
f"/api/v1.0/documents/{child.id}/ask-for-access/{document_ask_for_access.id}/"
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
## Delete
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_documents_ask_for_access_delete_anonymous():
|
||||||
|
"""Anonymous users should not be able to delete document ask for access."""
|
||||||
|
document = DocumentFactory()
|
||||||
|
document_ask_for_access = DocumentAskForAccessFactory(
|
||||||
|
document=document, role=RoleChoices.READER
|
||||||
|
)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
response = client.delete(
|
||||||
|
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/"
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_documents_ask_for_access_delete_authenticated():
|
||||||
|
"""Authenticated users should not be able to delete document ask for access."""
|
||||||
|
document = DocumentFactory()
|
||||||
|
document_ask_for_access = DocumentAskForAccessFactory(
|
||||||
|
document=document, role=RoleChoices.READER
|
||||||
|
)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(UserFactory())
|
||||||
|
|
||||||
|
response = client.delete(
|
||||||
|
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/"
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("role", [RoleChoices.READER, RoleChoices.EDITOR])
|
||||||
|
def test_api_documents_ask_for_access_delete_authenticated_non_owner_or_admin(role):
|
||||||
|
"""Non owner or admin users should not be able to delete document ask for access."""
|
||||||
|
user = UserFactory()
|
||||||
|
document = DocumentFactory(users=[(user, role)])
|
||||||
|
document_ask_for_access = DocumentAskForAccessFactory(
|
||||||
|
document=document, role=RoleChoices.READER
|
||||||
|
)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.delete(
|
||||||
|
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/"
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("role", [RoleChoices.OWNER, RoleChoices.ADMIN])
|
||||||
|
def test_api_documents_ask_for_access_delete_owner_or_admin(role):
|
||||||
|
"""Owner or admin users should be able to delete document ask for access."""
|
||||||
|
user = UserFactory()
|
||||||
|
document = DocumentFactory(users=[(user, role)])
|
||||||
|
document_ask_for_access = DocumentAskForAccessFactory(
|
||||||
|
document=document, role=RoleChoices.READER
|
||||||
|
)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.delete(
|
||||||
|
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/"
|
||||||
|
)
|
||||||
|
assert response.status_code == 204
|
||||||
|
assert not DocumentAskForAccess.objects.filter(
|
||||||
|
id=document_ask_for_access.id
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("role", [RoleChoices.OWNER, RoleChoices.ADMIN])
|
||||||
|
def test_api_documents_ask_for_access_delete_authenticated_non_root_document(role):
|
||||||
|
"""
|
||||||
|
Authenticated users should not be able to delete document ask for access on a non-root
|
||||||
|
document.
|
||||||
|
"""
|
||||||
|
user = UserFactory()
|
||||||
|
parent = DocumentFactory(users=[(user, role)])
|
||||||
|
child = DocumentFactory(parent=parent, users=[(user, role)])
|
||||||
|
document_ask_for_access = DocumentAskForAccessFactory(
|
||||||
|
document=child, role=RoleChoices.READER
|
||||||
|
)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.delete(
|
||||||
|
f"/api/v1.0/documents/{child.id}/ask-for-access/{document_ask_for_access.id}/"
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
## Accept
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_documents_ask_for_access_accept_anonymous():
|
||||||
|
"""Anonymous users should not be able to accept document ask for access."""
|
||||||
|
document = DocumentFactory()
|
||||||
|
document_ask_for_access = DocumentAskForAccessFactory(
|
||||||
|
document=document, role=RoleChoices.READER
|
||||||
|
)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/accept/"
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_documents_ask_for_access_accept_authenticated():
|
||||||
|
"""Authenticated users should not be able to accept document ask for access."""
|
||||||
|
document = DocumentFactory()
|
||||||
|
document_ask_for_access = DocumentAskForAccessFactory(
|
||||||
|
document=document, role=RoleChoices.READER
|
||||||
|
)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(UserFactory())
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/accept/"
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("role", [RoleChoices.READER, RoleChoices.EDITOR])
|
||||||
|
def test_api_documents_ask_for_access_accept_authenticated_non_owner_or_admin(role):
|
||||||
|
"""Non owner or admin users should not be able to accept document ask for access."""
|
||||||
|
user = UserFactory()
|
||||||
|
document = DocumentFactory(users=[(user, role)])
|
||||||
|
document_ask_for_access = DocumentAskForAccessFactory(
|
||||||
|
document=document, role=RoleChoices.READER
|
||||||
|
)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/accept/"
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("role", [RoleChoices.OWNER, RoleChoices.ADMIN])
|
||||||
|
def test_api_documents_ask_for_access_accept_owner_or_admin(role):
|
||||||
|
"""Owner or admin users should be able to accept document ask for access."""
|
||||||
|
user = UserFactory()
|
||||||
|
document = DocumentFactory(users=[(user, role)])
|
||||||
|
document_ask_for_access = DocumentAskForAccessFactory(
|
||||||
|
document=document, role=RoleChoices.READER
|
||||||
|
)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/accept/"
|
||||||
|
)
|
||||||
|
assert response.status_code == 204
|
||||||
|
|
||||||
|
assert not DocumentAskForAccess.objects.filter(
|
||||||
|
id=document_ask_for_access.id
|
||||||
|
).exists()
|
||||||
|
assert DocumentAccess.objects.filter(
|
||||||
|
document=document, user=document_ask_for_access.user, role=RoleChoices.READER
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("role", [RoleChoices.OWNER, RoleChoices.ADMIN])
|
||||||
|
def test_api_documents_ask_for_access_accept_authenticated_specific_role(role):
|
||||||
|
"""
|
||||||
|
Owner or admin users should be able to accept document ask for access with a specific role.
|
||||||
|
"""
|
||||||
|
user = UserFactory()
|
||||||
|
document = DocumentFactory(users=[(user, role)])
|
||||||
|
document_ask_for_access = DocumentAskForAccessFactory(
|
||||||
|
document=document, role=RoleChoices.READER
|
||||||
|
)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/accept/",
|
||||||
|
data={"role": RoleChoices.EDITOR},
|
||||||
|
)
|
||||||
|
assert response.status_code == 204
|
||||||
|
|
||||||
|
assert not DocumentAskForAccess.objects.filter(
|
||||||
|
id=document_ask_for_access.id
|
||||||
|
).exists()
|
||||||
|
assert DocumentAccess.objects.filter(
|
||||||
|
document=document, user=document_ask_for_access.user, role=RoleChoices.EDITOR
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("role", [RoleChoices.OWNER, RoleChoices.ADMIN])
|
||||||
|
def test_api_documents_ask_for_access_accept_authenticated_owner_or_admin_update_access(
|
||||||
|
role,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Owner or admin users should be able to accept document ask for access and update the access.
|
||||||
|
"""
|
||||||
|
user = UserFactory()
|
||||||
|
document = DocumentFactory(users=[(user, role)])
|
||||||
|
document_access = UserDocumentAccessFactory(
|
||||||
|
document=document, role=RoleChoices.READER
|
||||||
|
)
|
||||||
|
document_ask_for_access = DocumentAskForAccessFactory(
|
||||||
|
document=document, user=document_access.user, role=RoleChoices.EDITOR
|
||||||
|
)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/accept/",
|
||||||
|
data={"role": RoleChoices.EDITOR},
|
||||||
|
)
|
||||||
|
assert response.status_code == 204
|
||||||
|
|
||||||
|
assert not DocumentAskForAccess.objects.filter(
|
||||||
|
id=document_ask_for_access.id
|
||||||
|
).exists()
|
||||||
|
document_access.refresh_from_db()
|
||||||
|
assert document_access.role == RoleChoices.EDITOR
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("role", [RoleChoices.OWNER, RoleChoices.ADMIN])
|
||||||
|
# pylint: disable=line-too-long
|
||||||
|
def test_api_documents_ask_for_access_accept_authenticated_owner_or_admin_update_access_with_specific_role(
|
||||||
|
role,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Owner or admin users should be able to accept document ask for access and update the access
|
||||||
|
with a specific role.
|
||||||
|
"""
|
||||||
|
user = UserFactory()
|
||||||
|
document = DocumentFactory(users=[(user, role)])
|
||||||
|
document_access = UserDocumentAccessFactory(
|
||||||
|
document=document, role=RoleChoices.READER
|
||||||
|
)
|
||||||
|
document_ask_for_access = DocumentAskForAccessFactory(
|
||||||
|
document=document, user=document_access.user, role=RoleChoices.EDITOR
|
||||||
|
)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/accept/",
|
||||||
|
data={"role": RoleChoices.ADMIN},
|
||||||
|
)
|
||||||
|
assert response.status_code == 204
|
||||||
|
|
||||||
|
assert not DocumentAskForAccess.objects.filter(
|
||||||
|
id=document_ask_for_access.id
|
||||||
|
).exists()
|
||||||
|
document_access.refresh_from_db()
|
||||||
|
assert document_access.role == RoleChoices.ADMIN
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("role", [RoleChoices.OWNER, RoleChoices.ADMIN])
|
||||||
|
def test_api_documents_ask_for_access_accept_authenticated_non_root_document(role):
|
||||||
|
"""
|
||||||
|
Authenticated users should not be able to accept document ask for access on a non-root
|
||||||
|
document.
|
||||||
|
"""
|
||||||
|
user = UserFactory()
|
||||||
|
parent = DocumentFactory(users=[(user, role)])
|
||||||
|
child = DocumentFactory(parent=parent, users=[(user, role)])
|
||||||
|
document_ask_for_access = DocumentAskForAccessFactory(
|
||||||
|
document=child, role=RoleChoices.READER
|
||||||
|
)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/documents/{child.id}/ask-for-access/{document_ask_for_access.id}/accept/"
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
@@ -439,3 +439,56 @@ def test_api_documents_attachment_upload_unsafe():
|
|||||||
"application/octet-stream",
|
"application/octet-stream",
|
||||||
]
|
]
|
||||||
assert file_head["ContentDisposition"] == 'attachment; filename="script.exe"'
|
assert file_head["ContentDisposition"] == 'attachment; filename="script.exe"'
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_documents_attachment_upload_unsafe_mime_types_disabled(settings):
|
||||||
|
"""A file with an unsafe mime type but checking disabled should not be tagged as unsafe."""
|
||||||
|
settings.DOCUMENT_ATTACHMENT_CHECK_UNSAFE_MIME_TYPES_ENABLED = False
|
||||||
|
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
document = factories.DocumentFactory(users=[(user, "owner")])
|
||||||
|
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
|
||||||
|
|
||||||
|
file = SimpleUploadedFile(
|
||||||
|
name="script.exe", content=b"\x4d\x5a\x90\x00\x03\x00\x00\x00"
|
||||||
|
)
|
||||||
|
with mock.patch.object(malware_detection, "analyse_file") as mock_analyse_file:
|
||||||
|
response = client.post(url, {"file": file}, format="multipart")
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
pattern = re.compile(rf"^{document.id!s}/attachments/(.*)\.exe")
|
||||||
|
url_parsed = urlparse(response.json()["file"])
|
||||||
|
assert url_parsed.path == f"/api/v1.0/documents/{document.id!s}/media-check/"
|
||||||
|
query = parse_qs(url_parsed.query)
|
||||||
|
assert query["key"][0] is not None
|
||||||
|
file_path = query["key"][0]
|
||||||
|
match = pattern.search(file_path)
|
||||||
|
file_id = match.group(1)
|
||||||
|
|
||||||
|
document.refresh_from_db()
|
||||||
|
assert document.attachments == [f"{document.id!s}/attachments/{file_id!s}.exe"]
|
||||||
|
|
||||||
|
assert "-unsafe" not in file_id
|
||||||
|
# Validate that file_id is a valid UUID
|
||||||
|
uuid.UUID(file_id)
|
||||||
|
|
||||||
|
key = file_path.replace("/media/", "")
|
||||||
|
mock_analyse_file.assert_called_once_with(key, document_id=document.id)
|
||||||
|
# Now, check the metadata of the uploaded file
|
||||||
|
file_head = default_storage.connection.meta.client.head_object(
|
||||||
|
Bucket=default_storage.bucket_name, Key=key
|
||||||
|
)
|
||||||
|
assert file_head["Metadata"] == {
|
||||||
|
"owner": str(user.id),
|
||||||
|
"status": "processing",
|
||||||
|
}
|
||||||
|
# Depending the libmagic version, the content type may change.
|
||||||
|
assert file_head["ContentType"] in [
|
||||||
|
"application/x-dosexec",
|
||||||
|
"application/octet-stream",
|
||||||
|
]
|
||||||
|
assert file_head["ContentDisposition"] == 'attachment; filename="script.exe"'
|
||||||
|
|||||||
318
src/backend/core/tests/documents/test_api_documents_can_edit.py
Normal file
318
src/backend/core/tests/documents/test_api_documents_can_edit.py
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
"""Test the can_edit endpoint in the viewset DocumentViewSet."""
|
||||||
|
|
||||||
|
from django.core.cache import cache
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import responses
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from core import factories
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
@pytest.mark.parametrize("ws_not_connected_ready_only", [True, False])
|
||||||
|
@pytest.mark.parametrize("role", ["editor", "reader"])
|
||||||
|
def test_api_documents_can_edit_anonymous(settings, ws_not_connected_ready_only, role):
|
||||||
|
"""Anonymous users can edit documents when link_role is editor."""
|
||||||
|
document = factories.DocumentFactory(link_reach="public", link_role=role)
|
||||||
|
client = APIClient()
|
||||||
|
session_key = client.session.session_key
|
||||||
|
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||||
|
settings.COLLABORATION_SERVER_SECRET = "secret-token"
|
||||||
|
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = ws_not_connected_ready_only
|
||||||
|
endpoint_url = (
|
||||||
|
f"{settings.COLLABORATION_API_URL}get-connections/"
|
||||||
|
f"?room={document.id}&sessionKey={session_key}"
|
||||||
|
)
|
||||||
|
ws_resp = responses.get(endpoint_url, json={"count": 0, "exists": False})
|
||||||
|
|
||||||
|
response = client.get(f"/api/v1.0/documents/{document.id!s}/can-edit/")
|
||||||
|
|
||||||
|
if role == "reader":
|
||||||
|
assert response.status_code == 401
|
||||||
|
else:
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"can_edit": True}
|
||||||
|
assert ws_resp.call_count == (1 if ws_not_connected_ready_only else 0)
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
@pytest.mark.parametrize("ws_not_connected_ready_only", [True, False])
|
||||||
|
def test_api_documents_can_edit_authenticated_no_websocket(
|
||||||
|
settings, ws_not_connected_ready_only
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
A user not connected to the websocket and no other user have already updated the document,
|
||||||
|
the document can be updated.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory(with_owned_document=True)
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
session_key = client.session.session_key
|
||||||
|
|
||||||
|
document = factories.DocumentFactory(users=[(user, "editor")])
|
||||||
|
|
||||||
|
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||||
|
settings.COLLABORATION_SERVER_SECRET = "secret-token"
|
||||||
|
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = ws_not_connected_ready_only
|
||||||
|
endpoint_url = (
|
||||||
|
f"{settings.COLLABORATION_API_URL}get-connections/"
|
||||||
|
f"?room={document.id}&sessionKey={session_key}"
|
||||||
|
)
|
||||||
|
|
||||||
|
ws_resp = responses.get(endpoint_url, json={"count": 0, "exists": False})
|
||||||
|
|
||||||
|
assert cache.get(f"docs:no-websocket:{document.id}") is None
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
f"/api/v1.0/documents/{document.id!s}/can-edit/",
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
assert response.json() == {"can_edit": True}
|
||||||
|
assert ws_resp.call_count == (1 if ws_not_connected_ready_only else 0)
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_api_documents_can_edit_authenticated_no_websocket_user_already_editing(
|
||||||
|
settings,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
A user not connected to the websocket and another user have already updated the document,
|
||||||
|
the document can not be updated.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory(with_owned_document=True)
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
session_key = client.session.session_key
|
||||||
|
|
||||||
|
document = factories.DocumentFactory(users=[(user, "editor")])
|
||||||
|
|
||||||
|
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||||
|
settings.COLLABORATION_SERVER_SECRET = "secret-token"
|
||||||
|
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
|
||||||
|
endpoint_url = (
|
||||||
|
f"{settings.COLLABORATION_API_URL}get-connections/"
|
||||||
|
f"?room={document.id}&sessionKey={session_key}"
|
||||||
|
)
|
||||||
|
ws_resp = responses.get(endpoint_url, json={"count": 0, "exists": False})
|
||||||
|
|
||||||
|
cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
f"/api/v1.0/documents/{document.id!s}/can-edit/",
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"can_edit": False}
|
||||||
|
|
||||||
|
assert ws_resp.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_api_documents_can_edit_no_websocket_other_user_connected_to_websocket(
|
||||||
|
settings,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
A user not connected to the websocket and another user is connected to the websocket,
|
||||||
|
the document can not be updated.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory(with_owned_document=True)
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
session_key = client.session.session_key
|
||||||
|
|
||||||
|
document = factories.DocumentFactory(users=[(user, "editor")])
|
||||||
|
|
||||||
|
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||||
|
settings.COLLABORATION_SERVER_SECRET = "secret-token"
|
||||||
|
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
|
||||||
|
endpoint_url = (
|
||||||
|
f"{settings.COLLABORATION_API_URL}get-connections/"
|
||||||
|
f"?room={document.id}&sessionKey={session_key}"
|
||||||
|
)
|
||||||
|
ws_resp = responses.get(endpoint_url, json={"count": 3, "exists": False})
|
||||||
|
|
||||||
|
assert cache.get(f"docs:no-websocket:{document.id}") is None
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
f"/api/v1.0/documents/{document.id!s}/can-edit/",
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"can_edit": False}
|
||||||
|
assert cache.get(f"docs:no-websocket:{document.id}") is None
|
||||||
|
assert ws_resp.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_api_documents_can_edit_user_connected_to_websocket(settings):
|
||||||
|
"""
|
||||||
|
A user connected to the websocket, the document can be updated.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory(with_owned_document=True)
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
session_key = client.session.session_key
|
||||||
|
|
||||||
|
document = factories.DocumentFactory(users=[(user, "editor")])
|
||||||
|
|
||||||
|
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||||
|
settings.COLLABORATION_SERVER_SECRET = "secret-token"
|
||||||
|
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
|
||||||
|
endpoint_url = (
|
||||||
|
f"{settings.COLLABORATION_API_URL}get-connections/"
|
||||||
|
f"?room={document.id}&sessionKey={session_key}"
|
||||||
|
)
|
||||||
|
ws_resp = responses.get(endpoint_url, json={"count": 3, "exists": True})
|
||||||
|
|
||||||
|
assert cache.get(f"docs:no-websocket:{document.id}") is None
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
f"/api/v1.0/documents/{document.id!s}/can-edit/",
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"can_edit": True}
|
||||||
|
assert cache.get(f"docs:no-websocket:{document.id}") is None
|
||||||
|
assert ws_resp.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_api_documents_can_edit_websocket_server_unreachable_fallback_to_no_websocket(
|
||||||
|
settings,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
When the websocket server is unreachable, the document can be updated like if the user was
|
||||||
|
not connected to the websocket.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory(with_owned_document=True)
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
session_key = client.session.session_key
|
||||||
|
|
||||||
|
document = factories.DocumentFactory(users=[(user, "editor")])
|
||||||
|
|
||||||
|
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||||
|
settings.COLLABORATION_SERVER_SECRET = "secret-token"
|
||||||
|
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
|
||||||
|
endpoint_url = (
|
||||||
|
f"{settings.COLLABORATION_API_URL}get-connections/"
|
||||||
|
f"?room={document.id}&sessionKey={session_key}"
|
||||||
|
)
|
||||||
|
ws_resp = responses.get(endpoint_url, status=500)
|
||||||
|
|
||||||
|
assert cache.get(f"docs:no-websocket:{document.id}") is None
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
f"/api/v1.0/documents/{document.id!s}/can-edit/",
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"can_edit": True}
|
||||||
|
|
||||||
|
assert ws_resp.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_api_documents_can_edit_websocket_server_unreachable_fallback_to_no_websocket_other_users(
|
||||||
|
settings,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
When the websocket server is unreachable, the behavior fallback to the no websocket one.
|
||||||
|
If an other user is already editing, the document can not be updated.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory(with_owned_document=True)
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
session_key = client.session.session_key
|
||||||
|
|
||||||
|
document = factories.DocumentFactory(users=[(user, "editor")])
|
||||||
|
|
||||||
|
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||||
|
settings.COLLABORATION_SERVER_SECRET = "secret-token"
|
||||||
|
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
|
||||||
|
endpoint_url = (
|
||||||
|
f"{settings.COLLABORATION_API_URL}get-connections/"
|
||||||
|
f"?room={document.id}&sessionKey={session_key}"
|
||||||
|
)
|
||||||
|
ws_resp = responses.get(endpoint_url, status=500)
|
||||||
|
|
||||||
|
cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
f"/api/v1.0/documents/{document.id!s}/can-edit/",
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"can_edit": False}
|
||||||
|
|
||||||
|
assert cache.get(f"docs:no-websocket:{document.id}") == "other_session_key"
|
||||||
|
assert ws_resp.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_api_documents_can_edit_websocket_server_room_not_found(
|
||||||
|
settings,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
When the websocket server returns a 404, the document can be updated like if the user was
|
||||||
|
not connected to the websocket.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory(with_owned_document=True)
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
session_key = client.session.session_key
|
||||||
|
|
||||||
|
document = factories.DocumentFactory(users=[(user, "editor")])
|
||||||
|
|
||||||
|
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||||
|
settings.COLLABORATION_SERVER_SECRET = "secret-token"
|
||||||
|
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
|
||||||
|
endpoint_url = (
|
||||||
|
f"{settings.COLLABORATION_API_URL}get-connections/"
|
||||||
|
f"?room={document.id}&sessionKey={session_key}"
|
||||||
|
)
|
||||||
|
ws_resp = responses.get(endpoint_url, status=404)
|
||||||
|
|
||||||
|
assert cache.get(f"docs:no-websocket:{document.id}") is None
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
f"/api/v1.0/documents/{document.id!s}/can-edit/",
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"can_edit": True}
|
||||||
|
|
||||||
|
assert ws_resp.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_api_documents_can_edit_websocket_server_room_not_found_other_already_editing(
|
||||||
|
settings,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
When the websocket server returns a 404 and another user is editing the document,
|
||||||
|
the response should be can-edit=False.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory(with_owned_document=True)
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
session_key = client.session.session_key
|
||||||
|
|
||||||
|
document = factories.DocumentFactory(users=[(user, "editor")])
|
||||||
|
|
||||||
|
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||||
|
settings.COLLABORATION_SERVER_SECRET = "secret-token"
|
||||||
|
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
|
||||||
|
endpoint_url = (
|
||||||
|
f"{settings.COLLABORATION_API_URL}get-connections/"
|
||||||
|
f"?room={document.id}&sessionKey={session_key}"
|
||||||
|
)
|
||||||
|
ws_resp = responses.get(endpoint_url, status=404)
|
||||||
|
|
||||||
|
cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
f"/api/v1.0/documents/{document.id!s}/can-edit/",
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"can_edit": False}
|
||||||
|
|
||||||
|
assert ws_resp.call_count == 1
|
||||||
@@ -98,7 +98,9 @@ def test_api_documents_children_create_authenticated_success(reach, role, depth)
|
|||||||
if i == 0:
|
if i == 0:
|
||||||
document = factories.DocumentFactory(link_reach=reach, link_role=role)
|
document = factories.DocumentFactory(link_reach=reach, link_role=role)
|
||||||
else:
|
else:
|
||||||
document = factories.DocumentFactory(parent=document, link_role="reader")
|
document = factories.DocumentFactory(
|
||||||
|
parent=document, link_reach="restricted"
|
||||||
|
)
|
||||||
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
f"/api/v1.0/documents/{document.id!s}/children/",
|
f"/api/v1.0/documents/{document.id!s}/children/",
|
||||||
@@ -112,7 +114,8 @@ def test_api_documents_children_create_authenticated_success(reach, role, depth)
|
|||||||
child = Document.objects.get(id=response.json()["id"])
|
child = Document.objects.get(id=response.json()["id"])
|
||||||
assert child.title == "my child"
|
assert child.title == "my child"
|
||||||
assert child.link_reach == "restricted"
|
assert child.link_reach == "restricted"
|
||||||
assert child.accesses.filter(role="owner", user=user).exists()
|
# Access objects on the child are not necessary
|
||||||
|
assert child.accesses.exists() is False
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("depth", [1, 2, 3])
|
@pytest.mark.parametrize("depth", [1, 2, 3])
|
||||||
@@ -180,7 +183,8 @@ def test_api_documents_children_create_related_success(role, depth):
|
|||||||
child = Document.objects.get(id=response.json()["id"])
|
child = Document.objects.get(id=response.json()["id"])
|
||||||
assert child.title == "my child"
|
assert child.title == "my child"
|
||||||
assert child.link_reach == "restricted"
|
assert child.link_reach == "restricted"
|
||||||
assert child.accesses.filter(role="owner", user=user).exists()
|
# Access objects on the child are not necessary
|
||||||
|
assert child.accesses.exists() is False
|
||||||
|
|
||||||
|
|
||||||
def test_api_documents_children_create_authenticated_title_null():
|
def test_api_documents_children_create_authenticated_title_null():
|
||||||
|
|||||||
@@ -14,13 +14,18 @@ from core import factories
|
|||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
def test_api_documents_children_list_anonymous_public_standalone():
|
def test_api_documents_children_list_anonymous_public_standalone(
|
||||||
|
django_assert_num_queries,
|
||||||
|
):
|
||||||
"""Anonymous users should be allowed to retrieve the children of a public document."""
|
"""Anonymous users should be allowed to retrieve the children of a public document."""
|
||||||
document = factories.DocumentFactory(link_reach="public")
|
document = factories.DocumentFactory(link_reach="public")
|
||||||
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||||
factories.UserDocumentAccessFactory(document=child1)
|
factories.UserDocumentAccessFactory(document=child1)
|
||||||
|
|
||||||
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/children/")
|
with django_assert_num_queries(8):
|
||||||
|
APIClient().get(f"/api/v1.0/documents/{document.id!s}/children/")
|
||||||
|
with django_assert_num_queries(4):
|
||||||
|
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/children/")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
@@ -30,6 +35,10 @@ def test_api_documents_children_list_anonymous_public_standalone():
|
|||||||
"results": [
|
"results": [
|
||||||
{
|
{
|
||||||
"abilities": child1.get_abilities(AnonymousUser()),
|
"abilities": child1.get_abilities(AnonymousUser()),
|
||||||
|
"ancestors_link_reach": "public",
|
||||||
|
"ancestors_link_role": document.link_role,
|
||||||
|
"computed_link_reach": "public",
|
||||||
|
"computed_link_role": child1.computed_link_role,
|
||||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(child1.creator.id),
|
"creator": str(child1.creator.id),
|
||||||
"depth": 2,
|
"depth": 2,
|
||||||
@@ -44,10 +53,14 @@ def test_api_documents_children_list_anonymous_public_standalone():
|
|||||||
"path": child1.path,
|
"path": child1.path,
|
||||||
"title": child1.title,
|
"title": child1.title,
|
||||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"abilities": child2.get_abilities(AnonymousUser()),
|
"abilities": child2.get_abilities(AnonymousUser()),
|
||||||
|
"ancestors_link_reach": "public",
|
||||||
|
"ancestors_link_role": document.link_role,
|
||||||
|
"computed_link_reach": "public",
|
||||||
|
"computed_link_role": child2.computed_link_role,
|
||||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(child2.creator.id),
|
"creator": str(child2.creator.id),
|
||||||
"depth": 2,
|
"depth": 2,
|
||||||
@@ -62,13 +75,13 @@ def test_api_documents_children_list_anonymous_public_standalone():
|
|||||||
"path": child2.path,
|
"path": child2.path,
|
||||||
"title": child2.title,
|
"title": child2.title,
|
||||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_api_documents_children_list_anonymous_public_parent():
|
def test_api_documents_children_list_anonymous_public_parent(django_assert_num_queries):
|
||||||
"""
|
"""
|
||||||
Anonymous users should be allowed to retrieve the children of a document who
|
Anonymous users should be allowed to retrieve the children of a document who
|
||||||
has a public ancestor.
|
has a public ancestor.
|
||||||
@@ -83,7 +96,10 @@ def test_api_documents_children_list_anonymous_public_parent():
|
|||||||
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||||
factories.UserDocumentAccessFactory(document=child1)
|
factories.UserDocumentAccessFactory(document=child1)
|
||||||
|
|
||||||
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/children/")
|
with django_assert_num_queries(9):
|
||||||
|
APIClient().get(f"/api/v1.0/documents/{document.id!s}/children/")
|
||||||
|
with django_assert_num_queries(5):
|
||||||
|
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/children/")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
@@ -93,6 +109,10 @@ def test_api_documents_children_list_anonymous_public_parent():
|
|||||||
"results": [
|
"results": [
|
||||||
{
|
{
|
||||||
"abilities": child1.get_abilities(AnonymousUser()),
|
"abilities": child1.get_abilities(AnonymousUser()),
|
||||||
|
"ancestors_link_reach": child1.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": child1.ancestors_link_role,
|
||||||
|
"computed_link_reach": child1.computed_link_reach,
|
||||||
|
"computed_link_role": child1.computed_link_role,
|
||||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(child1.creator.id),
|
"creator": str(child1.creator.id),
|
||||||
"depth": 4,
|
"depth": 4,
|
||||||
@@ -107,10 +127,14 @@ def test_api_documents_children_list_anonymous_public_parent():
|
|||||||
"path": child1.path,
|
"path": child1.path,
|
||||||
"title": child1.title,
|
"title": child1.title,
|
||||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"abilities": child2.get_abilities(AnonymousUser()),
|
"abilities": child2.get_abilities(AnonymousUser()),
|
||||||
|
"ancestors_link_reach": child2.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": child2.ancestors_link_role,
|
||||||
|
"computed_link_reach": child2.computed_link_reach,
|
||||||
|
"computed_link_role": child2.computed_link_role,
|
||||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(child2.creator.id),
|
"creator": str(child2.creator.id),
|
||||||
"depth": 4,
|
"depth": 4,
|
||||||
@@ -125,7 +149,7 @@ def test_api_documents_children_list_anonymous_public_parent():
|
|||||||
"path": child2.path,
|
"path": child2.path,
|
||||||
"title": child2.title,
|
"title": child2.title,
|
||||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -149,7 +173,7 @@ def test_api_documents_children_list_anonymous_restricted_or_authenticated(reach
|
|||||||
|
|
||||||
@pytest.mark.parametrize("reach", ["public", "authenticated"])
|
@pytest.mark.parametrize("reach", ["public", "authenticated"])
|
||||||
def test_api_documents_children_list_authenticated_unrelated_public_or_authenticated(
|
def test_api_documents_children_list_authenticated_unrelated_public_or_authenticated(
|
||||||
reach,
|
reach, django_assert_num_queries
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Authenticated users should be able to retrieve the children of a public/authenticated
|
Authenticated users should be able to retrieve the children of a public/authenticated
|
||||||
@@ -163,9 +187,13 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic
|
|||||||
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||||
factories.UserDocumentAccessFactory(document=child1)
|
factories.UserDocumentAccessFactory(document=child1)
|
||||||
|
|
||||||
response = client.get(
|
with django_assert_num_queries(9):
|
||||||
f"/api/v1.0/documents/{document.id!s}/children/",
|
client.get(f"/api/v1.0/documents/{document.id!s}/children/")
|
||||||
)
|
with django_assert_num_queries(5):
|
||||||
|
response = client.get(
|
||||||
|
f"/api/v1.0/documents/{document.id!s}/children/",
|
||||||
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"count": 2,
|
"count": 2,
|
||||||
@@ -174,6 +202,10 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic
|
|||||||
"results": [
|
"results": [
|
||||||
{
|
{
|
||||||
"abilities": child1.get_abilities(user),
|
"abilities": child1.get_abilities(user),
|
||||||
|
"ancestors_link_reach": reach,
|
||||||
|
"ancestors_link_role": document.link_role,
|
||||||
|
"computed_link_reach": child1.computed_link_reach,
|
||||||
|
"computed_link_role": child1.computed_link_role,
|
||||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(child1.creator.id),
|
"creator": str(child1.creator.id),
|
||||||
"depth": 2,
|
"depth": 2,
|
||||||
@@ -188,10 +220,14 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic
|
|||||||
"path": child1.path,
|
"path": child1.path,
|
||||||
"title": child1.title,
|
"title": child1.title,
|
||||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"abilities": child2.get_abilities(user),
|
"abilities": child2.get_abilities(user),
|
||||||
|
"ancestors_link_reach": reach,
|
||||||
|
"ancestors_link_role": document.link_role,
|
||||||
|
"computed_link_reach": child2.computed_link_reach,
|
||||||
|
"computed_link_role": child2.computed_link_role,
|
||||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(child2.creator.id),
|
"creator": str(child2.creator.id),
|
||||||
"depth": 2,
|
"depth": 2,
|
||||||
@@ -206,7 +242,7 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic
|
|||||||
"path": child2.path,
|
"path": child2.path,
|
||||||
"title": child2.title,
|
"title": child2.title,
|
||||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -214,7 +250,7 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic
|
|||||||
|
|
||||||
@pytest.mark.parametrize("reach", ["public", "authenticated"])
|
@pytest.mark.parametrize("reach", ["public", "authenticated"])
|
||||||
def test_api_documents_children_list_authenticated_public_or_authenticated_parent(
|
def test_api_documents_children_list_authenticated_public_or_authenticated_parent(
|
||||||
reach,
|
reach, django_assert_num_queries
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Authenticated users should be allowed to retrieve the children of a document who
|
Authenticated users should be allowed to retrieve the children of a document who
|
||||||
@@ -231,7 +267,11 @@ def test_api_documents_children_list_authenticated_public_or_authenticated_paren
|
|||||||
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||||
factories.UserDocumentAccessFactory(document=child1)
|
factories.UserDocumentAccessFactory(document=child1)
|
||||||
|
|
||||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/children/")
|
with django_assert_num_queries(10):
|
||||||
|
client.get(f"/api/v1.0/documents/{document.id!s}/children/")
|
||||||
|
|
||||||
|
with django_assert_num_queries(6):
|
||||||
|
response = client.get(f"/api/v1.0/documents/{document.id!s}/children/")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
@@ -241,6 +281,10 @@ def test_api_documents_children_list_authenticated_public_or_authenticated_paren
|
|||||||
"results": [
|
"results": [
|
||||||
{
|
{
|
||||||
"abilities": child1.get_abilities(user),
|
"abilities": child1.get_abilities(user),
|
||||||
|
"ancestors_link_reach": child1.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": child1.ancestors_link_role,
|
||||||
|
"computed_link_reach": child1.computed_link_reach,
|
||||||
|
"computed_link_role": child1.computed_link_role,
|
||||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(child1.creator.id),
|
"creator": str(child1.creator.id),
|
||||||
"depth": 4,
|
"depth": 4,
|
||||||
@@ -255,10 +299,14 @@ def test_api_documents_children_list_authenticated_public_or_authenticated_paren
|
|||||||
"path": child1.path,
|
"path": child1.path,
|
||||||
"title": child1.title,
|
"title": child1.title,
|
||||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"abilities": child2.get_abilities(user),
|
"abilities": child2.get_abilities(user),
|
||||||
|
"ancestors_link_reach": child2.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": child2.ancestors_link_role,
|
||||||
|
"computed_link_reach": child2.computed_link_reach,
|
||||||
|
"computed_link_role": child2.computed_link_role,
|
||||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(child2.creator.id),
|
"creator": str(child2.creator.id),
|
||||||
"depth": 4,
|
"depth": 4,
|
||||||
@@ -273,13 +321,15 @@ def test_api_documents_children_list_authenticated_public_or_authenticated_paren
|
|||||||
"path": child2.path,
|
"path": child2.path,
|
||||||
"title": child2.title,
|
"title": child2.title,
|
||||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_api_documents_children_list_authenticated_unrelated_restricted():
|
def test_api_documents_children_list_authenticated_unrelated_restricted(
|
||||||
|
django_assert_num_queries,
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Authenticated users should not be allowed to retrieve the children of a document that is
|
Authenticated users should not be allowed to retrieve the children of a document that is
|
||||||
restricted and to which they are not related.
|
restricted and to which they are not related.
|
||||||
@@ -293,16 +343,20 @@ def test_api_documents_children_list_authenticated_unrelated_restricted():
|
|||||||
child1, _child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
child1, _child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||||
factories.UserDocumentAccessFactory(document=child1)
|
factories.UserDocumentAccessFactory(document=child1)
|
||||||
|
|
||||||
response = client.get(
|
with django_assert_num_queries(2):
|
||||||
f"/api/v1.0/documents/{document.id!s}/children/",
|
response = client.get(
|
||||||
)
|
f"/api/v1.0/documents/{document.id!s}/children/",
|
||||||
|
)
|
||||||
|
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"detail": "You do not have permission to perform this action."
|
"detail": "You do not have permission to perform this action."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_api_documents_children_list_authenticated_related_direct():
|
def test_api_documents_children_list_authenticated_related_direct(
|
||||||
|
django_assert_num_queries,
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Authenticated users should be allowed to retrieve the children of a document
|
Authenticated users should be allowed to retrieve the children of a document
|
||||||
to which they are directly related whatever the role.
|
to which they are directly related whatever the role.
|
||||||
@@ -319,10 +373,13 @@ def test_api_documents_children_list_authenticated_related_direct():
|
|||||||
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||||
factories.UserDocumentAccessFactory(document=child1)
|
factories.UserDocumentAccessFactory(document=child1)
|
||||||
|
|
||||||
response = client.get(
|
with django_assert_num_queries(9):
|
||||||
f"/api/v1.0/documents/{document.id!s}/children/",
|
response = client.get(
|
||||||
)
|
f"/api/v1.0/documents/{document.id!s}/children/",
|
||||||
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
link_role = None if document.link_reach == "restricted" else document.link_role
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"count": 2,
|
"count": 2,
|
||||||
"next": None,
|
"next": None,
|
||||||
@@ -330,6 +387,10 @@ def test_api_documents_children_list_authenticated_related_direct():
|
|||||||
"results": [
|
"results": [
|
||||||
{
|
{
|
||||||
"abilities": child1.get_abilities(user),
|
"abilities": child1.get_abilities(user),
|
||||||
|
"ancestors_link_reach": document.link_reach,
|
||||||
|
"ancestors_link_role": link_role,
|
||||||
|
"computed_link_reach": child1.computed_link_reach,
|
||||||
|
"computed_link_role": child1.computed_link_role,
|
||||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(child1.creator.id),
|
"creator": str(child1.creator.id),
|
||||||
"depth": 2,
|
"depth": 2,
|
||||||
@@ -344,10 +405,14 @@ def test_api_documents_children_list_authenticated_related_direct():
|
|||||||
"path": child1.path,
|
"path": child1.path,
|
||||||
"title": child1.title,
|
"title": child1.title,
|
||||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [access.role],
|
"user_role": access.role,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"abilities": child2.get_abilities(user),
|
"abilities": child2.get_abilities(user),
|
||||||
|
"ancestors_link_reach": document.link_reach,
|
||||||
|
"ancestors_link_role": link_role,
|
||||||
|
"computed_link_reach": child2.computed_link_reach,
|
||||||
|
"computed_link_role": child2.computed_link_role,
|
||||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(child2.creator.id),
|
"creator": str(child2.creator.id),
|
||||||
"depth": 2,
|
"depth": 2,
|
||||||
@@ -362,13 +427,15 @@ def test_api_documents_children_list_authenticated_related_direct():
|
|||||||
"path": child2.path,
|
"path": child2.path,
|
||||||
"title": child2.title,
|
"title": child2.title,
|
||||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [access.role],
|
"user_role": access.role,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_api_documents_children_list_authenticated_related_parent():
|
def test_api_documents_children_list_authenticated_related_parent(
|
||||||
|
django_assert_num_queries,
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Authenticated users should be allowed to retrieve the children of a document if they
|
Authenticated users should be allowed to retrieve the children of a document if they
|
||||||
are related to one of its ancestors whatever the role.
|
are related to one of its ancestors whatever the role.
|
||||||
@@ -389,9 +456,11 @@ def test_api_documents_children_list_authenticated_related_parent():
|
|||||||
document=grand_parent, user=user
|
document=grand_parent, user=user
|
||||||
)
|
)
|
||||||
|
|
||||||
response = client.get(
|
with django_assert_num_queries(10):
|
||||||
f"/api/v1.0/documents/{document.id!s}/children/",
|
response = client.get(
|
||||||
)
|
f"/api/v1.0/documents/{document.id!s}/children/",
|
||||||
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"count": 2,
|
"count": 2,
|
||||||
@@ -400,6 +469,10 @@ def test_api_documents_children_list_authenticated_related_parent():
|
|||||||
"results": [
|
"results": [
|
||||||
{
|
{
|
||||||
"abilities": child1.get_abilities(user),
|
"abilities": child1.get_abilities(user),
|
||||||
|
"ancestors_link_reach": "restricted",
|
||||||
|
"ancestors_link_role": None,
|
||||||
|
"computed_link_reach": child1.computed_link_reach,
|
||||||
|
"computed_link_role": child1.computed_link_role,
|
||||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(child1.creator.id),
|
"creator": str(child1.creator.id),
|
||||||
"depth": 4,
|
"depth": 4,
|
||||||
@@ -414,10 +487,14 @@ def test_api_documents_children_list_authenticated_related_parent():
|
|||||||
"path": child1.path,
|
"path": child1.path,
|
||||||
"title": child1.title,
|
"title": child1.title,
|
||||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [grand_parent_access.role],
|
"user_role": grand_parent_access.role,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"abilities": child2.get_abilities(user),
|
"abilities": child2.get_abilities(user),
|
||||||
|
"ancestors_link_reach": "restricted",
|
||||||
|
"ancestors_link_role": None,
|
||||||
|
"computed_link_reach": child2.computed_link_reach,
|
||||||
|
"computed_link_role": child2.computed_link_role,
|
||||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(child2.creator.id),
|
"creator": str(child2.creator.id),
|
||||||
"depth": 4,
|
"depth": 4,
|
||||||
@@ -432,13 +509,15 @@ def test_api_documents_children_list_authenticated_related_parent():
|
|||||||
"path": child2.path,
|
"path": child2.path,
|
||||||
"title": child2.title,
|
"title": child2.title,
|
||||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [grand_parent_access.role],
|
"user_role": grand_parent_access.role,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_api_documents_children_list_authenticated_related_child():
|
def test_api_documents_children_list_authenticated_related_child(
|
||||||
|
django_assert_num_queries,
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Authenticated users should not be allowed to retrieve all the children of a document
|
Authenticated users should not be allowed to retrieve all the children of a document
|
||||||
as a result of being related to one of its children.
|
as a result of being related to one of its children.
|
||||||
@@ -454,16 +533,20 @@ def test_api_documents_children_list_authenticated_related_child():
|
|||||||
factories.UserDocumentAccessFactory(document=child1, user=user)
|
factories.UserDocumentAccessFactory(document=child1, user=user)
|
||||||
factories.UserDocumentAccessFactory(document=document)
|
factories.UserDocumentAccessFactory(document=document)
|
||||||
|
|
||||||
response = client.get(
|
with django_assert_num_queries(2):
|
||||||
f"/api/v1.0/documents/{document.id!s}/children/",
|
response = client.get(
|
||||||
)
|
f"/api/v1.0/documents/{document.id!s}/children/",
|
||||||
|
)
|
||||||
|
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"detail": "You do not have permission to perform this action."
|
"detail": "You do not have permission to perform this action."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_api_documents_children_list_authenticated_related_team_none(mock_user_teams):
|
def test_api_documents_children_list_authenticated_related_team_none(
|
||||||
|
mock_user_teams, django_assert_num_queries
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Authenticated users should not be able to retrieve the children of a restricted document
|
Authenticated users should not be able to retrieve the children of a restricted document
|
||||||
related to teams in which the user is not.
|
related to teams in which the user is not.
|
||||||
@@ -480,7 +563,9 @@ def test_api_documents_children_list_authenticated_related_team_none(mock_user_t
|
|||||||
|
|
||||||
factories.TeamDocumentAccessFactory(document=document, team="myteam")
|
factories.TeamDocumentAccessFactory(document=document, team="myteam")
|
||||||
|
|
||||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/children/")
|
with django_assert_num_queries(2):
|
||||||
|
response = client.get(f"/api/v1.0/documents/{document.id!s}/children/")
|
||||||
|
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"detail": "You do not have permission to perform this action."
|
"detail": "You do not have permission to perform this action."
|
||||||
@@ -488,7 +573,7 @@ def test_api_documents_children_list_authenticated_related_team_none(mock_user_t
|
|||||||
|
|
||||||
|
|
||||||
def test_api_documents_children_list_authenticated_related_team_members(
|
def test_api_documents_children_list_authenticated_related_team_members(
|
||||||
mock_user_teams,
|
mock_user_teams, django_assert_num_queries
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Authenticated users should be allowed to retrieve the children of a document to which they
|
Authenticated users should be allowed to retrieve the children of a document to which they
|
||||||
@@ -506,7 +591,8 @@ def test_api_documents_children_list_authenticated_related_team_members(
|
|||||||
|
|
||||||
access = factories.TeamDocumentAccessFactory(document=document, team="myteam")
|
access = factories.TeamDocumentAccessFactory(document=document, team="myteam")
|
||||||
|
|
||||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/children/")
|
with django_assert_num_queries(9):
|
||||||
|
response = client.get(f"/api/v1.0/documents/{document.id!s}/children/")
|
||||||
|
|
||||||
# pylint: disable=R0801
|
# pylint: disable=R0801
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -517,6 +603,10 @@ def test_api_documents_children_list_authenticated_related_team_members(
|
|||||||
"results": [
|
"results": [
|
||||||
{
|
{
|
||||||
"abilities": child1.get_abilities(user),
|
"abilities": child1.get_abilities(user),
|
||||||
|
"ancestors_link_reach": "restricted",
|
||||||
|
"ancestors_link_role": None,
|
||||||
|
"computed_link_reach": child1.computed_link_reach,
|
||||||
|
"computed_link_role": child1.computed_link_role,
|
||||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(child1.creator.id),
|
"creator": str(child1.creator.id),
|
||||||
"depth": 2,
|
"depth": 2,
|
||||||
@@ -531,10 +621,14 @@ def test_api_documents_children_list_authenticated_related_team_members(
|
|||||||
"path": child1.path,
|
"path": child1.path,
|
||||||
"title": child1.title,
|
"title": child1.title,
|
||||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [access.role],
|
"user_role": access.role,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"abilities": child2.get_abilities(user),
|
"abilities": child2.get_abilities(user),
|
||||||
|
"ancestors_link_reach": "restricted",
|
||||||
|
"ancestors_link_role": None,
|
||||||
|
"computed_link_reach": child2.computed_link_reach,
|
||||||
|
"computed_link_role": child2.computed_link_role,
|
||||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(child2.creator.id),
|
"creator": str(child2.creator.id),
|
||||||
"depth": 2,
|
"depth": 2,
|
||||||
@@ -549,7 +643,7 @@ def test_api_documents_children_list_authenticated_related_team_members(
|
|||||||
"path": child2.path,
|
"path": child2.path,
|
||||||
"title": child2.title,
|
"title": child2.title,
|
||||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [access.role],
|
"user_role": access.role,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
176
src/backend/core/tests/documents/test_api_documents_content.py
Normal file
176
src/backend/core/tests/documents/test_api_documents_content.py
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
"""
|
||||||
|
Tests for Documents API endpoint in impress's core app: content
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import requests
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from core import factories
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"reach, role",
|
||||||
|
[
|
||||||
|
("public", "reader"),
|
||||||
|
("public", "editor"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@patch("core.services.converter_services.YdocConverter.convert")
|
||||||
|
def test_api_documents_content_public(mock_content, reach, role):
|
||||||
|
"""Anonymous users should be allowed to access content of public documents."""
|
||||||
|
document = factories.DocumentFactory(link_reach=reach, link_role=role)
|
||||||
|
mock_content.return_value = {"some": "data"}
|
||||||
|
|
||||||
|
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/content/")
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
data = response.json()
|
||||||
|
assert data["id"] == str(document.id)
|
||||||
|
assert data["title"] == document.title
|
||||||
|
assert data["content"] == {"some": "data"}
|
||||||
|
mock_content.assert_called_once_with(
|
||||||
|
base64.b64decode(document.content),
|
||||||
|
"application/vnd.yjs.doc",
|
||||||
|
"application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"reach, doc_role, user_role",
|
||||||
|
[
|
||||||
|
("restricted", "reader", "reader"),
|
||||||
|
("restricted", "reader", "editor"),
|
||||||
|
("restricted", "reader", "administrator"),
|
||||||
|
("restricted", "reader", "owner"),
|
||||||
|
("restricted", "editor", "reader"),
|
||||||
|
("restricted", "editor", "editor"),
|
||||||
|
("restricted", "editor", "administrator"),
|
||||||
|
("restricted", "editor", "owner"),
|
||||||
|
("authenticated", "reader", None),
|
||||||
|
("authenticated", "editor", None),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@patch("core.services.converter_services.YdocConverter.convert")
|
||||||
|
def test_api_documents_content_not_public(mock_content, reach, doc_role, user_role):
|
||||||
|
"""Authenticated users need access to get non-public document content."""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
document = factories.DocumentFactory(link_reach=reach, link_role=doc_role)
|
||||||
|
mock_content.return_value = {"some": "data"}
|
||||||
|
|
||||||
|
# First anonymous request should fail
|
||||||
|
client = APIClient()
|
||||||
|
response = client.get(f"/api/v1.0/documents/{document.id!s}/content/")
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||||
|
mock_content.assert_not_called()
|
||||||
|
|
||||||
|
# Login and try again
|
||||||
|
client.force_login(user)
|
||||||
|
response = client.get(f"/api/v1.0/documents/{document.id!s}/content/")
|
||||||
|
|
||||||
|
# If restricted, we still should not have access
|
||||||
|
if user_role is not None:
|
||||||
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
|
mock_content.assert_not_called()
|
||||||
|
|
||||||
|
# Create an access as a reader. This should unlock the access.
|
||||||
|
factories.UserDocumentAccessFactory(
|
||||||
|
document=document, user=user, role=user_role
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.get(f"/api/v1.0/documents/{document.id!s}/content/")
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
data = response.json()
|
||||||
|
assert data["id"] == str(document.id)
|
||||||
|
assert data["title"] == document.title
|
||||||
|
assert data["content"] == {"some": "data"}
|
||||||
|
mock_content.assert_called_once_with(
|
||||||
|
base64.b64decode(document.content),
|
||||||
|
"application/vnd.yjs.doc",
|
||||||
|
"application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"content_format, accept",
|
||||||
|
[
|
||||||
|
("markdown", "text/markdown"),
|
||||||
|
("html", "text/html"),
|
||||||
|
("json", "application/json"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@patch("core.services.converter_services.YdocConverter.convert")
|
||||||
|
def test_api_documents_content_format(mock_content, content_format, accept):
|
||||||
|
"""Test that the content endpoint returns a specific format."""
|
||||||
|
document = factories.DocumentFactory(link_reach="public")
|
||||||
|
mock_content.return_value = {"some": "data"}
|
||||||
|
|
||||||
|
response = APIClient().get(
|
||||||
|
f"/api/v1.0/documents/{document.id!s}/content/?content_format={content_format}"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
data = response.json()
|
||||||
|
assert data["id"] == str(document.id)
|
||||||
|
assert data["title"] == document.title
|
||||||
|
assert data["content"] == {"some": "data"}
|
||||||
|
mock_content.assert_called_once_with(
|
||||||
|
base64.b64decode(document.content), "application/vnd.yjs.doc", accept
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@patch("core.services.converter_services.YdocConverter._request")
|
||||||
|
def test_api_documents_content_invalid_format(mock_request):
|
||||||
|
"""Test that the content endpoint rejects invalid formats."""
|
||||||
|
document = factories.DocumentFactory(link_reach="public")
|
||||||
|
|
||||||
|
response = APIClient().get(
|
||||||
|
f"/api/v1.0/documents/{document.id!s}/content/?content_format=invalid"
|
||||||
|
)
|
||||||
|
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||||
|
mock_request.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@patch("core.services.converter_services.YdocConverter._request")
|
||||||
|
def test_api_documents_content_yservice_error(mock_request):
|
||||||
|
"""Test that service errors are handled properly."""
|
||||||
|
document = factories.DocumentFactory(link_reach="public")
|
||||||
|
mock_request.side_effect = requests.RequestException()
|
||||||
|
|
||||||
|
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/content/")
|
||||||
|
mock_request.assert_called_once()
|
||||||
|
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
|
|
||||||
|
|
||||||
|
@patch("core.services.converter_services.YdocConverter._request")
|
||||||
|
def test_api_documents_content_nonexistent_document(mock_request):
|
||||||
|
"""Test that accessing a nonexistent document returns 404."""
|
||||||
|
client = APIClient()
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1.0/documents/00000000-0000-0000-0000-000000000000/content/"
|
||||||
|
)
|
||||||
|
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||||
|
mock_request.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@patch("core.services.converter_services.YdocConverter._request")
|
||||||
|
def test_api_documents_content_empty_document(mock_request):
|
||||||
|
"""Test that accessing an empty document returns empty content."""
|
||||||
|
document = factories.DocumentFactory(link_reach="public", content="")
|
||||||
|
|
||||||
|
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/content/")
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
data = response.json()
|
||||||
|
assert data["id"] == str(document.id)
|
||||||
|
assert data["title"] == document.title
|
||||||
|
assert data["content"] is None
|
||||||
|
mock_request.assert_not_called()
|
||||||
@@ -23,10 +23,25 @@ def test_api_docs_cors_proxy_valid_url():
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.headers["Content-Type"] == "image/png"
|
assert response.headers["Content-Type"] == "image/png"
|
||||||
assert response.headers["Content-Disposition"] == "attachment;"
|
assert response.headers["Content-Disposition"] == "attachment;"
|
||||||
assert (
|
policy_list = sorted(response.headers["Content-Security-Policy"].split("; "))
|
||||||
response.headers["Content-Security-Policy"]
|
assert policy_list == [
|
||||||
== "default-src 'none'; img-src 'none' data:;"
|
"base-uri 'none'",
|
||||||
)
|
"child-src 'none'",
|
||||||
|
"connect-src 'none'",
|
||||||
|
"default-src 'none'",
|
||||||
|
"font-src 'none'",
|
||||||
|
"form-action 'none'",
|
||||||
|
"frame-ancestors 'none'",
|
||||||
|
"frame-src 'none'",
|
||||||
|
"img-src 'none' data:",
|
||||||
|
"manifest-src 'none'",
|
||||||
|
"media-src 'none'",
|
||||||
|
"object-src 'none'",
|
||||||
|
"prefetch-src 'none'",
|
||||||
|
"script-src 'none'",
|
||||||
|
"style-src 'none'",
|
||||||
|
"worker-src 'none'",
|
||||||
|
]
|
||||||
assert response.streaming_content
|
assert response.streaming_content
|
||||||
|
|
||||||
|
|
||||||
@@ -77,10 +92,25 @@ def test_api_docs_cors_proxy_authenticated_user_accessing_protected_doc():
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.headers["Content-Type"] == "image/png"
|
assert response.headers["Content-Type"] == "image/png"
|
||||||
assert response.headers["Content-Disposition"] == "attachment;"
|
assert response.headers["Content-Disposition"] == "attachment;"
|
||||||
assert (
|
policy_list = sorted(response.headers["Content-Security-Policy"].split("; "))
|
||||||
response.headers["Content-Security-Policy"]
|
assert policy_list == [
|
||||||
== "default-src 'none'; img-src 'none' data:;"
|
"base-uri 'none'",
|
||||||
)
|
"child-src 'none'",
|
||||||
|
"connect-src 'none'",
|
||||||
|
"default-src 'none'",
|
||||||
|
"font-src 'none'",
|
||||||
|
"form-action 'none'",
|
||||||
|
"frame-ancestors 'none'",
|
||||||
|
"frame-src 'none'",
|
||||||
|
"img-src 'none' data:",
|
||||||
|
"manifest-src 'none'",
|
||||||
|
"media-src 'none'",
|
||||||
|
"object-src 'none'",
|
||||||
|
"prefetch-src 'none'",
|
||||||
|
"script-src 'none'",
|
||||||
|
"style-src 'none'",
|
||||||
|
"worker-src 'none'",
|
||||||
|
]
|
||||||
assert response.streaming_content
|
assert response.streaming_content
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -23,10 +23,10 @@ pytestmark = pytest.mark.django_db
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_convert_md():
|
def mock_convert_md():
|
||||||
"""Mock YdocConverter.convert_markdown to return a converted content."""
|
"""Mock YdocConverter.convert to return a converted content."""
|
||||||
with patch.object(
|
with patch.object(
|
||||||
YdocConverter,
|
YdocConverter,
|
||||||
"convert_markdown",
|
"convert",
|
||||||
return_value="Converted document content",
|
return_value="Converted document content",
|
||||||
) as mock:
|
) as mock:
|
||||||
yield mock
|
yield mock
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ def test_api_documents_descendants_list_anonymous_public_standalone():
|
|||||||
"results": [
|
"results": [
|
||||||
{
|
{
|
||||||
"abilities": child1.get_abilities(AnonymousUser()),
|
"abilities": child1.get_abilities(AnonymousUser()),
|
||||||
|
"ancestors_link_reach": "public",
|
||||||
|
"ancestors_link_role": document.link_role,
|
||||||
|
"computed_link_reach": child1.computed_link_reach,
|
||||||
|
"computed_link_role": child1.computed_link_role,
|
||||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(child1.creator.id),
|
"creator": str(child1.creator.id),
|
||||||
"depth": 2,
|
"depth": 2,
|
||||||
@@ -46,10 +50,16 @@ def test_api_documents_descendants_list_anonymous_public_standalone():
|
|||||||
"path": child1.path,
|
"path": child1.path,
|
||||||
"title": child1.title,
|
"title": child1.title,
|
||||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"abilities": grand_child.get_abilities(AnonymousUser()),
|
"abilities": grand_child.get_abilities(AnonymousUser()),
|
||||||
|
"ancestors_link_reach": "public",
|
||||||
|
"ancestors_link_role": "editor"
|
||||||
|
if (child1.link_reach == "public" and child1.link_role == "editor")
|
||||||
|
else document.link_role,
|
||||||
|
"computed_link_reach": "public",
|
||||||
|
"computed_link_role": grand_child.computed_link_role,
|
||||||
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(grand_child.creator.id),
|
"creator": str(grand_child.creator.id),
|
||||||
"depth": 3,
|
"depth": 3,
|
||||||
@@ -64,10 +74,14 @@ def test_api_documents_descendants_list_anonymous_public_standalone():
|
|||||||
"path": grand_child.path,
|
"path": grand_child.path,
|
||||||
"title": grand_child.title,
|
"title": grand_child.title,
|
||||||
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"abilities": child2.get_abilities(AnonymousUser()),
|
"abilities": child2.get_abilities(AnonymousUser()),
|
||||||
|
"ancestors_link_reach": "public",
|
||||||
|
"ancestors_link_role": document.link_role,
|
||||||
|
"computed_link_reach": "public",
|
||||||
|
"computed_link_role": child2.computed_link_role,
|
||||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(child2.creator.id),
|
"creator": str(child2.creator.id),
|
||||||
"depth": 2,
|
"depth": 2,
|
||||||
@@ -82,7 +96,7 @@ def test_api_documents_descendants_list_anonymous_public_standalone():
|
|||||||
"path": child2.path,
|
"path": child2.path,
|
||||||
"title": child2.title,
|
"title": child2.title,
|
||||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -115,6 +129,10 @@ def test_api_documents_descendants_list_anonymous_public_parent():
|
|||||||
"results": [
|
"results": [
|
||||||
{
|
{
|
||||||
"abilities": child1.get_abilities(AnonymousUser()),
|
"abilities": child1.get_abilities(AnonymousUser()),
|
||||||
|
"ancestors_link_reach": "public",
|
||||||
|
"ancestors_link_role": grand_parent.link_role,
|
||||||
|
"computed_link_reach": child1.computed_link_reach,
|
||||||
|
"computed_link_role": child1.computed_link_role,
|
||||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(child1.creator.id),
|
"creator": str(child1.creator.id),
|
||||||
"depth": 4,
|
"depth": 4,
|
||||||
@@ -129,10 +147,14 @@ def test_api_documents_descendants_list_anonymous_public_parent():
|
|||||||
"path": child1.path,
|
"path": child1.path,
|
||||||
"title": child1.title,
|
"title": child1.title,
|
||||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"abilities": grand_child.get_abilities(AnonymousUser()),
|
"abilities": grand_child.get_abilities(AnonymousUser()),
|
||||||
|
"ancestors_link_reach": "public",
|
||||||
|
"ancestors_link_role": grand_child.ancestors_link_role,
|
||||||
|
"computed_link_reach": "public",
|
||||||
|
"computed_link_role": grand_child.computed_link_role,
|
||||||
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(grand_child.creator.id),
|
"creator": str(grand_child.creator.id),
|
||||||
"depth": 5,
|
"depth": 5,
|
||||||
@@ -147,10 +169,14 @@ def test_api_documents_descendants_list_anonymous_public_parent():
|
|||||||
"path": grand_child.path,
|
"path": grand_child.path,
|
||||||
"title": grand_child.title,
|
"title": grand_child.title,
|
||||||
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"abilities": child2.get_abilities(AnonymousUser()),
|
"abilities": child2.get_abilities(AnonymousUser()),
|
||||||
|
"ancestors_link_reach": "public",
|
||||||
|
"ancestors_link_role": grand_parent.link_role,
|
||||||
|
"computed_link_reach": "public",
|
||||||
|
"computed_link_role": child2.computed_link_role,
|
||||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(child2.creator.id),
|
"creator": str(child2.creator.id),
|
||||||
"depth": 4,
|
"depth": 4,
|
||||||
@@ -165,7 +191,7 @@ def test_api_documents_descendants_list_anonymous_public_parent():
|
|||||||
"path": child2.path,
|
"path": child2.path,
|
||||||
"title": child2.title,
|
"title": child2.title,
|
||||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -201,7 +227,9 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen
|
|||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
|
|
||||||
document = factories.DocumentFactory(link_reach=reach)
|
document = factories.DocumentFactory(link_reach=reach)
|
||||||
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
child1, child2 = factories.DocumentFactory.create_batch(
|
||||||
|
2, parent=document, link_reach="restricted"
|
||||||
|
)
|
||||||
grand_child = factories.DocumentFactory(parent=child1)
|
grand_child = factories.DocumentFactory(parent=child1)
|
||||||
|
|
||||||
factories.UserDocumentAccessFactory(document=child1)
|
factories.UserDocumentAccessFactory(document=child1)
|
||||||
@@ -217,6 +245,10 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen
|
|||||||
"results": [
|
"results": [
|
||||||
{
|
{
|
||||||
"abilities": child1.get_abilities(user),
|
"abilities": child1.get_abilities(user),
|
||||||
|
"ancestors_link_reach": reach,
|
||||||
|
"ancestors_link_role": document.link_role,
|
||||||
|
"computed_link_reach": child1.computed_link_reach,
|
||||||
|
"computed_link_role": child1.computed_link_role,
|
||||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(child1.creator.id),
|
"creator": str(child1.creator.id),
|
||||||
"depth": 2,
|
"depth": 2,
|
||||||
@@ -231,10 +263,14 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen
|
|||||||
"path": child1.path,
|
"path": child1.path,
|
||||||
"title": child1.title,
|
"title": child1.title,
|
||||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"abilities": grand_child.get_abilities(user),
|
"abilities": grand_child.get_abilities(user),
|
||||||
|
"ancestors_link_reach": reach,
|
||||||
|
"ancestors_link_role": document.link_role,
|
||||||
|
"computed_link_reach": grand_child.computed_link_reach,
|
||||||
|
"computed_link_role": grand_child.computed_link_role,
|
||||||
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(grand_child.creator.id),
|
"creator": str(grand_child.creator.id),
|
||||||
"depth": 3,
|
"depth": 3,
|
||||||
@@ -249,10 +285,14 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen
|
|||||||
"path": grand_child.path,
|
"path": grand_child.path,
|
||||||
"title": grand_child.title,
|
"title": grand_child.title,
|
||||||
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"abilities": child2.get_abilities(user),
|
"abilities": child2.get_abilities(user),
|
||||||
|
"ancestors_link_reach": reach,
|
||||||
|
"ancestors_link_role": document.link_role,
|
||||||
|
"computed_link_reach": child2.computed_link_reach,
|
||||||
|
"computed_link_role": child2.computed_link_role,
|
||||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(child2.creator.id),
|
"creator": str(child2.creator.id),
|
||||||
"depth": 2,
|
"depth": 2,
|
||||||
@@ -267,7 +307,7 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen
|
|||||||
"path": child2.path,
|
"path": child2.path,
|
||||||
"title": child2.title,
|
"title": child2.title,
|
||||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -289,7 +329,9 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa
|
|||||||
grand_parent = factories.DocumentFactory(link_reach=reach)
|
grand_parent = factories.DocumentFactory(link_reach=reach)
|
||||||
parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted")
|
parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted")
|
||||||
document = factories.DocumentFactory(link_reach="restricted", parent=parent)
|
document = factories.DocumentFactory(link_reach="restricted", parent=parent)
|
||||||
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
child1, child2 = factories.DocumentFactory.create_batch(
|
||||||
|
2, parent=document, link_reach="restricted"
|
||||||
|
)
|
||||||
grand_child = factories.DocumentFactory(parent=child1)
|
grand_child = factories.DocumentFactory(parent=child1)
|
||||||
|
|
||||||
factories.UserDocumentAccessFactory(document=child1)
|
factories.UserDocumentAccessFactory(document=child1)
|
||||||
@@ -304,6 +346,10 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa
|
|||||||
"results": [
|
"results": [
|
||||||
{
|
{
|
||||||
"abilities": child1.get_abilities(user),
|
"abilities": child1.get_abilities(user),
|
||||||
|
"ancestors_link_reach": reach,
|
||||||
|
"ancestors_link_role": grand_parent.link_role,
|
||||||
|
"computed_link_reach": child1.computed_link_reach,
|
||||||
|
"computed_link_role": child1.computed_link_role,
|
||||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(child1.creator.id),
|
"creator": str(child1.creator.id),
|
||||||
"depth": 4,
|
"depth": 4,
|
||||||
@@ -318,10 +364,14 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa
|
|||||||
"path": child1.path,
|
"path": child1.path,
|
||||||
"title": child1.title,
|
"title": child1.title,
|
||||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"abilities": grand_child.get_abilities(user),
|
"abilities": grand_child.get_abilities(user),
|
||||||
|
"ancestors_link_reach": reach,
|
||||||
|
"ancestors_link_role": grand_parent.link_role,
|
||||||
|
"computed_link_reach": grand_child.computed_link_reach,
|
||||||
|
"computed_link_role": grand_child.computed_link_role,
|
||||||
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(grand_child.creator.id),
|
"creator": str(grand_child.creator.id),
|
||||||
"depth": 5,
|
"depth": 5,
|
||||||
@@ -336,10 +386,14 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa
|
|||||||
"path": grand_child.path,
|
"path": grand_child.path,
|
||||||
"title": grand_child.title,
|
"title": grand_child.title,
|
||||||
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"abilities": child2.get_abilities(user),
|
"abilities": child2.get_abilities(user),
|
||||||
|
"ancestors_link_reach": reach,
|
||||||
|
"ancestors_link_role": grand_parent.link_role,
|
||||||
|
"computed_link_reach": child2.computed_link_reach,
|
||||||
|
"computed_link_role": child2.computed_link_role,
|
||||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(child2.creator.id),
|
"creator": str(child2.creator.id),
|
||||||
"depth": 4,
|
"depth": 4,
|
||||||
@@ -354,7 +408,7 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa
|
|||||||
"path": child2.path,
|
"path": child2.path,
|
||||||
"title": child2.title,
|
"title": child2.title,
|
||||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -414,6 +468,10 @@ def test_api_documents_descendants_list_authenticated_related_direct():
|
|||||||
"results": [
|
"results": [
|
||||||
{
|
{
|
||||||
"abilities": child1.get_abilities(user),
|
"abilities": child1.get_abilities(user),
|
||||||
|
"ancestors_link_reach": child1.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": child1.ancestors_link_role,
|
||||||
|
"computed_link_reach": child1.computed_link_reach,
|
||||||
|
"computed_link_role": child1.computed_link_role,
|
||||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(child1.creator.id),
|
"creator": str(child1.creator.id),
|
||||||
"depth": 2,
|
"depth": 2,
|
||||||
@@ -428,10 +486,14 @@ def test_api_documents_descendants_list_authenticated_related_direct():
|
|||||||
"path": child1.path,
|
"path": child1.path,
|
||||||
"title": child1.title,
|
"title": child1.title,
|
||||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [access.role],
|
"user_role": access.role,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"abilities": grand_child.get_abilities(user),
|
"abilities": grand_child.get_abilities(user),
|
||||||
|
"ancestors_link_reach": grand_child.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": grand_child.ancestors_link_role,
|
||||||
|
"computed_link_reach": grand_child.computed_link_reach,
|
||||||
|
"computed_link_role": grand_child.computed_link_role,
|
||||||
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(grand_child.creator.id),
|
"creator": str(grand_child.creator.id),
|
||||||
"depth": 3,
|
"depth": 3,
|
||||||
@@ -446,10 +508,14 @@ def test_api_documents_descendants_list_authenticated_related_direct():
|
|||||||
"path": grand_child.path,
|
"path": grand_child.path,
|
||||||
"title": grand_child.title,
|
"title": grand_child.title,
|
||||||
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [access.role],
|
"user_role": access.role,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"abilities": child2.get_abilities(user),
|
"abilities": child2.get_abilities(user),
|
||||||
|
"ancestors_link_reach": child2.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": child2.ancestors_link_role,
|
||||||
|
"computed_link_reach": child2.computed_link_reach,
|
||||||
|
"computed_link_role": child2.computed_link_role,
|
||||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(child2.creator.id),
|
"creator": str(child2.creator.id),
|
||||||
"depth": 2,
|
"depth": 2,
|
||||||
@@ -464,7 +530,7 @@ def test_api_documents_descendants_list_authenticated_related_direct():
|
|||||||
"path": child2.path,
|
"path": child2.path,
|
||||||
"title": child2.title,
|
"title": child2.title,
|
||||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [access.role],
|
"user_role": access.role,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -504,6 +570,10 @@ def test_api_documents_descendants_list_authenticated_related_parent():
|
|||||||
"results": [
|
"results": [
|
||||||
{
|
{
|
||||||
"abilities": child1.get_abilities(user),
|
"abilities": child1.get_abilities(user),
|
||||||
|
"ancestors_link_reach": child1.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": child1.ancestors_link_role,
|
||||||
|
"computed_link_reach": child1.computed_link_reach,
|
||||||
|
"computed_link_role": child1.computed_link_role,
|
||||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(child1.creator.id),
|
"creator": str(child1.creator.id),
|
||||||
"depth": 4,
|
"depth": 4,
|
||||||
@@ -518,10 +588,14 @@ def test_api_documents_descendants_list_authenticated_related_parent():
|
|||||||
"path": child1.path,
|
"path": child1.path,
|
||||||
"title": child1.title,
|
"title": child1.title,
|
||||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [grand_parent_access.role],
|
"user_role": grand_parent_access.role,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"abilities": grand_child.get_abilities(user),
|
"abilities": grand_child.get_abilities(user),
|
||||||
|
"ancestors_link_reach": grand_child.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": grand_child.ancestors_link_role,
|
||||||
|
"computed_link_reach": grand_child.computed_link_reach,
|
||||||
|
"computed_link_role": grand_child.computed_link_role,
|
||||||
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(grand_child.creator.id),
|
"creator": str(grand_child.creator.id),
|
||||||
"depth": 5,
|
"depth": 5,
|
||||||
@@ -536,10 +610,14 @@ def test_api_documents_descendants_list_authenticated_related_parent():
|
|||||||
"path": grand_child.path,
|
"path": grand_child.path,
|
||||||
"title": grand_child.title,
|
"title": grand_child.title,
|
||||||
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [grand_parent_access.role],
|
"user_role": grand_parent_access.role,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"abilities": child2.get_abilities(user),
|
"abilities": child2.get_abilities(user),
|
||||||
|
"ancestors_link_reach": child2.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": child2.ancestors_link_role,
|
||||||
|
"computed_link_reach": child2.computed_link_reach,
|
||||||
|
"computed_link_role": child2.computed_link_role,
|
||||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(child2.creator.id),
|
"creator": str(child2.creator.id),
|
||||||
"depth": 4,
|
"depth": 4,
|
||||||
@@ -554,7 +632,7 @@ def test_api_documents_descendants_list_authenticated_related_parent():
|
|||||||
"path": child2.path,
|
"path": child2.path,
|
||||||
"title": child2.title,
|
"title": child2.title,
|
||||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [grand_parent_access.role],
|
"user_role": grand_parent_access.role,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -640,6 +718,10 @@ def test_api_documents_descendants_list_authenticated_related_team_members(
|
|||||||
"results": [
|
"results": [
|
||||||
{
|
{
|
||||||
"abilities": child1.get_abilities(user),
|
"abilities": child1.get_abilities(user),
|
||||||
|
"ancestors_link_reach": child1.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": child1.ancestors_link_role,
|
||||||
|
"computed_link_reach": child1.computed_link_reach,
|
||||||
|
"computed_link_role": child1.computed_link_role,
|
||||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(child1.creator.id),
|
"creator": str(child1.creator.id),
|
||||||
"depth": 2,
|
"depth": 2,
|
||||||
@@ -654,10 +736,14 @@ def test_api_documents_descendants_list_authenticated_related_team_members(
|
|||||||
"path": child1.path,
|
"path": child1.path,
|
||||||
"title": child1.title,
|
"title": child1.title,
|
||||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [access.role],
|
"user_role": access.role,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"abilities": grand_child.get_abilities(user),
|
"abilities": grand_child.get_abilities(user),
|
||||||
|
"ancestors_link_reach": grand_child.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": grand_child.ancestors_link_role,
|
||||||
|
"computed_link_reach": grand_child.computed_link_reach,
|
||||||
|
"computed_link_role": grand_child.computed_link_role,
|
||||||
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(grand_child.creator.id),
|
"creator": str(grand_child.creator.id),
|
||||||
"depth": 3,
|
"depth": 3,
|
||||||
@@ -672,10 +758,14 @@ def test_api_documents_descendants_list_authenticated_related_team_members(
|
|||||||
"path": grand_child.path,
|
"path": grand_child.path,
|
||||||
"title": grand_child.title,
|
"title": grand_child.title,
|
||||||
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [access.role],
|
"user_role": access.role,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"abilities": child2.get_abilities(user),
|
"abilities": child2.get_abilities(user),
|
||||||
|
"ancestors_link_reach": child2.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": child2.ancestors_link_role,
|
||||||
|
"computed_link_reach": child2.computed_link_reach,
|
||||||
|
"computed_link_role": child2.computed_link_role,
|
||||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(child2.creator.id),
|
"creator": str(child2.creator.id),
|
||||||
"depth": 2,
|
"depth": 2,
|
||||||
@@ -690,7 +780,7 @@ def test_api_documents_descendants_list_authenticated_related_team_members(
|
|||||||
"path": child2.path,
|
"path": child2.path,
|
||||||
"title": child2.title,
|
"title": child2.title,
|
||||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [access.role],
|
"user_role": access.role,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from django.utils import timezone
|
|||||||
import pycrdt
|
import pycrdt
|
||||||
import pytest
|
import pytest
|
||||||
import requests
|
import requests
|
||||||
|
from freezegun import freeze_time
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from core import factories, models
|
from core import factories, models
|
||||||
@@ -60,7 +61,7 @@ def test_api_documents_duplicate_forbidden():
|
|||||||
def test_api_documents_duplicate_anonymous():
|
def test_api_documents_duplicate_anonymous():
|
||||||
"""Anonymous users should not be able to duplicate documents even with read access."""
|
"""Anonymous users should not be able to duplicate documents even with read access."""
|
||||||
|
|
||||||
document = factories.DocumentFactory(link_reach="public")
|
document = factories.DocumentFactory(link_reach="public", link_role="reader")
|
||||||
|
|
||||||
response = APIClient().post(f"/api/v1.0/documents/{document.id!s}/duplicate/")
|
response = APIClient().post(f"/api/v1.0/documents/{document.id!s}/duplicate/")
|
||||||
|
|
||||||
@@ -133,19 +134,21 @@ def test_api_documents_duplicate_success(index):
|
|||||||
|
|
||||||
# Ensure access persists after the owner loses access to the original document
|
# Ensure access persists after the owner loses access to the original document
|
||||||
models.DocumentAccess.objects.filter(document=document).delete()
|
models.DocumentAccess.objects.filter(document=document).delete()
|
||||||
response = client.get(
|
|
||||||
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=image_refs[0][1]
|
now = timezone.now()
|
||||||
)
|
with freeze_time(now):
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=image_refs[0][1]
|
||||||
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
assert response["X-Amz-Date"] == now.strftime("%Y%m%dT%H%M%SZ")
|
||||||
authorization = response["Authorization"]
|
authorization = response["Authorization"]
|
||||||
assert "AWS4-HMAC-SHA256 Credential=" in authorization
|
assert "AWS4-HMAC-SHA256 Credential=" in authorization
|
||||||
assert (
|
assert (
|
||||||
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
|
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
|
||||||
in authorization
|
in authorization
|
||||||
)
|
)
|
||||||
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
|
|
||||||
|
|
||||||
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
|
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
|
||||||
response = requests.get(
|
response = requests.get(
|
||||||
@@ -168,14 +171,17 @@ def test_api_documents_duplicate_success(index):
|
|||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
def test_api_documents_duplicate_with_accesses():
|
@pytest.mark.parametrize("role", ["owner", "administrator"])
|
||||||
"""Accesses should be duplicated if the user requests it specifically."""
|
def test_api_documents_duplicate_with_accesses_admin(role):
|
||||||
|
"""
|
||||||
|
Accesses should be duplicated if the user requests it specifically and is owner or admin.
|
||||||
|
"""
|
||||||
user = factories.UserFactory()
|
user = factories.UserFactory()
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
|
|
||||||
document = factories.DocumentFactory(
|
document = factories.DocumentFactory(
|
||||||
users=[user],
|
users=[(user, role)],
|
||||||
title="document with accesses",
|
title="document with accesses",
|
||||||
)
|
)
|
||||||
user_access = factories.UserDocumentAccessFactory(document=document)
|
user_access = factories.UserDocumentAccessFactory(document=document)
|
||||||
@@ -205,3 +211,85 @@ def test_api_documents_duplicate_with_accesses():
|
|||||||
assert duplicated_accesses.get(user=user).role == "owner"
|
assert duplicated_accesses.get(user=user).role == "owner"
|
||||||
assert duplicated_accesses.get(user=user_access.user).role == user_access.role
|
assert duplicated_accesses.get(user=user_access.user).role == user_access.role
|
||||||
assert duplicated_accesses.get(team=team_access.team).role == team_access.role
|
assert duplicated_accesses.get(team=team_access.team).role == team_access.role
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("role", ["editor", "reader"])
|
||||||
|
def test_api_documents_duplicate_with_accesses_non_admin(role):
|
||||||
|
"""
|
||||||
|
Accesses should not be duplicated if the user requests it specifically and is not owner
|
||||||
|
or admin.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
document = factories.DocumentFactory(
|
||||||
|
users=[(user, role)],
|
||||||
|
title="document with accesses",
|
||||||
|
)
|
||||||
|
factories.UserDocumentAccessFactory(document=document)
|
||||||
|
factories.TeamDocumentAccessFactory(document=document)
|
||||||
|
|
||||||
|
# Duplicate the document via the API endpoint requesting to duplicate accesses
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/documents/{document.id!s}/duplicate/",
|
||||||
|
{"with_accesses": True},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
duplicated_document = models.Document.objects.get(id=response.json()["id"])
|
||||||
|
assert duplicated_document.title == "Copy of document with accesses"
|
||||||
|
assert duplicated_document.content == document.content
|
||||||
|
assert duplicated_document.link_reach == document.link_reach
|
||||||
|
assert duplicated_document.link_role == document.link_role
|
||||||
|
assert duplicated_document.creator == user
|
||||||
|
assert duplicated_document.duplicated_from == document
|
||||||
|
assert duplicated_document.attachments == []
|
||||||
|
|
||||||
|
# Check that accesses were duplicated and the user who did the duplicate is forced as owner
|
||||||
|
duplicated_accesses = duplicated_document.accesses
|
||||||
|
assert duplicated_accesses.count() == 1
|
||||||
|
assert duplicated_accesses.get(user=user).role == "owner"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("role", ["editor", "reader"])
|
||||||
|
def test_api_documents_duplicate_non_root_document(role):
|
||||||
|
"""
|
||||||
|
Non-root documents can be duplicated but without accesses.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
document = factories.DocumentFactory(users=[(user, "owner")])
|
||||||
|
child = factories.DocumentFactory(
|
||||||
|
parent=document, users=[(user, role)], title="document with accesses"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert child.accesses.count() == 1
|
||||||
|
|
||||||
|
# Duplicate the document via the API endpoint requesting to duplicate accesses
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/documents/{child.id!s}/duplicate/",
|
||||||
|
{"with_accesses": True},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
duplicated_document = models.Document.objects.get(id=response.json()["id"])
|
||||||
|
assert duplicated_document.title == "Copy of document with accesses"
|
||||||
|
assert duplicated_document.content == child.content
|
||||||
|
assert duplicated_document.link_reach == child.link_reach
|
||||||
|
assert duplicated_document.link_role == child.link_role
|
||||||
|
assert duplicated_document.creator == user
|
||||||
|
assert duplicated_document.duplicated_from == child
|
||||||
|
assert duplicated_document.attachments == []
|
||||||
|
|
||||||
|
# No access should be created for non root documents
|
||||||
|
duplicated_accesses = duplicated_document.accesses
|
||||||
|
assert duplicated_accesses.count() == 0
|
||||||
|
assert duplicated_document.is_sibling_of(child)
|
||||||
|
assert duplicated_document.is_child_of(document)
|
||||||
|
|||||||
@@ -45,7 +45,10 @@ def test_api_document_favorite_anonymous_user(method, reach):
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_api_document_favorite_authenticated_post_allowed(reach, has_role):
|
def test_api_document_favorite_authenticated_post_allowed(reach, has_role):
|
||||||
"""Authenticated users should be able to mark a document as favorite using POST."""
|
"""
|
||||||
|
Authenticated users should be able to mark a document to which they have access
|
||||||
|
as favorite using POST.
|
||||||
|
"""
|
||||||
user = factories.UserFactory()
|
user = factories.UserFactory()
|
||||||
document = factories.DocumentFactory(link_reach=reach)
|
document = factories.DocumentFactory(link_reach=reach)
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
@@ -69,7 +72,10 @@ def test_api_document_favorite_authenticated_post_allowed(reach, has_role):
|
|||||||
|
|
||||||
|
|
||||||
def test_api_document_favorite_authenticated_post_forbidden():
|
def test_api_document_favorite_authenticated_post_forbidden():
|
||||||
"""Authenticated users should be able to mark a document as favorite using POST."""
|
"""
|
||||||
|
Authenticated users should not be allowed to mark a document to which they don't
|
||||||
|
have access as favorite using POST.
|
||||||
|
"""
|
||||||
user = factories.UserFactory()
|
user = factories.UserFactory()
|
||||||
document = factories.DocumentFactory(link_reach="restricted")
|
document = factories.DocumentFactory(link_reach="restricted")
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ def test_api_document_favorite_list_authenticated_with_favorite():
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
|
|
||||||
# User don't have access to this document, let say it had access and this access has been
|
# If the user doesn't have access to this document (e.g the user had access
|
||||||
# removed. It should not be in the favorite list anymore.
|
# and this access was removed), it should not be in the favorite list anymore.
|
||||||
factories.DocumentFactory(favorited_by=[user])
|
factories.DocumentFactory(favorited_by=[user])
|
||||||
|
|
||||||
document = factories.UserDocumentAccessFactory(
|
document = factories.UserDocumentAccessFactory(
|
||||||
@@ -59,6 +59,10 @@ def test_api_document_favorite_list_authenticated_with_favorite():
|
|||||||
"results": [
|
"results": [
|
||||||
{
|
{
|
||||||
"abilities": document.get_abilities(user),
|
"abilities": document.get_abilities(user),
|
||||||
|
"ancestors_link_reach": None,
|
||||||
|
"ancestors_link_role": None,
|
||||||
|
"computed_link_reach": document.computed_link_reach,
|
||||||
|
"computed_link_role": document.computed_link_role,
|
||||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(document.creator.id),
|
"creator": str(document.creator.id),
|
||||||
"content": document.content,
|
"content": document.content,
|
||||||
@@ -74,7 +78,7 @@ def test_api_document_favorite_list_authenticated_with_favorite():
|
|||||||
"path": document.path,
|
"path": document.path,
|
||||||
"title": document.title,
|
"title": document.title,
|
||||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": ["reader"],
|
"user_role": "reader",
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,10 @@ def test_api_documents_list_format():
|
|||||||
assert results[0] == {
|
assert results[0] == {
|
||||||
"id": str(document.id),
|
"id": str(document.id),
|
||||||
"abilities": document.get_abilities(user),
|
"abilities": document.get_abilities(user),
|
||||||
|
"ancestors_link_reach": None,
|
||||||
|
"ancestors_link_role": None,
|
||||||
|
"computed_link_reach": document.computed_link_reach,
|
||||||
|
"computed_link_role": document.computed_link_role,
|
||||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(document.creator.id),
|
"creator": str(document.creator.id),
|
||||||
"depth": 1,
|
"depth": 1,
|
||||||
@@ -76,7 +80,7 @@ def test_api_documents_list_format():
|
|||||||
"path": document.path,
|
"path": document.path,
|
||||||
"title": document.title,
|
"title": document.title,
|
||||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [access.role],
|
"user_role": access.role,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -148,11 +152,11 @@ def test_api_documents_list_authenticated_direct(django_assert_num_queries):
|
|||||||
str(child4_with_access.id),
|
str(child4_with_access.id),
|
||||||
}
|
}
|
||||||
|
|
||||||
with django_assert_num_queries(12):
|
with django_assert_num_queries(14):
|
||||||
response = client.get("/api/v1.0/documents/")
|
response = client.get("/api/v1.0/documents/")
|
||||||
|
|
||||||
# nb_accesses should now be cached
|
# nb_accesses should now be cached
|
||||||
with django_assert_num_queries(4):
|
with django_assert_num_queries(6):
|
||||||
response = client.get("/api/v1.0/documents/")
|
response = client.get("/api/v1.0/documents/")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -268,11 +272,11 @@ def test_api_documents_list_authenticated_link_reach_public_or_authenticated(
|
|||||||
|
|
||||||
expected_ids = {str(document1.id), str(document2.id), str(visible_child.id)}
|
expected_ids = {str(document1.id), str(document2.id), str(visible_child.id)}
|
||||||
|
|
||||||
with django_assert_num_queries(10):
|
with django_assert_num_queries(11):
|
||||||
response = client.get("/api/v1.0/documents/")
|
response = client.get("/api/v1.0/documents/")
|
||||||
|
|
||||||
# nb_accesses should now be cached
|
# nb_accesses should now be cached
|
||||||
with django_assert_num_queries(4):
|
with django_assert_num_queries(5):
|
||||||
response = client.get("/api/v1.0/documents/")
|
response = client.get("/api/v1.0/documents/")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|||||||
@@ -312,6 +312,84 @@ def test_api_documents_list_filter_is_favorite_invalid():
|
|||||||
assert len(results) == 5
|
assert len(results) == 5
|
||||||
|
|
||||||
|
|
||||||
|
# Filters: is_masked
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_documents_list_filter_is_masked_true():
|
||||||
|
"""
|
||||||
|
Authenticated users should be able to filter documents they marked as masked.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
factories.DocumentFactory.create_batch(2, users=[user])
|
||||||
|
masked_documents = factories.DocumentFactory.create_batch(
|
||||||
|
3, users=[user], masked_by=[user]
|
||||||
|
)
|
||||||
|
unmasked_documents = factories.DocumentFactory.create_batch(2, users=[user])
|
||||||
|
for document in unmasked_documents:
|
||||||
|
models.LinkTrace.objects.create(document=document, user=user, is_masked=False)
|
||||||
|
|
||||||
|
response = client.get("/api/v1.0/documents/?is_masked=true")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
results = response.json()["results"]
|
||||||
|
assert len(results) == 3
|
||||||
|
|
||||||
|
# Ensure all results are marked as masked by the current user
|
||||||
|
masked_documents_ids = [str(doc.id) for doc in masked_documents]
|
||||||
|
for result in results:
|
||||||
|
assert result["id"] in masked_documents_ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_documents_list_filter_is_masked_false():
|
||||||
|
"""
|
||||||
|
Authenticated users should be able to filter documents they didn't mark as masked.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
factories.DocumentFactory.create_batch(2, users=[user])
|
||||||
|
masked_documents = factories.DocumentFactory.create_batch(
|
||||||
|
3, users=[user], masked_by=[user]
|
||||||
|
)
|
||||||
|
unmasked_documents = factories.DocumentFactory.create_batch(2, users=[user])
|
||||||
|
for document in unmasked_documents:
|
||||||
|
models.LinkTrace.objects.create(document=document, user=user, is_masked=False)
|
||||||
|
|
||||||
|
response = client.get("/api/v1.0/documents/?is_masked=false")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
results = response.json()["results"]
|
||||||
|
assert len(results) == 4
|
||||||
|
|
||||||
|
# Ensure all results are not marked as masked by the current user
|
||||||
|
masked_documents_ids = [str(doc.id) for doc in masked_documents]
|
||||||
|
for result in results:
|
||||||
|
assert result["id"] not in masked_documents_ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_documents_list_filter_is_masked_invalid():
|
||||||
|
"""Filtering with an invalid `is_masked` value should do nothing."""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
factories.DocumentFactory.create_batch(2, users=[user])
|
||||||
|
factories.DocumentFactory.create_batch(3, users=[user], masked_by=[user])
|
||||||
|
unmasked_documents = factories.DocumentFactory.create_batch(2, users=[user])
|
||||||
|
for document in unmasked_documents:
|
||||||
|
models.LinkTrace.objects.create(document=document, user=user, is_masked=False)
|
||||||
|
|
||||||
|
response = client.get("/api/v1.0/documents/?is_masked=invalid")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
results = response.json()["results"]
|
||||||
|
assert len(results) == 7
|
||||||
|
|
||||||
|
|
||||||
# Filters: title
|
# Filters: title
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
353
src/backend/core/tests/documents/test_api_documents_mask.py
Normal file
353
src/backend/core/tests/documents/test_api_documents_mask.py
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
"""Test mask document API endpoint for users in impress's core app."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from core import factories, models
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"reach",
|
||||||
|
[
|
||||||
|
"restricted",
|
||||||
|
"authenticated",
|
||||||
|
"public",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@pytest.mark.parametrize("method", ["post", "delete"])
|
||||||
|
def test_api_document_mask_anonymous_user(method, reach):
|
||||||
|
"""Anonymous users should not be able to mask/unmask documents."""
|
||||||
|
document = factories.DocumentFactory(link_reach=reach)
|
||||||
|
|
||||||
|
response = getattr(APIClient(), method)(
|
||||||
|
f"/api/v1.0/documents/{document.id!s}/mask/"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert response.json() == {
|
||||||
|
"detail": "Authentication credentials were not provided."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Verify in database
|
||||||
|
assert models.LinkTrace.objects.exists() is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"reach, has_role",
|
||||||
|
[
|
||||||
|
["restricted", True],
|
||||||
|
["authenticated", False],
|
||||||
|
["authenticated", True],
|
||||||
|
["public", False],
|
||||||
|
["public", True],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_api_document_mask_authenticated_post_allowed(reach, has_role):
|
||||||
|
"""Authenticated users should be able to mask a document to which they have access."""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
document = factories.DocumentFactory(link_reach=reach)
|
||||||
|
if has_role:
|
||||||
|
models.DocumentAccess.objects.create(document=document, user=user)
|
||||||
|
|
||||||
|
# Try masking the document without a link trace
|
||||||
|
response = client.post(f"/api/v1.0/documents/{document.id!s}/mask/")
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response.json() == {"detail": "User never accessed this document before."}
|
||||||
|
assert not models.LinkTrace.objects.filter(document=document, user=user).exists()
|
||||||
|
|
||||||
|
models.LinkTrace.objects.create(document=document, user=user)
|
||||||
|
# Mask document
|
||||||
|
response = client.post(f"/api/v1.0/documents/{document.id!s}/mask/")
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
assert response.json() == {"detail": "Document was masked"}
|
||||||
|
assert models.LinkTrace.objects.filter(
|
||||||
|
document=document, user=user, is_masked=True
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_document_mask_authenticated_post_forbidden():
|
||||||
|
"""
|
||||||
|
Authenticated users should no be allowed to mask a document
|
||||||
|
to which they don't have access.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
document = factories.DocumentFactory(link_reach="restricted")
|
||||||
|
|
||||||
|
# Try masking
|
||||||
|
response = client.post(f"/api/v1.0/documents/{document.id!s}/mask/")
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert response.json() == {
|
||||||
|
"detail": "You do not have permission to perform this action."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Verify in database
|
||||||
|
assert (
|
||||||
|
models.LinkTrace.objects.filter(document=document, user=user).exists() is False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"reach, has_role",
|
||||||
|
[
|
||||||
|
["restricted", True],
|
||||||
|
["authenticated", False],
|
||||||
|
["authenticated", True],
|
||||||
|
["public", False],
|
||||||
|
["public", True],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_api_document_mask_authenticated_post_already_masked_allowed(reach, has_role):
|
||||||
|
"""POST should not create duplicate link trace if already marked."""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
document = factories.DocumentFactory(link_reach=reach, masked_by=[user])
|
||||||
|
if has_role:
|
||||||
|
models.DocumentAccess.objects.create(document=document, user=user)
|
||||||
|
|
||||||
|
# Try masking again
|
||||||
|
response = client.post(f"/api/v1.0/documents/{document.id!s}/mask/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"detail": "Document was already masked"}
|
||||||
|
assert models.LinkTrace.objects.filter(
|
||||||
|
document=document, user=user, is_masked=True
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_document_mask_authenticated_post_already_masked_forbidden():
|
||||||
|
"""POST should not create duplicate masks if already marked."""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
document = factories.DocumentFactory(link_reach="restricted", masked_by=[user])
|
||||||
|
# Try masking again
|
||||||
|
response = client.post(f"/api/v1.0/documents/{document.id!s}/mask/")
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert response.json() == {
|
||||||
|
"detail": "You do not have permission to perform this action."
|
||||||
|
}
|
||||||
|
assert models.LinkTrace.objects.filter(document=document, user=user).exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"reach, has_role",
|
||||||
|
[
|
||||||
|
["restricted", True],
|
||||||
|
["authenticated", False],
|
||||||
|
["authenticated", True],
|
||||||
|
["public", False],
|
||||||
|
["public", True],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_api_document_mask_authenticated_post_unmasked_allowed(reach, has_role):
|
||||||
|
"""POST should not create duplicate link trace if unmasked."""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
document = factories.DocumentFactory(link_reach=reach)
|
||||||
|
models.LinkTrace.objects.create(document=document, user=user, is_masked=False)
|
||||||
|
if has_role:
|
||||||
|
models.DocumentAccess.objects.create(document=document, user=user)
|
||||||
|
|
||||||
|
# Try masking again
|
||||||
|
response = client.post(f"/api/v1.0/documents/{document.id!s}/mask/")
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
assert response.json() == {"detail": "Document was masked"}
|
||||||
|
assert models.LinkTrace.objects.filter(
|
||||||
|
document=document, user=user, is_masked=True
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_document_mask_authenticated_post_unmasked_forbidden():
|
||||||
|
"""POST should not create duplicate masks if unmasked."""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
document = factories.DocumentFactory(link_reach="restricted")
|
||||||
|
models.LinkTrace.objects.create(document=document, user=user, is_masked=False)
|
||||||
|
# Try masking again
|
||||||
|
response = client.post(f"/api/v1.0/documents/{document.id!s}/mask/")
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert response.json() == {
|
||||||
|
"detail": "You do not have permission to perform this action."
|
||||||
|
}
|
||||||
|
assert models.LinkTrace.objects.filter(
|
||||||
|
document=document, user=user, is_masked=False
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"reach, has_role",
|
||||||
|
[
|
||||||
|
["restricted", True],
|
||||||
|
["authenticated", False],
|
||||||
|
["authenticated", True],
|
||||||
|
["public", False],
|
||||||
|
["public", True],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_api_document_mask_authenticated_delete_allowed(reach, has_role):
|
||||||
|
"""Authenticated users should be able to unmask a document using DELETE."""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
document = factories.DocumentFactory(link_reach=reach, masked_by=[user])
|
||||||
|
if has_role:
|
||||||
|
models.DocumentAccess.objects.create(document=document, user=user)
|
||||||
|
|
||||||
|
# Unmask document
|
||||||
|
response = client.delete(f"/api/v1.0/documents/{document.id!s}/mask/")
|
||||||
|
|
||||||
|
assert response.status_code == 204
|
||||||
|
assert response.content == b"" # No body
|
||||||
|
assert response.text == "" # Empty decoded text
|
||||||
|
assert "Content-Type" not in response.headers # No Content-Type for 204
|
||||||
|
|
||||||
|
assert models.LinkTrace.objects.filter(
|
||||||
|
document=document, user=user, is_masked=False
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_document_mask_authenticated_delete_forbidden():
|
||||||
|
"""
|
||||||
|
Authenticated users should not be allowed to unmask a document if
|
||||||
|
they don't have access to it.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
document = factories.DocumentFactory(link_reach="restricted", masked_by=[user])
|
||||||
|
|
||||||
|
# Unmask document
|
||||||
|
response = client.delete(f"/api/v1.0/documents/{document.id!s}/mask/")
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert response.json() == {
|
||||||
|
"detail": "You do not have permission to perform this action."
|
||||||
|
}
|
||||||
|
assert models.LinkTrace.objects.filter(
|
||||||
|
document=document, user=user, is_masked=True
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"reach, has_role",
|
||||||
|
[
|
||||||
|
["restricted", True],
|
||||||
|
["authenticated", False],
|
||||||
|
["authenticated", True],
|
||||||
|
["public", False],
|
||||||
|
["public", True],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_api_document_mask_authenticated_delete_not_masked_allowed(reach, has_role):
|
||||||
|
"""DELETE should be idempotent if the document is not masked."""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
document = factories.DocumentFactory(link_reach=reach)
|
||||||
|
if has_role:
|
||||||
|
models.DocumentAccess.objects.create(document=document, user=user)
|
||||||
|
|
||||||
|
# Try unmasking the document without a link trace
|
||||||
|
response = client.delete(f"/api/v1.0/documents/{document.id!s}/mask/")
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response.json() == {"detail": "User never accessed this document before."}
|
||||||
|
assert not models.LinkTrace.objects.filter(document=document, user=user).exists()
|
||||||
|
|
||||||
|
models.LinkTrace.objects.create(document=document, user=user, is_masked=False)
|
||||||
|
# Unmask document
|
||||||
|
response = client.delete(f"/api/v1.0/documents/{document.id!s}/mask/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"detail": "Document was already not masked"}
|
||||||
|
assert models.LinkTrace.objects.filter(
|
||||||
|
document=document, user=user, is_masked=False
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_document_mask_authenticated_delete_not_masked_forbidden():
|
||||||
|
"""DELETE should be idempotent if the document is not masked."""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
document = factories.DocumentFactory(link_reach="restricted")
|
||||||
|
|
||||||
|
# Try to unmask when no entry exists
|
||||||
|
response = client.delete(f"/api/v1.0/documents/{document.id!s}/mask/")
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert response.json() == {
|
||||||
|
"detail": "You do not have permission to perform this action."
|
||||||
|
}
|
||||||
|
assert (
|
||||||
|
models.LinkTrace.objects.filter(document=document, user=user).exists() is False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"reach, has_role",
|
||||||
|
[
|
||||||
|
["restricted", True],
|
||||||
|
["authenticated", False],
|
||||||
|
["authenticated", True],
|
||||||
|
["public", False],
|
||||||
|
["public", True],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_api_document_mask_authenticated_post_unmark_then_mark_again_allowed(
|
||||||
|
reach, has_role
|
||||||
|
):
|
||||||
|
"""A user should be able to mask, unmask, and mask a document again."""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
document = factories.DocumentFactory(link_reach=reach)
|
||||||
|
if has_role:
|
||||||
|
models.DocumentAccess.objects.create(document=document, user=user)
|
||||||
|
models.LinkTrace.objects.create(document=document, user=user, is_masked=False)
|
||||||
|
|
||||||
|
url = f"/api/v1.0/documents/{document.id!s}/mask/"
|
||||||
|
|
||||||
|
# Mask document
|
||||||
|
response = client.post(url)
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
# Unmask document
|
||||||
|
response = client.delete(url)
|
||||||
|
assert response.status_code == 204
|
||||||
|
assert response.content == b"" # No body
|
||||||
|
assert response.text == "" # Empty decoded text
|
||||||
|
assert "Content-Type" not in response.headers # No Content-Type for 204
|
||||||
|
|
||||||
|
# Mask document again
|
||||||
|
response = client.post(url)
|
||||||
|
assert response.status_code == 201
|
||||||
|
assert response.json() == {"detail": "Document was masked"}
|
||||||
|
|
||||||
|
assert models.LinkTrace.objects.filter(
|
||||||
|
document=document, user=user, is_masked=True
|
||||||
|
).exists()
|
||||||
@@ -12,6 +12,7 @@ from django.utils import timezone
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import requests
|
import requests
|
||||||
|
from freezegun import freeze_time
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from core import factories, models
|
from core import factories, models
|
||||||
@@ -52,9 +53,11 @@ def test_api_documents_media_auth_anonymous_public():
|
|||||||
factories.DocumentFactory(id=document_id, link_reach="public", attachments=[key])
|
factories.DocumentFactory(id=document_id, link_reach="public", attachments=[key])
|
||||||
|
|
||||||
original_url = f"http://localhost/media/{key:s}"
|
original_url = f"http://localhost/media/{key:s}"
|
||||||
response = APIClient().get(
|
now = timezone.now()
|
||||||
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url
|
with freeze_time(now):
|
||||||
)
|
response = APIClient().get(
|
||||||
|
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url
|
||||||
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
@@ -64,7 +67,7 @@ def test_api_documents_media_auth_anonymous_public():
|
|||||||
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
|
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
|
||||||
in authorization
|
in authorization
|
||||||
)
|
)
|
||||||
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
|
assert response["X-Amz-Date"] == now.strftime("%Y%m%dT%H%M%SZ")
|
||||||
|
|
||||||
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
|
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
|
||||||
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}"
|
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}"
|
||||||
@@ -167,9 +170,11 @@ def test_api_documents_media_auth_anonymous_attachments():
|
|||||||
parent = factories.DocumentFactory(link_reach="public")
|
parent = factories.DocumentFactory(link_reach="public")
|
||||||
factories.DocumentFactory(parent=parent, link_reach="restricted", attachments=[key])
|
factories.DocumentFactory(parent=parent, link_reach="restricted", attachments=[key])
|
||||||
|
|
||||||
response = APIClient().get(
|
now = timezone.now()
|
||||||
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url
|
with freeze_time(now):
|
||||||
)
|
response = APIClient().get(
|
||||||
|
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url
|
||||||
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
@@ -179,7 +184,7 @@ def test_api_documents_media_auth_anonymous_attachments():
|
|||||||
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
|
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
|
||||||
in authorization
|
in authorization
|
||||||
)
|
)
|
||||||
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
|
assert response["X-Amz-Date"] == now.strftime("%Y%m%dT%H%M%SZ")
|
||||||
|
|
||||||
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
|
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
|
||||||
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}"
|
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}"
|
||||||
@@ -221,9 +226,11 @@ def test_api_documents_media_auth_authenticated_public_or_authenticated(reach):
|
|||||||
|
|
||||||
factories.DocumentFactory(id=document_id, link_reach=reach, attachments=[key])
|
factories.DocumentFactory(id=document_id, link_reach=reach, attachments=[key])
|
||||||
|
|
||||||
response = client.get(
|
now = timezone.now()
|
||||||
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url
|
with freeze_time(now):
|
||||||
)
|
response = client.get(
|
||||||
|
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url
|
||||||
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
@@ -233,7 +240,7 @@ def test_api_documents_media_auth_authenticated_public_or_authenticated(reach):
|
|||||||
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
|
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
|
||||||
in authorization
|
in authorization
|
||||||
)
|
)
|
||||||
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
|
assert response["X-Amz-Date"] == now.strftime("%Y%m%dT%H%M%SZ")
|
||||||
|
|
||||||
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
|
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
|
||||||
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}"
|
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}"
|
||||||
@@ -307,9 +314,11 @@ def test_api_documents_media_auth_related(via, mock_user_teams):
|
|||||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||||
factories.TeamDocumentAccessFactory(document=document, team="lasuite")
|
factories.TeamDocumentAccessFactory(document=document, team="lasuite")
|
||||||
|
|
||||||
response = client.get(
|
now = timezone.now()
|
||||||
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url
|
with freeze_time(now):
|
||||||
)
|
response = client.get(
|
||||||
|
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url
|
||||||
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
@@ -319,7 +328,7 @@ def test_api_documents_media_auth_related(via, mock_user_teams):
|
|||||||
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
|
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
|
||||||
in authorization
|
in authorization
|
||||||
)
|
)
|
||||||
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
|
assert response["X-Amz-Date"] == now.strftime("%Y%m%dT%H%M%SZ")
|
||||||
|
|
||||||
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
|
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
|
||||||
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}"
|
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}"
|
||||||
@@ -373,10 +382,12 @@ def test_api_documents_media_auth_missing_status_metadata():
|
|||||||
|
|
||||||
factories.DocumentFactory(id=document_id, link_reach="public", attachments=[key])
|
factories.DocumentFactory(id=document_id, link_reach="public", attachments=[key])
|
||||||
|
|
||||||
|
now = timezone.now()
|
||||||
original_url = f"http://localhost/media/{key:s}"
|
original_url = f"http://localhost/media/{key:s}"
|
||||||
response = APIClient().get(
|
with freeze_time(now):
|
||||||
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url
|
response = APIClient().get(
|
||||||
)
|
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url
|
||||||
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
@@ -386,7 +397,7 @@ def test_api_documents_media_auth_missing_status_metadata():
|
|||||||
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
|
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
|
||||||
in authorization
|
in authorization
|
||||||
)
|
)
|
||||||
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
|
assert response["X-Amz-Date"] == now.strftime("%Y%m%dT%H%M%SZ")
|
||||||
|
|
||||||
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
|
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
|
||||||
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}"
|
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}"
|
||||||
|
|||||||
@@ -124,8 +124,8 @@ def test_api_documents_move_authenticated_target_roles_mocked(
|
|||||||
target_role, target_parent_role, position
|
target_role, target_parent_role, position
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Authenticated users with insufficient permissions on the target document (or its
|
Only authenticated users with sufficient permissions on the target document (or its
|
||||||
parent depending on the position chosen), should not be allowed to move documents.
|
parent depending on the position chosen), should be allowed to move documents.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
user = factories.UserFactory()
|
user = factories.UserFactory()
|
||||||
@@ -208,6 +208,107 @@ def test_api_documents_move_authenticated_target_roles_mocked(
|
|||||||
assert document.is_root() is True
|
assert document.is_root() is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_documents_move_authenticated_no_owner_user_and_team():
|
||||||
|
"""
|
||||||
|
Moving a document with no owner to the root of the tree should automatically declare
|
||||||
|
the owner of the previous root of the document as owner of the document itself.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
parent_owner = factories.UserFactory()
|
||||||
|
parent = factories.DocumentFactory(
|
||||||
|
users=[(parent_owner, "owner")], teams=[("lasuite", "owner")]
|
||||||
|
)
|
||||||
|
# A document with no owner
|
||||||
|
document = factories.DocumentFactory(parent=parent, users=[(user, "administrator")])
|
||||||
|
child = factories.DocumentFactory(parent=document)
|
||||||
|
target = factories.DocumentFactory()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/documents/{document.id!s}/move/",
|
||||||
|
data={"target_document_id": str(target.id), "position": "first-sibling"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"message": "Document moved successfully."}
|
||||||
|
assert list(target.get_siblings()) == [document, parent, target]
|
||||||
|
|
||||||
|
document.refresh_from_db()
|
||||||
|
assert list(document.get_children()) == [child]
|
||||||
|
assert document.accesses.count() == 3
|
||||||
|
assert document.accesses.get(user__isnull=False, role="owner").user == parent_owner
|
||||||
|
assert document.accesses.get(user__isnull=True, role="owner").team == "lasuite"
|
||||||
|
assert document.accesses.get(role="administrator").user == user
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_documents_move_authenticated_no_owner_same_user():
|
||||||
|
"""
|
||||||
|
Moving a document should not fail if the user moving a document with no owner was
|
||||||
|
at the same time owner of the previous root and has a role on the document being moved.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
parent = factories.DocumentFactory(
|
||||||
|
users=[(user, "owner")], teams=[("lasuite", "owner")]
|
||||||
|
)
|
||||||
|
# A document with no owner
|
||||||
|
document = factories.DocumentFactory(parent=parent, users=[(user, "reader")])
|
||||||
|
child = factories.DocumentFactory(parent=document)
|
||||||
|
target = factories.DocumentFactory()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/documents/{document.id!s}/move/",
|
||||||
|
data={"target_document_id": str(target.id), "position": "first-sibling"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"message": "Document moved successfully."}
|
||||||
|
assert list(target.get_siblings()) == [document, parent, target]
|
||||||
|
|
||||||
|
document.refresh_from_db()
|
||||||
|
assert list(document.get_children()) == [child]
|
||||||
|
assert document.accesses.count() == 2
|
||||||
|
assert document.accesses.get(user__isnull=False, role="owner").user == user
|
||||||
|
assert document.accesses.get(user__isnull=True, role="owner").team == "lasuite"
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_documents_move_authenticated_no_owner_same_team():
|
||||||
|
"""
|
||||||
|
Moving a document should not fail if the team that is owner of the document root was
|
||||||
|
already declared on the document with a different role.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
parent = factories.DocumentFactory(teams=[("lasuite", "owner")])
|
||||||
|
# A document with no owner but same team
|
||||||
|
document = factories.DocumentFactory(
|
||||||
|
parent=parent, users=[(user, "administrator")], teams=[("lasuite", "reader")]
|
||||||
|
)
|
||||||
|
child = factories.DocumentFactory(parent=document)
|
||||||
|
target = factories.DocumentFactory()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/documents/{document.id!s}/move/",
|
||||||
|
data={"target_document_id": str(target.id), "position": "first-sibling"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"message": "Document moved successfully."}
|
||||||
|
assert list(target.get_siblings()) == [document, parent, target]
|
||||||
|
|
||||||
|
document.refresh_from_db()
|
||||||
|
assert list(document.get_children()) == [child]
|
||||||
|
assert document.accesses.count() == 2
|
||||||
|
assert document.accesses.get(user__isnull=False, role="administrator").user == user
|
||||||
|
assert document.accesses.get(user__isnull=True, role="owner").team == "lasuite"
|
||||||
|
|
||||||
|
|
||||||
def test_api_documents_move_authenticated_deleted_document():
|
def test_api_documents_move_authenticated_deleted_document():
|
||||||
"""
|
"""
|
||||||
It should not be possible to move a deleted document or its descendants, even
|
It should not be possible to move a deleted document or its descendants, even
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Tests for Documents API endpoint in impress's core app: retrieve
|
Tests for Documents API endpoint in impress's core app: retrieve
|
||||||
"""
|
"""
|
||||||
|
# pylint: disable=too-many-lines
|
||||||
|
|
||||||
import random
|
import random
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
@@ -11,7 +12,7 @@ from django.utils import timezone
|
|||||||
import pytest
|
import pytest
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from core import factories, models
|
from core import choices, factories, models
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
@@ -31,13 +32,15 @@ def test_api_documents_retrieve_anonymous_public_standalone():
|
|||||||
"ai_transform": False,
|
"ai_transform": False,
|
||||||
"ai_translate": False,
|
"ai_translate": False,
|
||||||
"attachment_upload": document.link_role == "editor",
|
"attachment_upload": document.link_role == "editor",
|
||||||
|
"can_edit": document.link_role == "editor",
|
||||||
"children_create": False,
|
"children_create": False,
|
||||||
"children_list": True,
|
"children_list": True,
|
||||||
"collaboration_auth": True,
|
"collaboration_auth": True,
|
||||||
"cors_proxy": True,
|
"cors_proxy": True,
|
||||||
|
"content": True,
|
||||||
"descendants": True,
|
"descendants": True,
|
||||||
"destroy": False,
|
"destroy": False,
|
||||||
"duplicate": True,
|
"duplicate": False,
|
||||||
# Anonymous user can't favorite a document even with read access
|
# Anonymous user can't favorite a document even with read access
|
||||||
"favorite": False,
|
"favorite": False,
|
||||||
"invite_owner": False,
|
"invite_owner": False,
|
||||||
@@ -45,8 +48,9 @@ def test_api_documents_retrieve_anonymous_public_standalone():
|
|||||||
"link_select_options": {
|
"link_select_options": {
|
||||||
"authenticated": ["reader", "editor"],
|
"authenticated": ["reader", "editor"],
|
||||||
"public": ["reader", "editor"],
|
"public": ["reader", "editor"],
|
||||||
"restricted": ["reader", "editor"],
|
"restricted": None,
|
||||||
},
|
},
|
||||||
|
"mask": False,
|
||||||
"media_auth": True,
|
"media_auth": True,
|
||||||
"media_check": True,
|
"media_check": True,
|
||||||
"move": False,
|
"move": False,
|
||||||
@@ -59,6 +63,10 @@ def test_api_documents_retrieve_anonymous_public_standalone():
|
|||||||
"versions_list": False,
|
"versions_list": False,
|
||||||
"versions_retrieve": False,
|
"versions_retrieve": False,
|
||||||
},
|
},
|
||||||
|
"ancestors_link_reach": None,
|
||||||
|
"ancestors_link_role": None,
|
||||||
|
"computed_link_reach": document.computed_link_reach,
|
||||||
|
"computed_link_role": document.computed_link_role,
|
||||||
"content": document.content,
|
"content": document.content,
|
||||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(document.creator.id),
|
"creator": str(document.creator.id),
|
||||||
@@ -73,7 +81,7 @@ def test_api_documents_retrieve_anonymous_public_standalone():
|
|||||||
"path": document.path,
|
"path": document.path,
|
||||||
"title": document.title,
|
"title": document.title,
|
||||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -91,6 +99,7 @@ def test_api_documents_retrieve_anonymous_public_parent():
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
links = document.get_ancestors().values("link_reach", "link_role")
|
links = document.get_ancestors().values("link_reach", "link_role")
|
||||||
|
links_definition = choices.get_equivalent_link_definition(links)
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"id": str(document.id),
|
"id": str(document.id),
|
||||||
"abilities": {
|
"abilities": {
|
||||||
@@ -99,18 +108,23 @@ def test_api_documents_retrieve_anonymous_public_parent():
|
|||||||
"ai_transform": False,
|
"ai_transform": False,
|
||||||
"ai_translate": False,
|
"ai_translate": False,
|
||||||
"attachment_upload": grand_parent.link_role == "editor",
|
"attachment_upload": grand_parent.link_role == "editor",
|
||||||
|
"can_edit": grand_parent.link_role == "editor",
|
||||||
"children_create": False,
|
"children_create": False,
|
||||||
"children_list": True,
|
"children_list": True,
|
||||||
"collaboration_auth": True,
|
"collaboration_auth": True,
|
||||||
"descendants": True,
|
"descendants": True,
|
||||||
"cors_proxy": True,
|
"cors_proxy": True,
|
||||||
|
"content": True,
|
||||||
"destroy": False,
|
"destroy": False,
|
||||||
"duplicate": True,
|
"duplicate": False,
|
||||||
# Anonymous user can't favorite a document even with read access
|
# Anonymous user can't favorite a document even with read access
|
||||||
"favorite": False,
|
"favorite": False,
|
||||||
"invite_owner": False,
|
"invite_owner": False,
|
||||||
"link_configuration": False,
|
"link_configuration": False,
|
||||||
"link_select_options": models.LinkReachChoices.get_select_options(links),
|
"link_select_options": models.LinkReachChoices.get_select_options(
|
||||||
|
**links_definition
|
||||||
|
),
|
||||||
|
"mask": False,
|
||||||
"media_auth": True,
|
"media_auth": True,
|
||||||
"media_check": True,
|
"media_check": True,
|
||||||
"move": False,
|
"move": False,
|
||||||
@@ -123,6 +137,10 @@ def test_api_documents_retrieve_anonymous_public_parent():
|
|||||||
"versions_list": False,
|
"versions_list": False,
|
||||||
"versions_retrieve": False,
|
"versions_retrieve": False,
|
||||||
},
|
},
|
||||||
|
"ancestors_link_reach": "public",
|
||||||
|
"ancestors_link_role": grand_parent.link_role,
|
||||||
|
"computed_link_reach": "public",
|
||||||
|
"computed_link_role": grand_parent.link_role,
|
||||||
"content": document.content,
|
"content": document.content,
|
||||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(document.creator.id),
|
"creator": str(document.creator.id),
|
||||||
@@ -137,7 +155,7 @@ def test_api_documents_retrieve_anonymous_public_parent():
|
|||||||
"path": document.path,
|
"path": document.path,
|
||||||
"title": document.title,
|
"title": document.title,
|
||||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -196,11 +214,13 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
|
|||||||
"ai_transform": document.link_role == "editor",
|
"ai_transform": document.link_role == "editor",
|
||||||
"ai_translate": document.link_role == "editor",
|
"ai_translate": document.link_role == "editor",
|
||||||
"attachment_upload": document.link_role == "editor",
|
"attachment_upload": document.link_role == "editor",
|
||||||
|
"can_edit": document.link_role == "editor",
|
||||||
"children_create": document.link_role == "editor",
|
"children_create": document.link_role == "editor",
|
||||||
"children_list": True,
|
"children_list": True,
|
||||||
"collaboration_auth": True,
|
"collaboration_auth": True,
|
||||||
"descendants": True,
|
"descendants": True,
|
||||||
"cors_proxy": True,
|
"cors_proxy": True,
|
||||||
|
"content": True,
|
||||||
"destroy": False,
|
"destroy": False,
|
||||||
"duplicate": True,
|
"duplicate": True,
|
||||||
"favorite": True,
|
"favorite": True,
|
||||||
@@ -209,8 +229,9 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
|
|||||||
"link_select_options": {
|
"link_select_options": {
|
||||||
"authenticated": ["reader", "editor"],
|
"authenticated": ["reader", "editor"],
|
||||||
"public": ["reader", "editor"],
|
"public": ["reader", "editor"],
|
||||||
"restricted": ["reader", "editor"],
|
"restricted": None,
|
||||||
},
|
},
|
||||||
|
"mask": True,
|
||||||
"media_auth": True,
|
"media_auth": True,
|
||||||
"media_check": True,
|
"media_check": True,
|
||||||
"move": False,
|
"move": False,
|
||||||
@@ -223,6 +244,10 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
|
|||||||
"versions_list": False,
|
"versions_list": False,
|
||||||
"versions_retrieve": False,
|
"versions_retrieve": False,
|
||||||
},
|
},
|
||||||
|
"ancestors_link_reach": None,
|
||||||
|
"ancestors_link_role": None,
|
||||||
|
"computed_link_reach": document.computed_link_reach,
|
||||||
|
"computed_link_role": document.computed_link_role,
|
||||||
"content": document.content,
|
"content": document.content,
|
||||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(document.creator.id),
|
"creator": str(document.creator.id),
|
||||||
@@ -237,7 +262,7 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
|
|||||||
"path": document.path,
|
"path": document.path,
|
||||||
"title": document.title,
|
"title": document.title,
|
||||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
}
|
}
|
||||||
assert (
|
assert (
|
||||||
models.LinkTrace.objects.filter(document=document, user=user).exists() is True
|
models.LinkTrace.objects.filter(document=document, user=user).exists() is True
|
||||||
@@ -263,6 +288,7 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
links = document.get_ancestors().values("link_reach", "link_role")
|
links = document.get_ancestors().values("link_reach", "link_role")
|
||||||
|
links_definition = choices.get_equivalent_link_definition(links)
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"id": str(document.id),
|
"id": str(document.id),
|
||||||
"abilities": {
|
"abilities": {
|
||||||
@@ -271,20 +297,25 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
|
|||||||
"ai_transform": grand_parent.link_role == "editor",
|
"ai_transform": grand_parent.link_role == "editor",
|
||||||
"ai_translate": grand_parent.link_role == "editor",
|
"ai_translate": grand_parent.link_role == "editor",
|
||||||
"attachment_upload": grand_parent.link_role == "editor",
|
"attachment_upload": grand_parent.link_role == "editor",
|
||||||
|
"can_edit": grand_parent.link_role == "editor",
|
||||||
"children_create": grand_parent.link_role == "editor",
|
"children_create": grand_parent.link_role == "editor",
|
||||||
"children_list": True,
|
"children_list": True,
|
||||||
"collaboration_auth": True,
|
"collaboration_auth": True,
|
||||||
"descendants": True,
|
"descendants": True,
|
||||||
"cors_proxy": True,
|
"cors_proxy": True,
|
||||||
|
"content": True,
|
||||||
"destroy": False,
|
"destroy": False,
|
||||||
"duplicate": True,
|
"duplicate": True,
|
||||||
"favorite": True,
|
"favorite": True,
|
||||||
"invite_owner": False,
|
"invite_owner": False,
|
||||||
"link_configuration": False,
|
"link_configuration": False,
|
||||||
"link_select_options": models.LinkReachChoices.get_select_options(links),
|
"link_select_options": models.LinkReachChoices.get_select_options(
|
||||||
|
**links_definition
|
||||||
|
),
|
||||||
|
"mask": True,
|
||||||
|
"move": False,
|
||||||
"media_auth": True,
|
"media_auth": True,
|
||||||
"media_check": True,
|
"media_check": True,
|
||||||
"move": False,
|
|
||||||
"partial_update": grand_parent.link_role == "editor",
|
"partial_update": grand_parent.link_role == "editor",
|
||||||
"restore": False,
|
"restore": False,
|
||||||
"retrieve": True,
|
"retrieve": True,
|
||||||
@@ -294,6 +325,10 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
|
|||||||
"versions_list": False,
|
"versions_list": False,
|
||||||
"versions_retrieve": False,
|
"versions_retrieve": False,
|
||||||
},
|
},
|
||||||
|
"ancestors_link_reach": reach,
|
||||||
|
"ancestors_link_role": grand_parent.link_role,
|
||||||
|
"computed_link_reach": document.computed_link_reach,
|
||||||
|
"computed_link_role": document.computed_link_role,
|
||||||
"content": document.content,
|
"content": document.content,
|
||||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(document.creator.id),
|
"creator": str(document.creator.id),
|
||||||
@@ -308,7 +343,7 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
|
|||||||
"path": document.path,
|
"path": document.path,
|
||||||
"title": document.title,
|
"title": document.title,
|
||||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -404,6 +439,10 @@ def test_api_documents_retrieve_authenticated_related_direct():
|
|||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"id": str(document.id),
|
"id": str(document.id),
|
||||||
"abilities": document.get_abilities(user),
|
"abilities": document.get_abilities(user),
|
||||||
|
"ancestors_link_reach": None,
|
||||||
|
"ancestors_link_role": None,
|
||||||
|
"computed_link_reach": document.computed_link_reach,
|
||||||
|
"computed_link_role": document.computed_link_role,
|
||||||
"content": document.content,
|
"content": document.content,
|
||||||
"creator": str(document.creator.id),
|
"creator": str(document.creator.id),
|
||||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
@@ -418,7 +457,7 @@ def test_api_documents_retrieve_authenticated_related_direct():
|
|||||||
"path": document.path,
|
"path": document.path,
|
||||||
"title": document.title,
|
"title": document.title,
|
||||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [access.role],
|
"user_role": access.role,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -444,6 +483,7 @@ def test_api_documents_retrieve_authenticated_related_parent():
|
|||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
links = document.get_ancestors().values("link_reach", "link_role")
|
links = document.get_ancestors().values("link_reach", "link_role")
|
||||||
|
link_definition = choices.get_equivalent_link_definition(links)
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"id": str(document.id),
|
"id": str(document.id),
|
||||||
"abilities": {
|
"abilities": {
|
||||||
@@ -452,17 +492,22 @@ def test_api_documents_retrieve_authenticated_related_parent():
|
|||||||
"ai_transform": access.role != "reader",
|
"ai_transform": access.role != "reader",
|
||||||
"ai_translate": access.role != "reader",
|
"ai_translate": access.role != "reader",
|
||||||
"attachment_upload": access.role != "reader",
|
"attachment_upload": access.role != "reader",
|
||||||
|
"can_edit": access.role != "reader",
|
||||||
"children_create": access.role != "reader",
|
"children_create": access.role != "reader",
|
||||||
"children_list": True,
|
"children_list": True,
|
||||||
"collaboration_auth": True,
|
"collaboration_auth": True,
|
||||||
"descendants": True,
|
"descendants": True,
|
||||||
"cors_proxy": True,
|
"cors_proxy": True,
|
||||||
|
"content": True,
|
||||||
"destroy": access.role == "owner",
|
"destroy": access.role == "owner",
|
||||||
"duplicate": True,
|
"duplicate": True,
|
||||||
"favorite": True,
|
"favorite": True,
|
||||||
"invite_owner": access.role == "owner",
|
"invite_owner": access.role == "owner",
|
||||||
"link_configuration": access.role in ["administrator", "owner"],
|
"link_configuration": access.role in ["administrator", "owner"],
|
||||||
"link_select_options": models.LinkReachChoices.get_select_options(links),
|
"link_select_options": models.LinkReachChoices.get_select_options(
|
||||||
|
**link_definition
|
||||||
|
),
|
||||||
|
"mask": True,
|
||||||
"media_auth": True,
|
"media_auth": True,
|
||||||
"media_check": True,
|
"media_check": True,
|
||||||
"move": access.role in ["administrator", "owner"],
|
"move": access.role in ["administrator", "owner"],
|
||||||
@@ -475,6 +520,10 @@ def test_api_documents_retrieve_authenticated_related_parent():
|
|||||||
"versions_list": True,
|
"versions_list": True,
|
||||||
"versions_retrieve": True,
|
"versions_retrieve": True,
|
||||||
},
|
},
|
||||||
|
"ancestors_link_reach": "restricted",
|
||||||
|
"ancestors_link_role": None,
|
||||||
|
"computed_link_reach": "restricted",
|
||||||
|
"computed_link_role": None,
|
||||||
"content": document.content,
|
"content": document.content,
|
||||||
"creator": str(document.creator.id),
|
"creator": str(document.creator.id),
|
||||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
@@ -489,7 +538,7 @@ def test_api_documents_retrieve_authenticated_related_parent():
|
|||||||
"path": document.path,
|
"path": document.path,
|
||||||
"title": document.title,
|
"title": document.title,
|
||||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [access.role],
|
"user_role": access.role,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -585,16 +634,16 @@ def test_api_documents_retrieve_authenticated_related_team_none(mock_user_teams)
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"teams,roles",
|
"teams,role",
|
||||||
[
|
[
|
||||||
[["readers"], ["reader"]],
|
[["readers"], "reader"],
|
||||||
[["unknown", "readers"], ["reader"]],
|
[["unknown", "readers"], "reader"],
|
||||||
[["editors"], ["editor"]],
|
[["editors"], "editor"],
|
||||||
[["unknown", "editors"], ["editor"]],
|
[["unknown", "editors"], "editor"],
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_api_documents_retrieve_authenticated_related_team_members(
|
def test_api_documents_retrieve_authenticated_related_team_members(
|
||||||
teams, roles, mock_user_teams
|
teams, role, mock_user_teams
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Authenticated users should be allowed to retrieve a document to which they
|
Authenticated users should be allowed to retrieve a document to which they
|
||||||
@@ -627,6 +676,10 @@ def test_api_documents_retrieve_authenticated_related_team_members(
|
|||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"id": str(document.id),
|
"id": str(document.id),
|
||||||
"abilities": document.get_abilities(user),
|
"abilities": document.get_abilities(user),
|
||||||
|
"ancestors_link_reach": None,
|
||||||
|
"ancestors_link_role": None,
|
||||||
|
"computed_link_reach": document.computed_link_reach,
|
||||||
|
"computed_link_role": document.computed_link_role,
|
||||||
"content": document.content,
|
"content": document.content,
|
||||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(document.creator.id),
|
"creator": str(document.creator.id),
|
||||||
@@ -641,20 +694,20 @@ def test_api_documents_retrieve_authenticated_related_team_members(
|
|||||||
"path": document.path,
|
"path": document.path,
|
||||||
"title": document.title,
|
"title": document.title,
|
||||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": roles,
|
"user_role": role,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"teams,roles",
|
"teams,role",
|
||||||
[
|
[
|
||||||
[["administrators"], ["administrator"]],
|
[["administrators"], "administrator"],
|
||||||
[["editors", "administrators"], ["administrator", "editor"]],
|
[["editors", "administrators"], "administrator"],
|
||||||
[["unknown", "administrators"], ["administrator"]],
|
[["unknown", "administrators"], "administrator"],
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_api_documents_retrieve_authenticated_related_team_administrators(
|
def test_api_documents_retrieve_authenticated_related_team_administrators(
|
||||||
teams, roles, mock_user_teams
|
teams, role, mock_user_teams
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Authenticated users should be allowed to retrieve a document to which they
|
Authenticated users should be allowed to retrieve a document to which they
|
||||||
@@ -689,6 +742,10 @@ def test_api_documents_retrieve_authenticated_related_team_administrators(
|
|||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"id": str(document.id),
|
"id": str(document.id),
|
||||||
"abilities": document.get_abilities(user),
|
"abilities": document.get_abilities(user),
|
||||||
|
"ancestors_link_reach": None,
|
||||||
|
"ancestors_link_role": None,
|
||||||
|
"computed_link_reach": document.computed_link_reach,
|
||||||
|
"computed_link_role": document.computed_link_role,
|
||||||
"content": document.content,
|
"content": document.content,
|
||||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(document.creator.id),
|
"creator": str(document.creator.id),
|
||||||
@@ -703,21 +760,21 @@ def test_api_documents_retrieve_authenticated_related_team_administrators(
|
|||||||
"path": document.path,
|
"path": document.path,
|
||||||
"title": document.title,
|
"title": document.title,
|
||||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": roles,
|
"user_role": role,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"teams,roles",
|
"teams,role",
|
||||||
[
|
[
|
||||||
[["owners"], ["owner"]],
|
[["owners"], "owner"],
|
||||||
[["owners", "administrators"], ["owner", "administrator"]],
|
[["owners", "administrators"], "owner"],
|
||||||
[["members", "administrators", "owners"], ["owner", "administrator"]],
|
[["members", "administrators", "owners"], "owner"],
|
||||||
[["unknown", "owners"], ["owner"]],
|
[["unknown", "owners"], "owner"],
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_api_documents_retrieve_authenticated_related_team_owners(
|
def test_api_documents_retrieve_authenticated_related_team_owners(
|
||||||
teams, roles, mock_user_teams
|
teams, role, mock_user_teams
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Authenticated users should be allowed to retrieve a restricted document to which
|
Authenticated users should be allowed to retrieve a restricted document to which
|
||||||
@@ -751,6 +808,10 @@ def test_api_documents_retrieve_authenticated_related_team_owners(
|
|||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"id": str(document.id),
|
"id": str(document.id),
|
||||||
"abilities": document.get_abilities(user),
|
"abilities": document.get_abilities(user),
|
||||||
|
"ancestors_link_reach": None,
|
||||||
|
"ancestors_link_role": None,
|
||||||
|
"computed_link_reach": document.computed_link_reach,
|
||||||
|
"computed_link_role": document.computed_link_role,
|
||||||
"content": document.content,
|
"content": document.content,
|
||||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(document.creator.id),
|
"creator": str(document.creator.id),
|
||||||
@@ -765,11 +826,11 @@ def test_api_documents_retrieve_authenticated_related_team_owners(
|
|||||||
"path": document.path,
|
"path": document.path,
|
||||||
"title": document.title,
|
"title": document.title,
|
||||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": roles,
|
"user_role": role,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_api_documents_retrieve_user_roles(django_assert_max_num_queries):
|
def test_api_documents_retrieve_user_role(django_assert_max_num_queries):
|
||||||
"""
|
"""
|
||||||
Roles should be annotated on querysets taking into account all documents ancestors.
|
Roles should be annotated on querysets taking into account all documents ancestors.
|
||||||
"""
|
"""
|
||||||
@@ -792,15 +853,14 @@ def test_api_documents_retrieve_user_roles(django_assert_max_num_queries):
|
|||||||
factories.UserDocumentAccessFactory(document=parent, user=user),
|
factories.UserDocumentAccessFactory(document=parent, user=user),
|
||||||
factories.UserDocumentAccessFactory(document=document, user=user),
|
factories.UserDocumentAccessFactory(document=document, user=user),
|
||||||
)
|
)
|
||||||
expected_roles = {access.role for access in accesses}
|
expected_role = choices.RoleChoices.max(*[access.role for access in accesses])
|
||||||
|
|
||||||
with django_assert_max_num_queries(14):
|
with django_assert_max_num_queries(14):
|
||||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
|
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
user_roles = response.json()["user_roles"]
|
assert response.json()["user_role"] == expected_role
|
||||||
assert set(user_roles) == expected_roles
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_documents_retrieve_numqueries_with_link_trace(django_assert_num_queries):
|
def test_api_documents_retrieve_numqueries_with_link_trace(django_assert_num_queries):
|
||||||
|
|||||||
@@ -75,11 +75,13 @@ def test_api_documents_trashbin_format():
|
|||||||
"ai_transform": True,
|
"ai_transform": True,
|
||||||
"ai_translate": True,
|
"ai_translate": True,
|
||||||
"attachment_upload": True,
|
"attachment_upload": True,
|
||||||
|
"can_edit": True,
|
||||||
"children_create": True,
|
"children_create": True,
|
||||||
"children_list": True,
|
"children_list": True,
|
||||||
"collaboration_auth": True,
|
"collaboration_auth": True,
|
||||||
"descendants": True,
|
"descendants": True,
|
||||||
"cors_proxy": True,
|
"cors_proxy": True,
|
||||||
|
"content": True,
|
||||||
"destroy": True,
|
"destroy": True,
|
||||||
"duplicate": True,
|
"duplicate": True,
|
||||||
"favorite": True,
|
"favorite": True,
|
||||||
@@ -88,8 +90,9 @@ def test_api_documents_trashbin_format():
|
|||||||
"link_select_options": {
|
"link_select_options": {
|
||||||
"authenticated": ["reader", "editor"],
|
"authenticated": ["reader", "editor"],
|
||||||
"public": ["reader", "editor"],
|
"public": ["reader", "editor"],
|
||||||
"restricted": ["reader", "editor"],
|
"restricted": None,
|
||||||
},
|
},
|
||||||
|
"mask": True,
|
||||||
"media_auth": True,
|
"media_auth": True,
|
||||||
"media_check": True,
|
"media_check": True,
|
||||||
"move": False, # Can't move a deleted document
|
"move": False, # Can't move a deleted document
|
||||||
@@ -102,6 +105,10 @@ def test_api_documents_trashbin_format():
|
|||||||
"versions_list": True,
|
"versions_list": True,
|
||||||
"versions_retrieve": True,
|
"versions_retrieve": True,
|
||||||
},
|
},
|
||||||
|
"ancestors_link_reach": None,
|
||||||
|
"ancestors_link_role": None,
|
||||||
|
"computed_link_reach": document.computed_link_reach,
|
||||||
|
"computed_link_role": document.computed_link_role,
|
||||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(document.creator.id),
|
"creator": str(document.creator.id),
|
||||||
"depth": 1,
|
"depth": 1,
|
||||||
@@ -114,7 +121,7 @@ def test_api_documents_trashbin_format():
|
|||||||
"path": document.path,
|
"path": document.path,
|
||||||
"title": document.title,
|
"title": document.title,
|
||||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": ["owner"],
|
"user_role": "owner",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -32,13 +32,19 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"abilities": parent.get_abilities(AnonymousUser()),
|
"abilities": parent.get_abilities(AnonymousUser()),
|
||||||
|
"ancestors_link_reach": parent.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": parent.ancestors_link_role,
|
||||||
"children": [
|
"children": [
|
||||||
{
|
{
|
||||||
"abilities": document.get_abilities(AnonymousUser()),
|
"abilities": document.get_abilities(AnonymousUser()),
|
||||||
"children": [
|
"children": [
|
||||||
{
|
{
|
||||||
"abilities": child.get_abilities(AnonymousUser()),
|
"abilities": child.get_abilities(AnonymousUser()),
|
||||||
|
"ancestors_link_reach": child.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": child.ancestors_link_role,
|
||||||
"children": [],
|
"children": [],
|
||||||
|
"computed_link_reach": child.computed_link_reach,
|
||||||
|
"computed_link_role": child.computed_link_role,
|
||||||
"created_at": child.created_at.isoformat().replace(
|
"created_at": child.created_at.isoformat().replace(
|
||||||
"+00:00", "Z"
|
"+00:00", "Z"
|
||||||
),
|
),
|
||||||
@@ -57,9 +63,13 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q
|
|||||||
"updated_at": child.updated_at.isoformat().replace(
|
"updated_at": child.updated_at.isoformat().replace(
|
||||||
"+00:00", "Z"
|
"+00:00", "Z"
|
||||||
),
|
),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
"ancestors_link_reach": document.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": document.ancestors_link_role,
|
||||||
|
"computed_link_reach": document.computed_link_reach,
|
||||||
|
"computed_link_role": document.computed_link_role,
|
||||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(document.creator.id),
|
"creator": str(document.creator.id),
|
||||||
"depth": 2,
|
"depth": 2,
|
||||||
@@ -74,11 +84,15 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q
|
|||||||
"path": document.path,
|
"path": document.path,
|
||||||
"title": document.title,
|
"title": document.title,
|
||||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"abilities": sibling1.get_abilities(AnonymousUser()),
|
"abilities": sibling1.get_abilities(AnonymousUser()),
|
||||||
|
"ancestors_link_reach": sibling1.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": sibling1.ancestors_link_role,
|
||||||
"children": [],
|
"children": [],
|
||||||
|
"computed_link_reach": sibling1.computed_link_reach,
|
||||||
|
"computed_link_role": sibling1.computed_link_role,
|
||||||
"created_at": sibling1.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": sibling1.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(sibling1.creator.id),
|
"creator": str(sibling1.creator.id),
|
||||||
"depth": 2,
|
"depth": 2,
|
||||||
@@ -93,11 +107,15 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q
|
|||||||
"path": sibling1.path,
|
"path": sibling1.path,
|
||||||
"title": sibling1.title,
|
"title": sibling1.title,
|
||||||
"updated_at": sibling1.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": sibling1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"abilities": sibling2.get_abilities(AnonymousUser()),
|
"abilities": sibling2.get_abilities(AnonymousUser()),
|
||||||
|
"ancestors_link_reach": sibling2.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": sibling2.ancestors_link_role,
|
||||||
"children": [],
|
"children": [],
|
||||||
|
"computed_link_reach": sibling2.computed_link_reach,
|
||||||
|
"computed_link_role": sibling2.computed_link_role,
|
||||||
"created_at": sibling2.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": sibling2.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(sibling2.creator.id),
|
"creator": str(sibling2.creator.id),
|
||||||
"depth": 2,
|
"depth": 2,
|
||||||
@@ -112,9 +130,11 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q
|
|||||||
"path": sibling2.path,
|
"path": sibling2.path,
|
||||||
"title": sibling2.title,
|
"title": sibling2.title,
|
||||||
"updated_at": sibling2.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": sibling2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
"computed_link_reach": parent.computed_link_reach,
|
||||||
|
"computed_link_role": parent.computed_link_role,
|
||||||
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(parent.creator.id),
|
"creator": str(parent.creator.id),
|
||||||
"depth": 1,
|
"depth": 1,
|
||||||
@@ -129,7 +149,7 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q
|
|||||||
"path": parent.path,
|
"path": parent.path,
|
||||||
"title": parent.title,
|
"title": parent.title,
|
||||||
"updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -163,18 +183,28 @@ def test_api_documents_tree_list_anonymous_public_parent():
|
|||||||
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/tree/")
|
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/tree/")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == {
|
expected_tree = {
|
||||||
"abilities": grand_parent.get_abilities(AnonymousUser()),
|
"abilities": grand_parent.get_abilities(AnonymousUser()),
|
||||||
|
"ancestors_link_reach": grand_parent.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": grand_parent.ancestors_link_role,
|
||||||
"children": [
|
"children": [
|
||||||
{
|
{
|
||||||
"abilities": parent.get_abilities(AnonymousUser()),
|
"abilities": parent.get_abilities(AnonymousUser()),
|
||||||
|
"ancestors_link_reach": parent.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": parent.ancestors_link_role,
|
||||||
"children": [
|
"children": [
|
||||||
{
|
{
|
||||||
"abilities": document.get_abilities(AnonymousUser()),
|
"abilities": document.get_abilities(AnonymousUser()),
|
||||||
|
"ancestors_link_reach": document.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": document.ancestors_link_role,
|
||||||
"children": [
|
"children": [
|
||||||
{
|
{
|
||||||
"abilities": child.get_abilities(AnonymousUser()),
|
"abilities": child.get_abilities(AnonymousUser()),
|
||||||
|
"ancestors_link_reach": child.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": child.ancestors_link_role,
|
||||||
"children": [],
|
"children": [],
|
||||||
|
"computed_link_reach": child.computed_link_reach,
|
||||||
|
"computed_link_role": child.computed_link_role,
|
||||||
"created_at": child.created_at.isoformat().replace(
|
"created_at": child.created_at.isoformat().replace(
|
||||||
"+00:00", "Z"
|
"+00:00", "Z"
|
||||||
),
|
),
|
||||||
@@ -193,9 +223,11 @@ def test_api_documents_tree_list_anonymous_public_parent():
|
|||||||
"updated_at": child.updated_at.isoformat().replace(
|
"updated_at": child.updated_at.isoformat().replace(
|
||||||
"+00:00", "Z"
|
"+00:00", "Z"
|
||||||
),
|
),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
"computed_link_reach": document.computed_link_reach,
|
||||||
|
"computed_link_role": document.computed_link_role,
|
||||||
"created_at": document.created_at.isoformat().replace(
|
"created_at": document.created_at.isoformat().replace(
|
||||||
"+00:00", "Z"
|
"+00:00", "Z"
|
||||||
),
|
),
|
||||||
@@ -214,11 +246,15 @@ def test_api_documents_tree_list_anonymous_public_parent():
|
|||||||
"updated_at": document.updated_at.isoformat().replace(
|
"updated_at": document.updated_at.isoformat().replace(
|
||||||
"+00:00", "Z"
|
"+00:00", "Z"
|
||||||
),
|
),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"abilities": document_sibling.get_abilities(AnonymousUser()),
|
"abilities": document_sibling.get_abilities(AnonymousUser()),
|
||||||
|
"ancestors_link_reach": document_sibling.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": document_sibling.ancestors_link_role,
|
||||||
"children": [],
|
"children": [],
|
||||||
|
"computed_link_reach": document_sibling.computed_link_reach,
|
||||||
|
"computed_link_role": document_sibling.computed_link_role,
|
||||||
"created_at": document_sibling.created_at.isoformat().replace(
|
"created_at": document_sibling.created_at.isoformat().replace(
|
||||||
"+00:00", "Z"
|
"+00:00", "Z"
|
||||||
),
|
),
|
||||||
@@ -237,9 +273,11 @@ def test_api_documents_tree_list_anonymous_public_parent():
|
|||||||
"updated_at": document_sibling.updated_at.isoformat().replace(
|
"updated_at": document_sibling.updated_at.isoformat().replace(
|
||||||
"+00:00", "Z"
|
"+00:00", "Z"
|
||||||
),
|
),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
"computed_link_reach": parent.computed_link_reach,
|
||||||
|
"computed_link_role": parent.computed_link_role,
|
||||||
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(parent.creator.id),
|
"creator": str(parent.creator.id),
|
||||||
"depth": 3,
|
"depth": 3,
|
||||||
@@ -254,11 +292,15 @@ def test_api_documents_tree_list_anonymous_public_parent():
|
|||||||
"path": parent.path,
|
"path": parent.path,
|
||||||
"title": parent.title,
|
"title": parent.title,
|
||||||
"updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"abilities": parent_sibling.get_abilities(AnonymousUser()),
|
"abilities": parent_sibling.get_abilities(AnonymousUser()),
|
||||||
|
"ancestors_link_reach": parent_sibling.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": parent_sibling.ancestors_link_role,
|
||||||
"children": [],
|
"children": [],
|
||||||
|
"computed_link_reach": parent_sibling.computed_link_reach,
|
||||||
|
"computed_link_role": parent_sibling.computed_link_role,
|
||||||
"created_at": parent_sibling.created_at.isoformat().replace(
|
"created_at": parent_sibling.created_at.isoformat().replace(
|
||||||
"+00:00", "Z"
|
"+00:00", "Z"
|
||||||
),
|
),
|
||||||
@@ -277,9 +319,11 @@ def test_api_documents_tree_list_anonymous_public_parent():
|
|||||||
"updated_at": parent_sibling.updated_at.isoformat().replace(
|
"updated_at": parent_sibling.updated_at.isoformat().replace(
|
||||||
"+00:00", "Z"
|
"+00:00", "Z"
|
||||||
),
|
),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
"computed_link_reach": grand_parent.computed_link_reach,
|
||||||
|
"computed_link_role": grand_parent.computed_link_role,
|
||||||
"created_at": grand_parent.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": grand_parent.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(grand_parent.creator.id),
|
"creator": str(grand_parent.creator.id),
|
||||||
"depth": 2,
|
"depth": 2,
|
||||||
@@ -294,8 +338,9 @@ def test_api_documents_tree_list_anonymous_public_parent():
|
|||||||
"path": grand_parent.path,
|
"path": grand_parent.path,
|
||||||
"title": grand_parent.title,
|
"title": grand_parent.title,
|
||||||
"updated_at": grand_parent.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": grand_parent.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
}
|
}
|
||||||
|
assert response.json() == expected_tree
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("reach", ["restricted", "authenticated"])
|
@pytest.mark.parametrize("reach", ["restricted", "authenticated"])
|
||||||
@@ -341,13 +386,21 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"abilities": parent.get_abilities(user),
|
"abilities": parent.get_abilities(user),
|
||||||
|
"ancestors_link_reach": None,
|
||||||
|
"ancestors_link_role": None,
|
||||||
"children": [
|
"children": [
|
||||||
{
|
{
|
||||||
"abilities": document.get_abilities(user),
|
"abilities": document.get_abilities(user),
|
||||||
|
"ancestors_link_reach": document.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": document.ancestors_link_role,
|
||||||
"children": [
|
"children": [
|
||||||
{
|
{
|
||||||
"abilities": child.get_abilities(user),
|
"abilities": child.get_abilities(user),
|
||||||
|
"ancestors_link_reach": child.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": child.ancestors_link_role,
|
||||||
"children": [],
|
"children": [],
|
||||||
|
"computed_link_reach": child.computed_link_reach,
|
||||||
|
"computed_link_role": child.computed_link_role,
|
||||||
"created_at": child.created_at.isoformat().replace(
|
"created_at": child.created_at.isoformat().replace(
|
||||||
"+00:00", "Z"
|
"+00:00", "Z"
|
||||||
),
|
),
|
||||||
@@ -366,9 +419,11 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated
|
|||||||
"updated_at": child.updated_at.isoformat().replace(
|
"updated_at": child.updated_at.isoformat().replace(
|
||||||
"+00:00", "Z"
|
"+00:00", "Z"
|
||||||
),
|
),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
"computed_link_reach": document.computed_link_reach,
|
||||||
|
"computed_link_role": document.computed_link_role,
|
||||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(document.creator.id),
|
"creator": str(document.creator.id),
|
||||||
"depth": 2,
|
"depth": 2,
|
||||||
@@ -383,11 +438,15 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated
|
|||||||
"path": document.path,
|
"path": document.path,
|
||||||
"title": document.title,
|
"title": document.title,
|
||||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"abilities": sibling.get_abilities(user),
|
"abilities": sibling.get_abilities(user),
|
||||||
|
"ancestors_link_reach": sibling.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": sibling.ancestors_link_role,
|
||||||
"children": [],
|
"children": [],
|
||||||
|
"computed_link_reach": sibling.computed_link_reach,
|
||||||
|
"computed_link_role": sibling.computed_link_role,
|
||||||
"created_at": sibling.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": sibling.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(sibling.creator.id),
|
"creator": str(sibling.creator.id),
|
||||||
"depth": 2,
|
"depth": 2,
|
||||||
@@ -402,9 +461,11 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated
|
|||||||
"path": sibling.path,
|
"path": sibling.path,
|
||||||
"title": sibling.title,
|
"title": sibling.title,
|
||||||
"updated_at": sibling.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": sibling.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
"computed_link_reach": parent.computed_link_reach,
|
||||||
|
"computed_link_role": parent.computed_link_role,
|
||||||
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(parent.creator.id),
|
"creator": str(parent.creator.id),
|
||||||
"depth": 1,
|
"depth": 1,
|
||||||
@@ -419,7 +480,7 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated
|
|||||||
"path": parent.path,
|
"path": parent.path,
|
||||||
"title": parent.title,
|
"title": parent.title,
|
||||||
"updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -460,16 +521,26 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"abilities": grand_parent.get_abilities(user),
|
"abilities": grand_parent.get_abilities(user),
|
||||||
|
"ancestors_link_reach": grand_parent.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": grand_parent.ancestors_link_role,
|
||||||
"children": [
|
"children": [
|
||||||
{
|
{
|
||||||
"abilities": parent.get_abilities(user),
|
"abilities": parent.get_abilities(user),
|
||||||
|
"ancestors_link_reach": parent.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": parent.ancestors_link_role,
|
||||||
"children": [
|
"children": [
|
||||||
{
|
{
|
||||||
"abilities": document.get_abilities(user),
|
"abilities": document.get_abilities(user),
|
||||||
|
"ancestors_link_reach": document.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": document.ancestors_link_role,
|
||||||
"children": [
|
"children": [
|
||||||
{
|
{
|
||||||
"abilities": child.get_abilities(user),
|
"abilities": child.get_abilities(user),
|
||||||
|
"ancestors_link_reach": child.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": child.ancestors_link_role,
|
||||||
"children": [],
|
"children": [],
|
||||||
|
"computed_link_reach": child.computed_link_reach,
|
||||||
|
"computed_link_role": child.computed_link_role,
|
||||||
"created_at": child.created_at.isoformat().replace(
|
"created_at": child.created_at.isoformat().replace(
|
||||||
"+00:00", "Z"
|
"+00:00", "Z"
|
||||||
),
|
),
|
||||||
@@ -488,9 +559,11 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
|
|||||||
"updated_at": child.updated_at.isoformat().replace(
|
"updated_at": child.updated_at.isoformat().replace(
|
||||||
"+00:00", "Z"
|
"+00:00", "Z"
|
||||||
),
|
),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
"computed_link_reach": document.computed_link_reach,
|
||||||
|
"computed_link_role": document.computed_link_role,
|
||||||
"created_at": document.created_at.isoformat().replace(
|
"created_at": document.created_at.isoformat().replace(
|
||||||
"+00:00", "Z"
|
"+00:00", "Z"
|
||||||
),
|
),
|
||||||
@@ -509,11 +582,15 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
|
|||||||
"updated_at": document.updated_at.isoformat().replace(
|
"updated_at": document.updated_at.isoformat().replace(
|
||||||
"+00:00", "Z"
|
"+00:00", "Z"
|
||||||
),
|
),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"abilities": document_sibling.get_abilities(user),
|
"abilities": document_sibling.get_abilities(user),
|
||||||
|
"ancestors_link_reach": document_sibling.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": document_sibling.ancestors_link_role,
|
||||||
"children": [],
|
"children": [],
|
||||||
|
"computed_link_reach": document_sibling.computed_link_reach,
|
||||||
|
"computed_link_role": document_sibling.computed_link_role,
|
||||||
"created_at": document_sibling.created_at.isoformat().replace(
|
"created_at": document_sibling.created_at.isoformat().replace(
|
||||||
"+00:00", "Z"
|
"+00:00", "Z"
|
||||||
),
|
),
|
||||||
@@ -532,9 +609,11 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
|
|||||||
"updated_at": document_sibling.updated_at.isoformat().replace(
|
"updated_at": document_sibling.updated_at.isoformat().replace(
|
||||||
"+00:00", "Z"
|
"+00:00", "Z"
|
||||||
),
|
),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
"computed_link_reach": parent.computed_link_reach,
|
||||||
|
"computed_link_role": parent.computed_link_role,
|
||||||
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(parent.creator.id),
|
"creator": str(parent.creator.id),
|
||||||
"depth": 3,
|
"depth": 3,
|
||||||
@@ -549,11 +628,15 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
|
|||||||
"path": parent.path,
|
"path": parent.path,
|
||||||
"title": parent.title,
|
"title": parent.title,
|
||||||
"updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"abilities": parent_sibling.get_abilities(user),
|
"abilities": parent_sibling.get_abilities(user),
|
||||||
|
"ancestors_link_reach": parent_sibling.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": parent_sibling.ancestors_link_role,
|
||||||
"children": [],
|
"children": [],
|
||||||
|
"computed_link_reach": parent_sibling.computed_link_reach,
|
||||||
|
"computed_link_role": parent_sibling.computed_link_role,
|
||||||
"created_at": parent_sibling.created_at.isoformat().replace(
|
"created_at": parent_sibling.created_at.isoformat().replace(
|
||||||
"+00:00", "Z"
|
"+00:00", "Z"
|
||||||
),
|
),
|
||||||
@@ -572,9 +655,11 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
|
|||||||
"updated_at": parent_sibling.updated_at.isoformat().replace(
|
"updated_at": parent_sibling.updated_at.isoformat().replace(
|
||||||
"+00:00", "Z"
|
"+00:00", "Z"
|
||||||
),
|
),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
"computed_link_reach": grand_parent.computed_link_reach,
|
||||||
|
"computed_link_role": grand_parent.computed_link_role,
|
||||||
"created_at": grand_parent.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": grand_parent.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(grand_parent.creator.id),
|
"creator": str(grand_parent.creator.id),
|
||||||
"depth": 2,
|
"depth": 2,
|
||||||
@@ -589,7 +674,7 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
|
|||||||
"path": grand_parent.path,
|
"path": grand_parent.path,
|
||||||
"title": grand_parent.title,
|
"title": grand_parent.title,
|
||||||
"updated_at": grand_parent.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": grand_parent.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [],
|
"user_role": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -639,13 +724,21 @@ def test_api_documents_tree_list_authenticated_related_direct():
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"abilities": parent.get_abilities(user),
|
"abilities": parent.get_abilities(user),
|
||||||
|
"ancestors_link_reach": parent.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": parent.ancestors_link_role,
|
||||||
"children": [
|
"children": [
|
||||||
{
|
{
|
||||||
"abilities": document.get_abilities(user),
|
"abilities": document.get_abilities(user),
|
||||||
|
"ancestors_link_reach": document.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": document.ancestors_link_role,
|
||||||
"children": [
|
"children": [
|
||||||
{
|
{
|
||||||
"abilities": child.get_abilities(user),
|
"abilities": child.get_abilities(user),
|
||||||
|
"ancestors_link_reach": child.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": child.ancestors_link_role,
|
||||||
"children": [],
|
"children": [],
|
||||||
|
"computed_link_reach": child.computed_link_reach,
|
||||||
|
"computed_link_role": child.computed_link_role,
|
||||||
"created_at": child.created_at.isoformat().replace(
|
"created_at": child.created_at.isoformat().replace(
|
||||||
"+00:00", "Z"
|
"+00:00", "Z"
|
||||||
),
|
),
|
||||||
@@ -664,9 +757,11 @@ def test_api_documents_tree_list_authenticated_related_direct():
|
|||||||
"updated_at": child.updated_at.isoformat().replace(
|
"updated_at": child.updated_at.isoformat().replace(
|
||||||
"+00:00", "Z"
|
"+00:00", "Z"
|
||||||
),
|
),
|
||||||
"user_roles": [access.role],
|
"user_role": access.role,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
"computed_link_reach": document.computed_link_reach,
|
||||||
|
"computed_link_role": document.computed_link_role,
|
||||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(document.creator.id),
|
"creator": str(document.creator.id),
|
||||||
"depth": 2,
|
"depth": 2,
|
||||||
@@ -681,11 +776,15 @@ def test_api_documents_tree_list_authenticated_related_direct():
|
|||||||
"path": document.path,
|
"path": document.path,
|
||||||
"title": document.title,
|
"title": document.title,
|
||||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [access.role],
|
"user_role": access.role,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"abilities": sibling.get_abilities(user),
|
"abilities": sibling.get_abilities(user),
|
||||||
|
"ancestors_link_reach": sibling.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": sibling.ancestors_link_role,
|
||||||
"children": [],
|
"children": [],
|
||||||
|
"computed_link_reach": sibling.computed_link_reach,
|
||||||
|
"computed_link_role": sibling.computed_link_role,
|
||||||
"created_at": sibling.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": sibling.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(sibling.creator.id),
|
"creator": str(sibling.creator.id),
|
||||||
"depth": 2,
|
"depth": 2,
|
||||||
@@ -700,9 +799,11 @@ def test_api_documents_tree_list_authenticated_related_direct():
|
|||||||
"path": sibling.path,
|
"path": sibling.path,
|
||||||
"title": sibling.title,
|
"title": sibling.title,
|
||||||
"updated_at": sibling.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": sibling.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [access.role],
|
"user_role": access.role,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
"computed_link_reach": parent.computed_link_reach,
|
||||||
|
"computed_link_role": parent.computed_link_role,
|
||||||
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(parent.creator.id),
|
"creator": str(parent.creator.id),
|
||||||
"depth": 1,
|
"depth": 1,
|
||||||
@@ -717,7 +818,7 @@ def test_api_documents_tree_list_authenticated_related_direct():
|
|||||||
"path": parent.path,
|
"path": parent.path,
|
||||||
"title": parent.title,
|
"title": parent.title,
|
||||||
"updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [access.role],
|
"user_role": access.role,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -762,16 +863,26 @@ def test_api_documents_tree_list_authenticated_related_parent():
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"abilities": grand_parent.get_abilities(user),
|
"abilities": grand_parent.get_abilities(user),
|
||||||
|
"ancestors_link_reach": grand_parent.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": grand_parent.ancestors_link_role,
|
||||||
"children": [
|
"children": [
|
||||||
{
|
{
|
||||||
"abilities": parent.get_abilities(user),
|
"abilities": parent.get_abilities(user),
|
||||||
|
"ancestors_link_reach": parent.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": parent.ancestors_link_role,
|
||||||
"children": [
|
"children": [
|
||||||
{
|
{
|
||||||
"abilities": document.get_abilities(user),
|
"abilities": document.get_abilities(user),
|
||||||
|
"ancestors_link_reach": document.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": document.ancestors_link_role,
|
||||||
"children": [
|
"children": [
|
||||||
{
|
{
|
||||||
"abilities": child.get_abilities(user),
|
"abilities": child.get_abilities(user),
|
||||||
|
"ancestors_link_reach": child.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": child.ancestors_link_role,
|
||||||
|
"computed_link_reach": child.computed_link_reach,
|
||||||
"children": [],
|
"children": [],
|
||||||
|
"computed_link_role": child.computed_link_role,
|
||||||
"created_at": child.created_at.isoformat().replace(
|
"created_at": child.created_at.isoformat().replace(
|
||||||
"+00:00", "Z"
|
"+00:00", "Z"
|
||||||
),
|
),
|
||||||
@@ -790,9 +901,11 @@ def test_api_documents_tree_list_authenticated_related_parent():
|
|||||||
"updated_at": child.updated_at.isoformat().replace(
|
"updated_at": child.updated_at.isoformat().replace(
|
||||||
"+00:00", "Z"
|
"+00:00", "Z"
|
||||||
),
|
),
|
||||||
"user_roles": [access.role],
|
"user_role": access.role,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
"computed_link_reach": document.computed_link_reach,
|
||||||
|
"computed_link_role": document.computed_link_role,
|
||||||
"created_at": document.created_at.isoformat().replace(
|
"created_at": document.created_at.isoformat().replace(
|
||||||
"+00:00", "Z"
|
"+00:00", "Z"
|
||||||
),
|
),
|
||||||
@@ -811,11 +924,15 @@ def test_api_documents_tree_list_authenticated_related_parent():
|
|||||||
"updated_at": document.updated_at.isoformat().replace(
|
"updated_at": document.updated_at.isoformat().replace(
|
||||||
"+00:00", "Z"
|
"+00:00", "Z"
|
||||||
),
|
),
|
||||||
"user_roles": [access.role],
|
"user_role": access.role,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"abilities": document_sibling.get_abilities(user),
|
"abilities": document_sibling.get_abilities(user),
|
||||||
|
"ancestors_link_reach": document_sibling.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": document_sibling.ancestors_link_role,
|
||||||
"children": [],
|
"children": [],
|
||||||
|
"computed_link_reach": document_sibling.computed_link_reach,
|
||||||
|
"computed_link_role": document_sibling.computed_link_role,
|
||||||
"created_at": document_sibling.created_at.isoformat().replace(
|
"created_at": document_sibling.created_at.isoformat().replace(
|
||||||
"+00:00", "Z"
|
"+00:00", "Z"
|
||||||
),
|
),
|
||||||
@@ -834,9 +951,11 @@ def test_api_documents_tree_list_authenticated_related_parent():
|
|||||||
"updated_at": document_sibling.updated_at.isoformat().replace(
|
"updated_at": document_sibling.updated_at.isoformat().replace(
|
||||||
"+00:00", "Z"
|
"+00:00", "Z"
|
||||||
),
|
),
|
||||||
"user_roles": [access.role],
|
"user_role": access.role,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
"computed_link_reach": parent.computed_link_reach,
|
||||||
|
"computed_link_role": parent.computed_link_role,
|
||||||
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(parent.creator.id),
|
"creator": str(parent.creator.id),
|
||||||
"depth": 3,
|
"depth": 3,
|
||||||
@@ -851,11 +970,15 @@ def test_api_documents_tree_list_authenticated_related_parent():
|
|||||||
"path": parent.path,
|
"path": parent.path,
|
||||||
"title": parent.title,
|
"title": parent.title,
|
||||||
"updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [access.role],
|
"user_role": access.role,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"abilities": parent_sibling.get_abilities(user),
|
"abilities": parent_sibling.get_abilities(user),
|
||||||
|
"ancestors_link_reach": parent_sibling.ancestors_link_reach,
|
||||||
|
"ancestors_link_role": parent_sibling.ancestors_link_role,
|
||||||
"children": [],
|
"children": [],
|
||||||
|
"computed_link_reach": parent_sibling.computed_link_reach,
|
||||||
|
"computed_link_role": parent_sibling.computed_link_role,
|
||||||
"created_at": parent_sibling.created_at.isoformat().replace(
|
"created_at": parent_sibling.created_at.isoformat().replace(
|
||||||
"+00:00", "Z"
|
"+00:00", "Z"
|
||||||
),
|
),
|
||||||
@@ -874,9 +997,11 @@ def test_api_documents_tree_list_authenticated_related_parent():
|
|||||||
"updated_at": parent_sibling.updated_at.isoformat().replace(
|
"updated_at": parent_sibling.updated_at.isoformat().replace(
|
||||||
"+00:00", "Z"
|
"+00:00", "Z"
|
||||||
),
|
),
|
||||||
"user_roles": [access.role],
|
"user_role": access.role,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
"computed_link_reach": grand_parent.computed_link_reach,
|
||||||
|
"computed_link_role": grand_parent.computed_link_role,
|
||||||
"created_at": grand_parent.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": grand_parent.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(grand_parent.creator.id),
|
"creator": str(grand_parent.creator.id),
|
||||||
"depth": 2,
|
"depth": 2,
|
||||||
@@ -891,7 +1016,7 @@ def test_api_documents_tree_list_authenticated_related_parent():
|
|||||||
"path": grand_parent.path,
|
"path": grand_parent.path,
|
||||||
"title": grand_parent.title,
|
"title": grand_parent.title,
|
||||||
"updated_at": grand_parent.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": grand_parent.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [access.role],
|
"user_role": access.role,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -949,13 +1074,21 @@ def test_api_documents_tree_list_authenticated_related_team_members(
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"abilities": parent.get_abilities(user),
|
"abilities": parent.get_abilities(user),
|
||||||
|
"ancestors_link_reach": None,
|
||||||
|
"ancestors_link_role": None,
|
||||||
"children": [
|
"children": [
|
||||||
{
|
{
|
||||||
"abilities": document.get_abilities(user),
|
"abilities": document.get_abilities(user),
|
||||||
|
"ancestors_link_reach": "restricted",
|
||||||
|
"ancestors_link_role": None,
|
||||||
"children": [
|
"children": [
|
||||||
{
|
{
|
||||||
"abilities": child.get_abilities(user),
|
"abilities": child.get_abilities(user),
|
||||||
|
"ancestors_link_reach": "restricted",
|
||||||
|
"ancestors_link_role": None,
|
||||||
"children": [],
|
"children": [],
|
||||||
|
"computed_link_reach": child.computed_link_reach,
|
||||||
|
"computed_link_role": child.computed_link_role,
|
||||||
"created_at": child.created_at.isoformat().replace(
|
"created_at": child.created_at.isoformat().replace(
|
||||||
"+00:00", "Z"
|
"+00:00", "Z"
|
||||||
),
|
),
|
||||||
@@ -974,9 +1107,11 @@ def test_api_documents_tree_list_authenticated_related_team_members(
|
|||||||
"updated_at": child.updated_at.isoformat().replace(
|
"updated_at": child.updated_at.isoformat().replace(
|
||||||
"+00:00", "Z"
|
"+00:00", "Z"
|
||||||
),
|
),
|
||||||
"user_roles": [access.role],
|
"user_role": access.role,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
"computed_link_reach": document.computed_link_reach,
|
||||||
|
"computed_link_role": document.computed_link_role,
|
||||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(document.creator.id),
|
"creator": str(document.creator.id),
|
||||||
"depth": 2,
|
"depth": 2,
|
||||||
@@ -991,11 +1126,15 @@ def test_api_documents_tree_list_authenticated_related_team_members(
|
|||||||
"path": document.path,
|
"path": document.path,
|
||||||
"title": document.title,
|
"title": document.title,
|
||||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [access.role],
|
"user_role": access.role,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"abilities": sibling.get_abilities(user),
|
"abilities": sibling.get_abilities(user),
|
||||||
|
"ancestors_link_reach": "restricted",
|
||||||
|
"ancestors_link_role": None,
|
||||||
"children": [],
|
"children": [],
|
||||||
|
"computed_link_reach": sibling.computed_link_reach,
|
||||||
|
"computed_link_role": sibling.computed_link_role,
|
||||||
"created_at": sibling.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": sibling.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(sibling.creator.id),
|
"creator": str(sibling.creator.id),
|
||||||
"depth": 2,
|
"depth": 2,
|
||||||
@@ -1010,9 +1149,11 @@ def test_api_documents_tree_list_authenticated_related_team_members(
|
|||||||
"path": sibling.path,
|
"path": sibling.path,
|
||||||
"title": sibling.title,
|
"title": sibling.title,
|
||||||
"updated_at": sibling.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": sibling.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [access.role],
|
"user_role": access.role,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
"computed_link_reach": parent.computed_link_reach,
|
||||||
|
"computed_link_role": parent.computed_link_role,
|
||||||
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
|
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
|
||||||
"creator": str(parent.creator.id),
|
"creator": str(parent.creator.id),
|
||||||
"depth": 1,
|
"depth": 1,
|
||||||
@@ -1027,5 +1168,5 @@ def test_api_documents_tree_list_authenticated_related_team_members(
|
|||||||
"path": parent.path,
|
"path": parent.path,
|
||||||
"title": parent.title,
|
"title": parent.title,
|
||||||
"updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"),
|
"updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"),
|
||||||
"user_roles": [access.role],
|
"user_role": access.role,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ Tests for Documents API endpoint in impress's core app: update
|
|||||||
import random
|
import random
|
||||||
|
|
||||||
from django.contrib.auth.models import AnonymousUser
|
from django.contrib.auth.models import AnonymousUser
|
||||||
|
from django.core.cache import cache
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
import responses
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from core import factories, models
|
from core import factories, models
|
||||||
@@ -44,6 +46,7 @@ def test_api_documents_update_anonymous_forbidden(reach, role, via_parent):
|
|||||||
new_document_values = serializers.DocumentSerializer(
|
new_document_values = serializers.DocumentSerializer(
|
||||||
instance=factories.DocumentFactory()
|
instance=factories.DocumentFactory()
|
||||||
).data
|
).data
|
||||||
|
new_document_values["websocket"] = True
|
||||||
response = APIClient().put(
|
response = APIClient().put(
|
||||||
f"/api/v1.0/documents/{document.id!s}/",
|
f"/api/v1.0/documents/{document.id!s}/",
|
||||||
new_document_values,
|
new_document_values,
|
||||||
@@ -90,8 +93,9 @@ def test_api_documents_update_authenticated_unrelated_forbidden(
|
|||||||
|
|
||||||
old_document_values = serializers.DocumentSerializer(instance=document).data
|
old_document_values = serializers.DocumentSerializer(instance=document).data
|
||||||
new_document_values = serializers.DocumentSerializer(
|
new_document_values = serializers.DocumentSerializer(
|
||||||
instance=factories.DocumentFactory()
|
instance=factories.DocumentFactory(),
|
||||||
).data
|
).data
|
||||||
|
new_document_values["websocket"] = True
|
||||||
response = client.put(
|
response = client.put(
|
||||||
f"/api/v1.0/documents/{document.id!s}/",
|
f"/api/v1.0/documents/{document.id!s}/",
|
||||||
new_document_values,
|
new_document_values,
|
||||||
@@ -141,8 +145,9 @@ def test_api_documents_update_anonymous_or_authenticated_unrelated(
|
|||||||
|
|
||||||
old_document_values = serializers.DocumentSerializer(instance=document).data
|
old_document_values = serializers.DocumentSerializer(instance=document).data
|
||||||
new_document_values = serializers.DocumentSerializer(
|
new_document_values = serializers.DocumentSerializer(
|
||||||
instance=factories.DocumentFactory()
|
instance=factories.DocumentFactory(),
|
||||||
).data
|
).data
|
||||||
|
new_document_values["websocket"] = True
|
||||||
response = client.put(
|
response = client.put(
|
||||||
f"/api/v1.0/documents/{document.id!s}/",
|
f"/api/v1.0/documents/{document.id!s}/",
|
||||||
new_document_values,
|
new_document_values,
|
||||||
@@ -155,6 +160,10 @@ def test_api_documents_update_anonymous_or_authenticated_unrelated(
|
|||||||
for key, value in document_values.items():
|
for key, value in document_values.items():
|
||||||
if key in [
|
if key in [
|
||||||
"id",
|
"id",
|
||||||
|
"ancestors_link_reach",
|
||||||
|
"ancestors_link_role",
|
||||||
|
"computed_link_reach",
|
||||||
|
"computed_link_role",
|
||||||
"accesses",
|
"accesses",
|
||||||
"created_at",
|
"created_at",
|
||||||
"creator",
|
"creator",
|
||||||
@@ -206,6 +215,7 @@ def test_api_documents_update_authenticated_reader(via, via_parent, mock_user_te
|
|||||||
new_document_values = serializers.DocumentSerializer(
|
new_document_values = serializers.DocumentSerializer(
|
||||||
instance=factories.DocumentFactory()
|
instance=factories.DocumentFactory()
|
||||||
).data
|
).data
|
||||||
|
new_document_values["websocket"] = True
|
||||||
response = client.put(
|
response = client.put(
|
||||||
f"/api/v1.0/documents/{document.id!s}/",
|
f"/api/v1.0/documents/{document.id!s}/",
|
||||||
new_document_values,
|
new_document_values,
|
||||||
@@ -258,6 +268,7 @@ def test_api_documents_update_authenticated_editor_administrator_or_owner(
|
|||||||
new_document_values = serializers.DocumentSerializer(
|
new_document_values = serializers.DocumentSerializer(
|
||||||
instance=factories.DocumentFactory()
|
instance=factories.DocumentFactory()
|
||||||
).data
|
).data
|
||||||
|
new_document_values["websocket"] = True
|
||||||
response = client.put(
|
response = client.put(
|
||||||
f"/api/v1.0/documents/{document.id!s}/",
|
f"/api/v1.0/documents/{document.id!s}/",
|
||||||
new_document_values,
|
new_document_values,
|
||||||
@@ -270,6 +281,10 @@ def test_api_documents_update_authenticated_editor_administrator_or_owner(
|
|||||||
for key, value in document_values.items():
|
for key, value in document_values.items():
|
||||||
if key in [
|
if key in [
|
||||||
"id",
|
"id",
|
||||||
|
"ancestors_link_reach",
|
||||||
|
"ancestors_link_role",
|
||||||
|
"computed_link_reach",
|
||||||
|
"computed_link_role",
|
||||||
"created_at",
|
"created_at",
|
||||||
"creator",
|
"creator",
|
||||||
"depth",
|
"depth",
|
||||||
@@ -287,6 +302,359 @@ def test_api_documents_update_authenticated_editor_administrator_or_owner(
|
|||||||
assert value == new_document_values[key]
|
assert value == new_document_values[key]
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_api_documents_update_authenticated_no_websocket(settings):
|
||||||
|
"""
|
||||||
|
When a user updates the document, not connected to the websocket and is the first to update,
|
||||||
|
the document should be updated.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory(with_owned_document=True)
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
session_key = client.session.session_key
|
||||||
|
|
||||||
|
document = factories.DocumentFactory(users=[(user, "editor")])
|
||||||
|
|
||||||
|
new_document_values = serializers.DocumentSerializer(
|
||||||
|
instance=factories.DocumentFactory()
|
||||||
|
).data
|
||||||
|
new_document_values["websocket"] = False
|
||||||
|
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||||
|
settings.COLLABORATION_SERVER_SECRET = "secret-token"
|
||||||
|
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
|
||||||
|
endpoint_url = (
|
||||||
|
f"{settings.COLLABORATION_API_URL}get-connections/"
|
||||||
|
f"?room={document.id}&sessionKey={session_key}"
|
||||||
|
)
|
||||||
|
|
||||||
|
ws_resp = responses.get(endpoint_url, json={"count": 0, "exists": False})
|
||||||
|
|
||||||
|
assert cache.get(f"docs:no-websocket:{document.id}") is None
|
||||||
|
|
||||||
|
response = client.put(
|
||||||
|
f"/api/v1.0/documents/{document.id!s}/",
|
||||||
|
new_document_values,
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
assert cache.get(f"docs:no-websocket:{document.id}") == session_key
|
||||||
|
assert ws_resp.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_api_documents_update_authenticated_no_websocket_user_already_editing(settings):
|
||||||
|
"""
|
||||||
|
When a user updates the document, not connected to the websocket and is not the first to update,
|
||||||
|
the document should not be updated.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory(with_owned_document=True)
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
session_key = client.session.session_key
|
||||||
|
|
||||||
|
document = factories.DocumentFactory(users=[(user, "editor")])
|
||||||
|
|
||||||
|
new_document_values = serializers.DocumentSerializer(
|
||||||
|
instance=factories.DocumentFactory()
|
||||||
|
).data
|
||||||
|
new_document_values["websocket"] = False
|
||||||
|
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||||
|
settings.COLLABORATION_SERVER_SECRET = "secret-token"
|
||||||
|
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
|
||||||
|
endpoint_url = (
|
||||||
|
f"{settings.COLLABORATION_API_URL}get-connections/"
|
||||||
|
f"?room={document.id}&sessionKey={session_key}"
|
||||||
|
)
|
||||||
|
ws_resp = responses.get(endpoint_url, json={"count": 0, "exists": False})
|
||||||
|
|
||||||
|
cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
|
||||||
|
|
||||||
|
response = client.put(
|
||||||
|
f"/api/v1.0/documents/{document.id!s}/",
|
||||||
|
new_document_values,
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert response.json() == {"detail": "You are not allowed to edit this document."}
|
||||||
|
|
||||||
|
assert ws_resp.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_api_documents_update_no_websocket_other_user_connected_to_websocket(settings):
|
||||||
|
"""
|
||||||
|
When a user updates the document, not connected to the websocket and another user is connected
|
||||||
|
to the websocket, the document should not be updated.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory(with_owned_document=True)
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
session_key = client.session.session_key
|
||||||
|
|
||||||
|
document = factories.DocumentFactory(users=[(user, "editor")])
|
||||||
|
|
||||||
|
new_document_values = serializers.DocumentSerializer(
|
||||||
|
instance=factories.DocumentFactory()
|
||||||
|
).data
|
||||||
|
new_document_values["websocket"] = False
|
||||||
|
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||||
|
settings.COLLABORATION_SERVER_SECRET = "secret-token"
|
||||||
|
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
|
||||||
|
endpoint_url = (
|
||||||
|
f"{settings.COLLABORATION_API_URL}get-connections/"
|
||||||
|
f"?room={document.id}&sessionKey={session_key}"
|
||||||
|
)
|
||||||
|
ws_resp = responses.get(endpoint_url, json={"count": 3, "exists": False})
|
||||||
|
|
||||||
|
assert cache.get(f"docs:no-websocket:{document.id}") is None
|
||||||
|
|
||||||
|
response = client.put(
|
||||||
|
f"/api/v1.0/documents/{document.id!s}/",
|
||||||
|
new_document_values,
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert response.json() == {"detail": "You are not allowed to edit this document."}
|
||||||
|
assert cache.get(f"docs:no-websocket:{document.id}") is None
|
||||||
|
assert ws_resp.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_api_documents_update_user_connected_to_websocket(settings):
|
||||||
|
"""
|
||||||
|
When a user updates the document, connected to the websocket, the document should be updated.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory(with_owned_document=True)
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
session_key = client.session.session_key
|
||||||
|
|
||||||
|
document = factories.DocumentFactory(users=[(user, "editor")])
|
||||||
|
|
||||||
|
new_document_values = serializers.DocumentSerializer(
|
||||||
|
instance=factories.DocumentFactory()
|
||||||
|
).data
|
||||||
|
new_document_values["websocket"] = False
|
||||||
|
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||||
|
settings.COLLABORATION_SERVER_SECRET = "secret-token"
|
||||||
|
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
|
||||||
|
endpoint_url = (
|
||||||
|
f"{settings.COLLABORATION_API_URL}get-connections/"
|
||||||
|
f"?room={document.id}&sessionKey={session_key}"
|
||||||
|
)
|
||||||
|
ws_resp = responses.get(endpoint_url, json={"count": 3, "exists": True})
|
||||||
|
|
||||||
|
assert cache.get(f"docs:no-websocket:{document.id}") is None
|
||||||
|
|
||||||
|
response = client.put(
|
||||||
|
f"/api/v1.0/documents/{document.id!s}/",
|
||||||
|
new_document_values,
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert cache.get(f"docs:no-websocket:{document.id}") is None
|
||||||
|
assert ws_resp.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_api_documents_update_websocket_server_unreachable_fallback_to_no_websocket(
|
||||||
|
settings,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
When the websocket server is unreachable, the document should be updated like if the user was
|
||||||
|
not connected to the websocket.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory(with_owned_document=True)
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
session_key = client.session.session_key
|
||||||
|
|
||||||
|
document = factories.DocumentFactory(users=[(user, "editor")])
|
||||||
|
|
||||||
|
new_document_values = serializers.DocumentSerializer(
|
||||||
|
instance=factories.DocumentFactory()
|
||||||
|
).data
|
||||||
|
new_document_values["websocket"] = False
|
||||||
|
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||||
|
settings.COLLABORATION_SERVER_SECRET = "secret-token"
|
||||||
|
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
|
||||||
|
endpoint_url = (
|
||||||
|
f"{settings.COLLABORATION_API_URL}get-connections/"
|
||||||
|
f"?room={document.id}&sessionKey={session_key}"
|
||||||
|
)
|
||||||
|
ws_resp = responses.get(endpoint_url, status=500)
|
||||||
|
|
||||||
|
assert cache.get(f"docs:no-websocket:{document.id}") is None
|
||||||
|
|
||||||
|
response = client.put(
|
||||||
|
f"/api/v1.0/documents/{document.id!s}/",
|
||||||
|
new_document_values,
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
assert cache.get(f"docs:no-websocket:{document.id}") == session_key
|
||||||
|
assert ws_resp.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_api_documents_update_websocket_server_unreachable_fallback_to_no_websocket_other_users(
|
||||||
|
settings,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
When the websocket server is unreachable, the behavior fallback to the no websocket one.
|
||||||
|
If an other user is already editing, the document should not be updated.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory(with_owned_document=True)
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
session_key = client.session.session_key
|
||||||
|
|
||||||
|
document = factories.DocumentFactory(users=[(user, "editor")])
|
||||||
|
|
||||||
|
new_document_values = serializers.DocumentSerializer(
|
||||||
|
instance=factories.DocumentFactory()
|
||||||
|
).data
|
||||||
|
new_document_values["websocket"] = False
|
||||||
|
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||||
|
settings.COLLABORATION_SERVER_SECRET = "secret-token"
|
||||||
|
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
|
||||||
|
endpoint_url = (
|
||||||
|
f"{settings.COLLABORATION_API_URL}get-connections/"
|
||||||
|
f"?room={document.id}&sessionKey={session_key}"
|
||||||
|
)
|
||||||
|
ws_resp = responses.get(endpoint_url, status=500)
|
||||||
|
|
||||||
|
cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
|
||||||
|
|
||||||
|
response = client.put(
|
||||||
|
f"/api/v1.0/documents/{document.id!s}/",
|
||||||
|
new_document_values,
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
assert cache.get(f"docs:no-websocket:{document.id}") == "other_session_key"
|
||||||
|
assert ws_resp.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_api_documents_update_websocket_server_room_not_found_fallback_to_no_websocket_other_users(
|
||||||
|
settings,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
When the WebSocket server does not have the room created, the logic should fallback to
|
||||||
|
no-WebSocket. If another user is already editing, the update must be denied.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory(with_owned_document=True)
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
session_key = client.session.session_key
|
||||||
|
|
||||||
|
document = factories.DocumentFactory(users=[(user, "editor")])
|
||||||
|
|
||||||
|
new_document_values = serializers.DocumentSerializer(
|
||||||
|
instance=factories.DocumentFactory()
|
||||||
|
).data
|
||||||
|
new_document_values["websocket"] = False
|
||||||
|
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||||
|
settings.COLLABORATION_SERVER_SECRET = "secret-token"
|
||||||
|
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
|
||||||
|
endpoint_url = (
|
||||||
|
f"{settings.COLLABORATION_API_URL}get-connections/"
|
||||||
|
f"?room={document.id}&sessionKey={session_key}"
|
||||||
|
)
|
||||||
|
ws_resp = responses.get(endpoint_url, status=404)
|
||||||
|
|
||||||
|
cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
|
||||||
|
|
||||||
|
response = client.put(
|
||||||
|
f"/api/v1.0/documents/{document.id!s}/",
|
||||||
|
new_document_values,
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
assert cache.get(f"docs:no-websocket:{document.id}") == "other_session_key"
|
||||||
|
assert ws_resp.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_api_documents_update_force_websocket_param_to_true(settings):
|
||||||
|
"""
|
||||||
|
When the websocket parameter is set to true, the document should be updated without any check.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory(with_owned_document=True)
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
session_key = client.session.session_key
|
||||||
|
|
||||||
|
document = factories.DocumentFactory(users=[(user, "editor")])
|
||||||
|
|
||||||
|
new_document_values = serializers.DocumentSerializer(
|
||||||
|
instance=factories.DocumentFactory()
|
||||||
|
).data
|
||||||
|
new_document_values["websocket"] = True
|
||||||
|
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||||
|
settings.COLLABORATION_SERVER_SECRET = "secret-token"
|
||||||
|
endpoint_url = (
|
||||||
|
f"{settings.COLLABORATION_API_URL}get-connections/"
|
||||||
|
f"?room={document.id}&sessionKey={session_key}"
|
||||||
|
)
|
||||||
|
ws_resp = responses.get(endpoint_url, status=500)
|
||||||
|
|
||||||
|
assert cache.get(f"docs:no-websocket:{document.id}") is None
|
||||||
|
|
||||||
|
response = client.put(
|
||||||
|
f"/api/v1.0/documents/{document.id!s}/",
|
||||||
|
new_document_values,
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
assert cache.get(f"docs:no-websocket:{document.id}") is None
|
||||||
|
assert ws_resp.call_count == 0
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_api_documents_update_feature_flag_disabled(settings):
|
||||||
|
"""
|
||||||
|
When the feature flag is disabled, the document should be updated without any check.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory(with_owned_document=True)
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
session_key = client.session.session_key
|
||||||
|
|
||||||
|
document = factories.DocumentFactory(users=[(user, "editor")])
|
||||||
|
|
||||||
|
new_document_values = serializers.DocumentSerializer(
|
||||||
|
instance=factories.DocumentFactory()
|
||||||
|
).data
|
||||||
|
new_document_values["websocket"] = False
|
||||||
|
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||||
|
settings.COLLABORATION_SERVER_SECRET = "secret-token"
|
||||||
|
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = False
|
||||||
|
endpoint_url = (
|
||||||
|
f"{settings.COLLABORATION_API_URL}get-connections/"
|
||||||
|
f"?room={document.id}&sessionKey={session_key}"
|
||||||
|
)
|
||||||
|
ws_resp = responses.get(endpoint_url, status=500)
|
||||||
|
|
||||||
|
assert cache.get(f"docs:no-websocket:{document.id}") is None
|
||||||
|
|
||||||
|
response = client.put(
|
||||||
|
f"/api/v1.0/documents/{document.id!s}/",
|
||||||
|
new_document_values,
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
assert cache.get(f"docs:no-websocket:{document.id}") is None
|
||||||
|
assert ws_resp.call_count == 0
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("via", VIA)
|
@pytest.mark.parametrize("via", VIA)
|
||||||
def test_api_documents_update_administrator_or_owner_of_another(via, mock_user_teams):
|
def test_api_documents_update_administrator_or_owner_of_another(via, mock_user_teams):
|
||||||
"""
|
"""
|
||||||
@@ -317,6 +685,7 @@ def test_api_documents_update_administrator_or_owner_of_another(via, mock_user_t
|
|||||||
new_document_values = serializers.DocumentSerializer(
|
new_document_values = serializers.DocumentSerializer(
|
||||||
instance=factories.DocumentFactory()
|
instance=factories.DocumentFactory()
|
||||||
).data
|
).data
|
||||||
|
new_document_values["websocket"] = True
|
||||||
response = client.put(
|
response = client.put(
|
||||||
f"/api/v1.0/documents/{other_document.id!s}/",
|
f"/api/v1.0/documents/{other_document.id!s}/",
|
||||||
new_document_values,
|
new_document_values,
|
||||||
|
|||||||
@@ -47,10 +47,10 @@ def test_api_documents_update_new_attachment_keys_anonymous(django_assert_num_qu
|
|||||||
factories.DocumentFactory(attachments=[image_keys[3]], link_reach="restricted")
|
factories.DocumentFactory(attachments=[image_keys[3]], link_reach="restricted")
|
||||||
expected_keys = {image_keys[i] for i in [0, 1]}
|
expected_keys = {image_keys[i] for i in [0, 1]}
|
||||||
|
|
||||||
with django_assert_num_queries(9):
|
with django_assert_num_queries(11):
|
||||||
response = APIClient().put(
|
response = APIClient().put(
|
||||||
f"/api/v1.0/documents/{document.id!s}/",
|
f"/api/v1.0/documents/{document.id!s}/",
|
||||||
{"content": get_ydoc_with_mages(image_keys)},
|
{"content": get_ydoc_with_mages(image_keys), "websocket": True},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -60,10 +60,10 @@ def test_api_documents_update_new_attachment_keys_anonymous(django_assert_num_qu
|
|||||||
|
|
||||||
# Check that the db query to check attachments readability for extracted
|
# Check that the db query to check attachments readability for extracted
|
||||||
# keys is not done if the content changes but no new keys are found
|
# keys is not done if the content changes but no new keys are found
|
||||||
with django_assert_num_queries(5):
|
with django_assert_num_queries(7):
|
||||||
response = APIClient().put(
|
response = APIClient().put(
|
||||||
f"/api/v1.0/documents/{document.id!s}/",
|
f"/api/v1.0/documents/{document.id!s}/",
|
||||||
{"content": get_ydoc_with_mages(image_keys[:2])},
|
{"content": get_ydoc_with_mages(image_keys[:2]), "websocket": True},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -98,7 +98,7 @@ def test_api_documents_update_new_attachment_keys_authenticated(
|
|||||||
factories.DocumentFactory(attachments=[image_keys[4]], users=[user])
|
factories.DocumentFactory(attachments=[image_keys[4]], users=[user])
|
||||||
expected_keys = {image_keys[i] for i in [0, 1, 2, 4]}
|
expected_keys = {image_keys[i] for i in [0, 1, 2, 4]}
|
||||||
|
|
||||||
with django_assert_num_queries(10):
|
with django_assert_num_queries(12):
|
||||||
response = client.put(
|
response = client.put(
|
||||||
f"/api/v1.0/documents/{document.id!s}/",
|
f"/api/v1.0/documents/{document.id!s}/",
|
||||||
{"content": get_ydoc_with_mages(image_keys)},
|
{"content": get_ydoc_with_mages(image_keys)},
|
||||||
@@ -111,7 +111,7 @@ def test_api_documents_update_new_attachment_keys_authenticated(
|
|||||||
|
|
||||||
# Check that the db query to check attachments readability for extracted
|
# Check that the db query to check attachments readability for extracted
|
||||||
# keys is not done if the content changes but no new keys are found
|
# keys is not done if the content changes but no new keys are found
|
||||||
with django_assert_num_queries(6):
|
with django_assert_num_queries(8):
|
||||||
response = client.put(
|
response = client.put(
|
||||||
f"/api/v1.0/documents/{document.id!s}/",
|
f"/api/v1.0/documents/{document.id!s}/",
|
||||||
{"content": get_ydoc_with_mages(image_keys[:2])},
|
{"content": get_ydoc_with_mages(image_keys[:2])},
|
||||||
|
|||||||
@@ -48,12 +48,7 @@ def test_api_template_accesses_list_authenticated_unrelated():
|
|||||||
f"/api/v1.0/templates/{template.id!s}/accesses/",
|
f"/api/v1.0/templates/{template.id!s}/accesses/",
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == {
|
assert response.json() == []
|
||||||
"count": 0,
|
|
||||||
"next": None,
|
|
||||||
"previous": None,
|
|
||||||
"results": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("via", VIA)
|
@pytest.mark.parametrize("via", VIA)
|
||||||
@@ -96,8 +91,8 @@ def test_api_template_accesses_list_authenticated_related(via, mock_user_teams):
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
content = response.json()
|
content = response.json()
|
||||||
assert len(content["results"]) == 3
|
assert len(content) == 3
|
||||||
assert sorted(content["results"], key=lambda x: x["id"]) == sorted(
|
assert sorted(content, key=lambda x: x["id"]) == sorted(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"id": str(user_access.id),
|
"id": str(user_access.id),
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ def test_api_template_accesses_create_authenticated_administrator(via, mock_user
|
|||||||
|
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"detail": "Only owners of a resource can assign other users as owners."
|
"detail": "Only owners of a template can assign other users as owners."
|
||||||
}
|
}
|
||||||
|
|
||||||
# It should be allowed to create a lower access
|
# It should be allowed to create a lower access
|
||||||
|
|||||||
@@ -62,6 +62,25 @@ def test_api_config(is_authenticated):
|
|||||||
"AI_FEATURE_ENABLED": False,
|
"AI_FEATURE_ENABLED": False,
|
||||||
"theme_customization": {},
|
"theme_customization": {},
|
||||||
}
|
}
|
||||||
|
policy_list = sorted(response.headers["Content-Security-Policy"].split("; "))
|
||||||
|
assert policy_list == [
|
||||||
|
"base-uri 'none'",
|
||||||
|
"child-src 'none'",
|
||||||
|
"connect-src 'none'",
|
||||||
|
"default-src 'none'",
|
||||||
|
"font-src 'none'",
|
||||||
|
"form-action 'none'",
|
||||||
|
"frame-ancestors 'none'",
|
||||||
|
"frame-src 'none'",
|
||||||
|
"img-src 'none'",
|
||||||
|
"manifest-src 'none'",
|
||||||
|
"media-src 'none'",
|
||||||
|
"object-src 'none'",
|
||||||
|
"prefetch-src 'none'",
|
||||||
|
"script-src 'none'",
|
||||||
|
"style-src 'none'",
|
||||||
|
"worker-src 'none'",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@override_settings(
|
@override_settings(
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ def test_api_users_list_query_short_queries():
|
|||||||
"""
|
"""
|
||||||
Queries shorter than 5 characters should return an empty result set.
|
Queries shorter than 5 characters should return an empty result set.
|
||||||
"""
|
"""
|
||||||
user = factories.UserFactory()
|
user = factories.UserFactory(email="paul@example.com")
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
|
|
||||||
|
|||||||
@@ -123,16 +123,22 @@ def test_models_document_access_get_abilities_for_owner_of_self_allowed():
|
|||||||
"retrieve": True,
|
"retrieve": True,
|
||||||
"update": True,
|
"update": True,
|
||||||
"partial_update": True,
|
"partial_update": True,
|
||||||
"set_role_to": ["administrator", "editor", "reader"],
|
"set_role_to": ["reader", "editor", "administrator", "owner"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_models_document_access_get_abilities_for_owner_of_self_last():
|
def test_models_document_access_get_abilities_for_owner_of_self_last_on_root(
|
||||||
|
django_assert_num_queries,
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Check abilities of self access for the owner of a document when there is only one owner left.
|
Check abilities of self access for the owner of a root document when there
|
||||||
|
is only one owner left.
|
||||||
"""
|
"""
|
||||||
access = factories.UserDocumentAccessFactory(role="owner")
|
access = factories.UserDocumentAccessFactory(role="owner")
|
||||||
abilities = access.get_abilities(access.user)
|
|
||||||
|
with django_assert_num_queries(2):
|
||||||
|
abilities = access.get_abilities(access.user)
|
||||||
|
|
||||||
assert abilities == {
|
assert abilities == {
|
||||||
"destroy": False,
|
"destroy": False,
|
||||||
"retrieve": True,
|
"retrieve": True,
|
||||||
@@ -142,6 +148,28 @@ def test_models_document_access_get_abilities_for_owner_of_self_last():
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_models_document_access_get_abilities_for_owner_of_self_last_on_child(
|
||||||
|
django_assert_num_queries,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Check abilities of self access for the owner of a child document when there
|
||||||
|
is only one owner left.
|
||||||
|
"""
|
||||||
|
parent = factories.DocumentFactory()
|
||||||
|
access = factories.UserDocumentAccessFactory(document__parent=parent, role="owner")
|
||||||
|
|
||||||
|
with django_assert_num_queries(1):
|
||||||
|
abilities = access.get_abilities(access.user)
|
||||||
|
|
||||||
|
assert abilities == {
|
||||||
|
"destroy": True,
|
||||||
|
"retrieve": True,
|
||||||
|
"update": True,
|
||||||
|
"partial_update": True,
|
||||||
|
"set_role_to": ["reader", "editor", "administrator", "owner"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_models_document_access_get_abilities_for_owner_of_owner():
|
def test_models_document_access_get_abilities_for_owner_of_owner():
|
||||||
"""Check abilities of owner access for the owner of a document."""
|
"""Check abilities of owner access for the owner of a document."""
|
||||||
access = factories.UserDocumentAccessFactory(role="owner")
|
access = factories.UserDocumentAccessFactory(role="owner")
|
||||||
@@ -155,7 +183,7 @@ def test_models_document_access_get_abilities_for_owner_of_owner():
|
|||||||
"retrieve": True,
|
"retrieve": True,
|
||||||
"update": True,
|
"update": True,
|
||||||
"partial_update": True,
|
"partial_update": True,
|
||||||
"set_role_to": ["administrator", "editor", "reader"],
|
"set_role_to": ["reader", "editor", "administrator", "owner"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -172,7 +200,7 @@ def test_models_document_access_get_abilities_for_owner_of_administrator():
|
|||||||
"retrieve": True,
|
"retrieve": True,
|
||||||
"update": True,
|
"update": True,
|
||||||
"partial_update": True,
|
"partial_update": True,
|
||||||
"set_role_to": ["owner", "editor", "reader"],
|
"set_role_to": ["reader", "editor", "administrator", "owner"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -189,7 +217,7 @@ def test_models_document_access_get_abilities_for_owner_of_editor():
|
|||||||
"retrieve": True,
|
"retrieve": True,
|
||||||
"update": True,
|
"update": True,
|
||||||
"partial_update": True,
|
"partial_update": True,
|
||||||
"set_role_to": ["owner", "administrator", "reader"],
|
"set_role_to": ["reader", "editor", "administrator", "owner"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -206,7 +234,7 @@ def test_models_document_access_get_abilities_for_owner_of_reader():
|
|||||||
"retrieve": True,
|
"retrieve": True,
|
||||||
"update": True,
|
"update": True,
|
||||||
"partial_update": True,
|
"partial_update": True,
|
||||||
"set_role_to": ["owner", "administrator", "editor"],
|
"set_role_to": ["reader", "editor", "administrator", "owner"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -243,7 +271,7 @@ def test_models_document_access_get_abilities_for_administrator_of_administrator
|
|||||||
"retrieve": True,
|
"retrieve": True,
|
||||||
"update": True,
|
"update": True,
|
||||||
"partial_update": True,
|
"partial_update": True,
|
||||||
"set_role_to": ["editor", "reader"],
|
"set_role_to": ["reader", "editor", "administrator"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -260,7 +288,7 @@ def test_models_document_access_get_abilities_for_administrator_of_editor():
|
|||||||
"retrieve": True,
|
"retrieve": True,
|
||||||
"update": True,
|
"update": True,
|
||||||
"partial_update": True,
|
"partial_update": True,
|
||||||
"set_role_to": ["administrator", "reader"],
|
"set_role_to": ["reader", "editor", "administrator"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -277,7 +305,7 @@ def test_models_document_access_get_abilities_for_administrator_of_reader():
|
|||||||
"retrieve": True,
|
"retrieve": True,
|
||||||
"update": True,
|
"update": True,
|
||||||
"partial_update": True,
|
"partial_update": True,
|
||||||
"set_role_to": ["administrator", "editor"],
|
"set_role_to": ["reader", "editor", "administrator"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -400,12 +428,12 @@ def test_models_document_access_get_abilities_for_reader_of_reader_user(
|
|||||||
|
|
||||||
|
|
||||||
def test_models_document_access_get_abilities_preset_role(django_assert_num_queries):
|
def test_models_document_access_get_abilities_preset_role(django_assert_num_queries):
|
||||||
"""No query is done if the role is preset, e.g., with a query annotation."""
|
"""No query is done if user roles are preset on the document, e.g., with a query annotation."""
|
||||||
access = factories.UserDocumentAccessFactory(role="reader")
|
access = factories.UserDocumentAccessFactory(role="reader")
|
||||||
user = factories.UserDocumentAccessFactory(
|
user = factories.UserDocumentAccessFactory(
|
||||||
document=access.document, role="reader"
|
document=access.document, role="reader"
|
||||||
).user
|
).user
|
||||||
access.user_roles = ["reader"]
|
access.set_user_roles_tuple(None, "reader")
|
||||||
|
|
||||||
with django_assert_num_queries(0):
|
with django_assert_num_queries(0):
|
||||||
abilities = access.get_abilities(user)
|
abilities = access.get_abilities(user)
|
||||||
|
|||||||
@@ -155,15 +155,18 @@ def test_models_documents_get_abilities_forbidden(
|
|||||||
"ai_transform": False,
|
"ai_transform": False,
|
||||||
"ai_translate": False,
|
"ai_translate": False,
|
||||||
"attachment_upload": False,
|
"attachment_upload": False,
|
||||||
|
"can_edit": False,
|
||||||
"children_create": False,
|
"children_create": False,
|
||||||
"children_list": False,
|
"children_list": False,
|
||||||
"collaboration_auth": False,
|
"collaboration_auth": False,
|
||||||
"descendants": False,
|
"descendants": False,
|
||||||
"cors_proxy": False,
|
"cors_proxy": False,
|
||||||
|
"content": False,
|
||||||
"destroy": False,
|
"destroy": False,
|
||||||
"duplicate": False,
|
"duplicate": False,
|
||||||
"favorite": False,
|
"favorite": False,
|
||||||
"invite_owner": False,
|
"invite_owner": False,
|
||||||
|
"mask": False,
|
||||||
"media_auth": False,
|
"media_auth": False,
|
||||||
"media_check": False,
|
"media_check": False,
|
||||||
"move": False,
|
"move": False,
|
||||||
@@ -171,7 +174,7 @@ def test_models_documents_get_abilities_forbidden(
|
|||||||
"link_select_options": {
|
"link_select_options": {
|
||||||
"authenticated": ["reader", "editor"],
|
"authenticated": ["reader", "editor"],
|
||||||
"public": ["reader", "editor"],
|
"public": ["reader", "editor"],
|
||||||
"restricted": ["reader", "editor"],
|
"restricted": None,
|
||||||
},
|
},
|
||||||
"partial_update": False,
|
"partial_update": False,
|
||||||
"restore": False,
|
"restore": False,
|
||||||
@@ -216,21 +219,24 @@ def test_models_documents_get_abilities_reader(
|
|||||||
"ai_transform": False,
|
"ai_transform": False,
|
||||||
"ai_translate": False,
|
"ai_translate": False,
|
||||||
"attachment_upload": False,
|
"attachment_upload": False,
|
||||||
|
"can_edit": False,
|
||||||
"children_create": False,
|
"children_create": False,
|
||||||
"children_list": True,
|
"children_list": True,
|
||||||
"collaboration_auth": True,
|
"collaboration_auth": True,
|
||||||
"descendants": True,
|
"descendants": True,
|
||||||
"cors_proxy": True,
|
"cors_proxy": True,
|
||||||
|
"content": True,
|
||||||
"destroy": False,
|
"destroy": False,
|
||||||
"duplicate": True,
|
"duplicate": is_authenticated,
|
||||||
"favorite": is_authenticated,
|
"favorite": is_authenticated,
|
||||||
"invite_owner": False,
|
"invite_owner": False,
|
||||||
"link_configuration": False,
|
"link_configuration": False,
|
||||||
"link_select_options": {
|
"link_select_options": {
|
||||||
"authenticated": ["reader", "editor"],
|
"authenticated": ["reader", "editor"],
|
||||||
"public": ["reader", "editor"],
|
"public": ["reader", "editor"],
|
||||||
"restricted": ["reader", "editor"],
|
"restricted": None,
|
||||||
},
|
},
|
||||||
|
"mask": is_authenticated,
|
||||||
"media_auth": True,
|
"media_auth": True,
|
||||||
"media_check": True,
|
"media_check": True,
|
||||||
"move": False,
|
"move": False,
|
||||||
@@ -252,7 +258,7 @@ def test_models_documents_get_abilities_reader(
|
|||||||
assert all(
|
assert all(
|
||||||
value is False
|
value is False
|
||||||
for key, value in document.get_abilities(user).items()
|
for key, value in document.get_abilities(user).items()
|
||||||
if key != "link_select_options"
|
if key not in ["link_select_options", "ancestors_links_definition"]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -279,21 +285,24 @@ def test_models_documents_get_abilities_editor(
|
|||||||
"ai_transform": is_authenticated,
|
"ai_transform": is_authenticated,
|
||||||
"ai_translate": is_authenticated,
|
"ai_translate": is_authenticated,
|
||||||
"attachment_upload": True,
|
"attachment_upload": True,
|
||||||
|
"can_edit": True,
|
||||||
"children_create": is_authenticated,
|
"children_create": is_authenticated,
|
||||||
"children_list": True,
|
"children_list": True,
|
||||||
"collaboration_auth": True,
|
"collaboration_auth": True,
|
||||||
"descendants": True,
|
"descendants": True,
|
||||||
"cors_proxy": True,
|
"cors_proxy": True,
|
||||||
|
"content": True,
|
||||||
"destroy": False,
|
"destroy": False,
|
||||||
"duplicate": True,
|
"duplicate": is_authenticated,
|
||||||
"favorite": is_authenticated,
|
"favorite": is_authenticated,
|
||||||
"invite_owner": False,
|
"invite_owner": False,
|
||||||
"link_configuration": False,
|
"link_configuration": False,
|
||||||
"link_select_options": {
|
"link_select_options": {
|
||||||
"authenticated": ["reader", "editor"],
|
"authenticated": ["reader", "editor"],
|
||||||
"public": ["reader", "editor"],
|
"public": ["reader", "editor"],
|
||||||
"restricted": ["reader", "editor"],
|
"restricted": None,
|
||||||
},
|
},
|
||||||
|
"mask": is_authenticated,
|
||||||
"media_auth": True,
|
"media_auth": True,
|
||||||
"media_check": True,
|
"media_check": True,
|
||||||
"move": False,
|
"move": False,
|
||||||
@@ -314,7 +323,7 @@ def test_models_documents_get_abilities_editor(
|
|||||||
assert all(
|
assert all(
|
||||||
value is False
|
value is False
|
||||||
for key, value in document.get_abilities(user).items()
|
for key, value in document.get_abilities(user).items()
|
||||||
if key != "link_select_options"
|
if key not in ["link_select_options", "ancestors_links_definition"]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -331,11 +340,13 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
|
|||||||
"ai_transform": True,
|
"ai_transform": True,
|
||||||
"ai_translate": True,
|
"ai_translate": True,
|
||||||
"attachment_upload": True,
|
"attachment_upload": True,
|
||||||
|
"can_edit": True,
|
||||||
"children_create": True,
|
"children_create": True,
|
||||||
"children_list": True,
|
"children_list": True,
|
||||||
"collaboration_auth": True,
|
"collaboration_auth": True,
|
||||||
"descendants": True,
|
"descendants": True,
|
||||||
"cors_proxy": True,
|
"cors_proxy": True,
|
||||||
|
"content": True,
|
||||||
"destroy": True,
|
"destroy": True,
|
||||||
"duplicate": True,
|
"duplicate": True,
|
||||||
"favorite": True,
|
"favorite": True,
|
||||||
@@ -344,8 +355,9 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
|
|||||||
"link_select_options": {
|
"link_select_options": {
|
||||||
"authenticated": ["reader", "editor"],
|
"authenticated": ["reader", "editor"],
|
||||||
"public": ["reader", "editor"],
|
"public": ["reader", "editor"],
|
||||||
"restricted": ["reader", "editor"],
|
"restricted": None,
|
||||||
},
|
},
|
||||||
|
"mask": True,
|
||||||
"media_auth": True,
|
"media_auth": True,
|
||||||
"media_check": True,
|
"media_check": True,
|
||||||
"move": True,
|
"move": True,
|
||||||
@@ -380,11 +392,13 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries)
|
|||||||
"ai_transform": True,
|
"ai_transform": True,
|
||||||
"ai_translate": True,
|
"ai_translate": True,
|
||||||
"attachment_upload": True,
|
"attachment_upload": True,
|
||||||
|
"can_edit": True,
|
||||||
"children_create": True,
|
"children_create": True,
|
||||||
"children_list": True,
|
"children_list": True,
|
||||||
"collaboration_auth": True,
|
"collaboration_auth": True,
|
||||||
"descendants": True,
|
"descendants": True,
|
||||||
"cors_proxy": True,
|
"cors_proxy": True,
|
||||||
|
"content": True,
|
||||||
"destroy": False,
|
"destroy": False,
|
||||||
"duplicate": True,
|
"duplicate": True,
|
||||||
"favorite": True,
|
"favorite": True,
|
||||||
@@ -393,8 +407,9 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries)
|
|||||||
"link_select_options": {
|
"link_select_options": {
|
||||||
"authenticated": ["reader", "editor"],
|
"authenticated": ["reader", "editor"],
|
||||||
"public": ["reader", "editor"],
|
"public": ["reader", "editor"],
|
||||||
"restricted": ["reader", "editor"],
|
"restricted": None,
|
||||||
},
|
},
|
||||||
|
"mask": True,
|
||||||
"media_auth": True,
|
"media_auth": True,
|
||||||
"media_check": True,
|
"media_check": True,
|
||||||
"move": True,
|
"move": True,
|
||||||
@@ -415,7 +430,7 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries)
|
|||||||
assert all(
|
assert all(
|
||||||
value is False
|
value is False
|
||||||
for key, value in document.get_abilities(user).items()
|
for key, value in document.get_abilities(user).items()
|
||||||
if key != "link_select_options"
|
if key not in ["link_select_options", "ancestors_links_definition"]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -432,11 +447,13 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
|
|||||||
"ai_transform": True,
|
"ai_transform": True,
|
||||||
"ai_translate": True,
|
"ai_translate": True,
|
||||||
"attachment_upload": True,
|
"attachment_upload": True,
|
||||||
|
"can_edit": True,
|
||||||
"children_create": True,
|
"children_create": True,
|
||||||
"children_list": True,
|
"children_list": True,
|
||||||
"collaboration_auth": True,
|
"collaboration_auth": True,
|
||||||
"descendants": True,
|
"descendants": True,
|
||||||
"cors_proxy": True,
|
"cors_proxy": True,
|
||||||
|
"content": True,
|
||||||
"destroy": False,
|
"destroy": False,
|
||||||
"duplicate": True,
|
"duplicate": True,
|
||||||
"favorite": True,
|
"favorite": True,
|
||||||
@@ -445,8 +462,9 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
|
|||||||
"link_select_options": {
|
"link_select_options": {
|
||||||
"authenticated": ["reader", "editor"],
|
"authenticated": ["reader", "editor"],
|
||||||
"public": ["reader", "editor"],
|
"public": ["reader", "editor"],
|
||||||
"restricted": ["reader", "editor"],
|
"restricted": None,
|
||||||
},
|
},
|
||||||
|
"mask": True,
|
||||||
"media_auth": True,
|
"media_auth": True,
|
||||||
"media_check": True,
|
"media_check": True,
|
||||||
"move": False,
|
"move": False,
|
||||||
@@ -467,7 +485,7 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
|
|||||||
assert all(
|
assert all(
|
||||||
value is False
|
value is False
|
||||||
for key, value in document.get_abilities(user).items()
|
for key, value in document.get_abilities(user).items()
|
||||||
if key != "link_select_options"
|
if key not in ["link_select_options", "ancestors_links_definition"]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -491,11 +509,13 @@ def test_models_documents_get_abilities_reader_user(
|
|||||||
"ai_transform": access_from_link and ai_access_setting != "restricted",
|
"ai_transform": access_from_link and ai_access_setting != "restricted",
|
||||||
"ai_translate": access_from_link and ai_access_setting != "restricted",
|
"ai_translate": access_from_link and ai_access_setting != "restricted",
|
||||||
"attachment_upload": access_from_link,
|
"attachment_upload": access_from_link,
|
||||||
|
"can_edit": access_from_link,
|
||||||
"children_create": access_from_link,
|
"children_create": access_from_link,
|
||||||
"children_list": True,
|
"children_list": True,
|
||||||
"collaboration_auth": True,
|
"collaboration_auth": True,
|
||||||
"descendants": True,
|
"descendants": True,
|
||||||
"cors_proxy": True,
|
"cors_proxy": True,
|
||||||
|
"content": True,
|
||||||
"destroy": False,
|
"destroy": False,
|
||||||
"duplicate": True,
|
"duplicate": True,
|
||||||
"favorite": True,
|
"favorite": True,
|
||||||
@@ -504,8 +524,9 @@ def test_models_documents_get_abilities_reader_user(
|
|||||||
"link_select_options": {
|
"link_select_options": {
|
||||||
"authenticated": ["reader", "editor"],
|
"authenticated": ["reader", "editor"],
|
||||||
"public": ["reader", "editor"],
|
"public": ["reader", "editor"],
|
||||||
"restricted": ["reader", "editor"],
|
"restricted": None,
|
||||||
},
|
},
|
||||||
|
"mask": True,
|
||||||
"media_auth": True,
|
"media_auth": True,
|
||||||
"media_check": True,
|
"media_check": True,
|
||||||
"move": False,
|
"move": False,
|
||||||
@@ -528,7 +549,7 @@ def test_models_documents_get_abilities_reader_user(
|
|||||||
assert all(
|
assert all(
|
||||||
value is False
|
value is False
|
||||||
for key, value in document.get_abilities(user).items()
|
for key, value in document.get_abilities(user).items()
|
||||||
if key != "link_select_options"
|
if key not in ["link_select_options", "ancestors_links_definition"]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -548,11 +569,13 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
|
|||||||
"ai_transform": False,
|
"ai_transform": False,
|
||||||
"ai_translate": False,
|
"ai_translate": False,
|
||||||
"attachment_upload": False,
|
"attachment_upload": False,
|
||||||
|
"can_edit": False,
|
||||||
"children_create": False,
|
"children_create": False,
|
||||||
"children_list": True,
|
"children_list": True,
|
||||||
"collaboration_auth": True,
|
"collaboration_auth": True,
|
||||||
"descendants": True,
|
"descendants": True,
|
||||||
"cors_proxy": True,
|
"cors_proxy": True,
|
||||||
|
"content": True,
|
||||||
"destroy": False,
|
"destroy": False,
|
||||||
"duplicate": True,
|
"duplicate": True,
|
||||||
"favorite": True,
|
"favorite": True,
|
||||||
@@ -561,8 +584,9 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
|
|||||||
"link_select_options": {
|
"link_select_options": {
|
||||||
"authenticated": ["reader", "editor"],
|
"authenticated": ["reader", "editor"],
|
||||||
"public": ["reader", "editor"],
|
"public": ["reader", "editor"],
|
||||||
"restricted": ["reader", "editor"],
|
"restricted": None,
|
||||||
},
|
},
|
||||||
|
"mask": True,
|
||||||
"media_auth": True,
|
"media_auth": True,
|
||||||
"media_check": True,
|
"media_check": True,
|
||||||
"move": False,
|
"move": False,
|
||||||
@@ -1064,7 +1088,7 @@ def test_models_documents_restore(django_assert_num_queries):
|
|||||||
assert document.deleted_at is not None
|
assert document.deleted_at is not None
|
||||||
assert document.ancestors_deleted_at == document.deleted_at
|
assert document.ancestors_deleted_at == document.deleted_at
|
||||||
|
|
||||||
with django_assert_num_queries(8):
|
with django_assert_num_queries(10):
|
||||||
document.restore()
|
document.restore()
|
||||||
document.refresh_from_db()
|
document.refresh_from_db()
|
||||||
assert document.deleted_at is None
|
assert document.deleted_at is None
|
||||||
@@ -1107,7 +1131,7 @@ def test_models_documents_restore_complex(django_assert_num_queries):
|
|||||||
assert child2.ancestors_deleted_at == document.deleted_at
|
assert child2.ancestors_deleted_at == document.deleted_at
|
||||||
|
|
||||||
# Restore the item
|
# Restore the item
|
||||||
with django_assert_num_queries(11):
|
with django_assert_num_queries(13):
|
||||||
document.restore()
|
document.restore()
|
||||||
document.refresh_from_db()
|
document.refresh_from_db()
|
||||||
child1.refresh_from_db()
|
child1.refresh_from_db()
|
||||||
@@ -1157,7 +1181,7 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries):
|
|||||||
|
|
||||||
# Restoring the grand parent should not restore the document
|
# Restoring the grand parent should not restore the document
|
||||||
# as it was deleted before the grand parent
|
# as it was deleted before the grand parent
|
||||||
with django_assert_num_queries(9):
|
with django_assert_num_queries(11):
|
||||||
grand_parent.restore()
|
grand_parent.restore()
|
||||||
|
|
||||||
grand_parent.refresh_from_db()
|
grand_parent.refresh_from_db()
|
||||||
@@ -1176,184 +1200,134 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"ancestors_links, select_options",
|
"reach, role, select_options",
|
||||||
[
|
[
|
||||||
# One ancestor
|
|
||||||
(
|
(
|
||||||
[{"link_reach": "public", "link_role": "reader"}],
|
"public",
|
||||||
|
"reader",
|
||||||
{
|
{
|
||||||
"restricted": ["editor"],
|
"public": ["reader", "editor"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
("public", "editor", {"public": ["editor"]}),
|
||||||
|
(
|
||||||
|
"authenticated",
|
||||||
|
"reader",
|
||||||
|
{
|
||||||
|
"authenticated": ["reader", "editor"],
|
||||||
|
"public": ["reader", "editor"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"authenticated",
|
||||||
|
"editor",
|
||||||
|
{"authenticated": ["editor"], "public": ["editor"]},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"restricted",
|
||||||
|
"reader",
|
||||||
|
{
|
||||||
|
"restricted": None,
|
||||||
|
"authenticated": ["reader", "editor"],
|
||||||
|
"public": ["reader", "editor"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"restricted",
|
||||||
|
"editor",
|
||||||
|
{
|
||||||
|
"restricted": None,
|
||||||
"authenticated": ["editor"],
|
"authenticated": ["editor"],
|
||||||
"public": ["reader", "editor"],
|
"public": ["editor"],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
([{"link_reach": "public", "link_role": "editor"}], {"public": ["editor"]}),
|
# Edge cases
|
||||||
(
|
(
|
||||||
[{"link_reach": "authenticated", "link_role": "reader"}],
|
"public",
|
||||||
|
None,
|
||||||
{
|
{
|
||||||
"restricted": ["editor"],
|
|
||||||
"authenticated": ["reader", "editor"],
|
|
||||||
"public": ["reader", "editor"],
|
"public": ["reader", "editor"],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
[{"link_reach": "authenticated", "link_role": "editor"}],
|
None,
|
||||||
{"authenticated": ["editor"], "public": ["reader", "editor"]},
|
"reader",
|
||||||
),
|
|
||||||
(
|
|
||||||
[{"link_reach": "restricted", "link_role": "reader"}],
|
|
||||||
{
|
|
||||||
"restricted": ["reader", "editor"],
|
|
||||||
"authenticated": ["reader", "editor"],
|
|
||||||
"public": ["reader", "editor"],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
(
|
|
||||||
[{"link_reach": "restricted", "link_role": "editor"}],
|
|
||||||
{
|
|
||||||
"restricted": ["editor"],
|
|
||||||
"authenticated": ["reader", "editor"],
|
|
||||||
"public": ["reader", "editor"],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
# Multiple ancestors with different roles
|
|
||||||
(
|
|
||||||
[
|
|
||||||
{"link_reach": "public", "link_role": "reader"},
|
|
||||||
{"link_reach": "public", "link_role": "editor"},
|
|
||||||
],
|
|
||||||
{"public": ["editor"]},
|
|
||||||
),
|
|
||||||
(
|
|
||||||
[
|
|
||||||
{"link_reach": "authenticated", "link_role": "reader"},
|
|
||||||
{"link_reach": "authenticated", "link_role": "editor"},
|
|
||||||
],
|
|
||||||
{"authenticated": ["editor"], "public": ["reader", "editor"]},
|
|
||||||
),
|
|
||||||
(
|
|
||||||
[
|
|
||||||
{"link_reach": "restricted", "link_role": "reader"},
|
|
||||||
{"link_reach": "restricted", "link_role": "editor"},
|
|
||||||
],
|
|
||||||
{
|
|
||||||
"restricted": ["editor"],
|
|
||||||
"authenticated": ["reader", "editor"],
|
|
||||||
"public": ["reader", "editor"],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
# Multiple ancestors with different reaches
|
|
||||||
(
|
|
||||||
[
|
|
||||||
{"link_reach": "authenticated", "link_role": "reader"},
|
|
||||||
{"link_reach": "public", "link_role": "reader"},
|
|
||||||
],
|
|
||||||
{
|
|
||||||
"restricted": ["editor"],
|
|
||||||
"authenticated": ["editor"],
|
|
||||||
"public": ["reader", "editor"],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
(
|
|
||||||
[
|
|
||||||
{"link_reach": "restricted", "link_role": "reader"},
|
|
||||||
{"link_reach": "authenticated", "link_role": "reader"},
|
|
||||||
{"link_reach": "public", "link_role": "reader"},
|
|
||||||
],
|
|
||||||
{
|
|
||||||
"restricted": ["editor"],
|
|
||||||
"authenticated": ["editor"],
|
|
||||||
"public": ["reader", "editor"],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
# Multiple ancestors with mixed reaches and roles
|
|
||||||
(
|
|
||||||
[
|
|
||||||
{"link_reach": "authenticated", "link_role": "editor"},
|
|
||||||
{"link_reach": "public", "link_role": "reader"},
|
|
||||||
],
|
|
||||||
{"authenticated": ["editor"], "public": ["reader", "editor"]},
|
|
||||||
),
|
|
||||||
(
|
|
||||||
[
|
|
||||||
{"link_reach": "authenticated", "link_role": "reader"},
|
|
||||||
{"link_reach": "public", "link_role": "editor"},
|
|
||||||
],
|
|
||||||
{"public": ["editor"]},
|
|
||||||
),
|
|
||||||
(
|
|
||||||
[
|
|
||||||
{"link_reach": "restricted", "link_role": "editor"},
|
|
||||||
{"link_reach": "authenticated", "link_role": "reader"},
|
|
||||||
],
|
|
||||||
{
|
|
||||||
"restricted": ["editor"],
|
|
||||||
"authenticated": ["reader", "editor"],
|
|
||||||
"public": ["reader", "editor"],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
(
|
|
||||||
[
|
|
||||||
{"link_reach": "restricted", "link_role": "reader"},
|
|
||||||
{"link_reach": "authenticated", "link_role": "editor"},
|
|
||||||
],
|
|
||||||
{"authenticated": ["editor"], "public": ["reader", "editor"]},
|
|
||||||
),
|
|
||||||
# No ancestors (edge case)
|
|
||||||
(
|
|
||||||
[],
|
|
||||||
{
|
{
|
||||||
"public": ["reader", "editor"],
|
"public": ["reader", "editor"],
|
||||||
"authenticated": ["reader", "editor"],
|
"authenticated": ["reader", "editor"],
|
||||||
"restricted": ["reader", "editor"],
|
"restricted": None,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
{
|
||||||
|
"public": ["reader", "editor"],
|
||||||
|
"authenticated": ["reader", "editor"],
|
||||||
|
"restricted": None,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_models_documents_get_select_options(ancestors_links, select_options):
|
def test_models_documents_get_select_options(reach, role, select_options):
|
||||||
"""Validate that the "get_select_options" method operates as expected."""
|
"""Validate that the "get_select_options" method operates as expected."""
|
||||||
assert models.LinkReachChoices.get_select_options(ancestors_links) == select_options
|
assert models.LinkReachChoices.get_select_options(reach, role) == select_options
|
||||||
|
|
||||||
|
|
||||||
def test_models_documents_compute_ancestors_links_no_highest_readable():
|
def test_models_documents_compute_ancestors_links_paths_mapping_single(
|
||||||
"""Test the compute_ancestors_links method."""
|
|
||||||
document = factories.DocumentFactory(link_reach="public")
|
|
||||||
assert document.compute_ancestors_links(user=AnonymousUser()) == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_models_documents_compute_ancestors_links_highest_readable(
|
|
||||||
django_assert_num_queries,
|
django_assert_num_queries,
|
||||||
):
|
):
|
||||||
"""Test the compute_ancestors_links method."""
|
"""Test the compute_ancestors_links_paths_mapping method on a single document."""
|
||||||
|
document = factories.DocumentFactory(link_reach="public")
|
||||||
|
with django_assert_num_queries(1):
|
||||||
|
assert document.compute_ancestors_links_paths_mapping() == {
|
||||||
|
document.path: [{"link_reach": "public", "link_role": document.link_role}]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_models_documents_compute_ancestors_links_paths_mapping_structure(
|
||||||
|
django_assert_num_queries,
|
||||||
|
):
|
||||||
|
"""Test the compute_ancestors_links_paths_mapping method on a tree of documents."""
|
||||||
user = factories.UserFactory()
|
user = factories.UserFactory()
|
||||||
other_user = factories.UserFactory()
|
other_user = factories.UserFactory()
|
||||||
root = factories.DocumentFactory(
|
|
||||||
link_reach="restricted", link_role="reader", users=[user]
|
|
||||||
)
|
|
||||||
|
|
||||||
factories.DocumentFactory(
|
root = factories.DocumentFactory(link_reach="restricted", users=[user])
|
||||||
parent=root, link_reach="public", link_role="reader", users=[user]
|
document = factories.DocumentFactory(
|
||||||
)
|
|
||||||
child2 = factories.DocumentFactory(
|
|
||||||
parent=root,
|
parent=root,
|
||||||
link_reach="authenticated",
|
link_reach="authenticated",
|
||||||
link_role="editor",
|
link_role="editor",
|
||||||
users=[user, other_user],
|
users=[user, other_user],
|
||||||
)
|
)
|
||||||
child3 = factories.DocumentFactory(
|
sibling = factories.DocumentFactory(parent=root, link_reach="public", users=[user])
|
||||||
parent=child2,
|
child = factories.DocumentFactory(
|
||||||
|
parent=document,
|
||||||
link_reach="authenticated",
|
link_reach="authenticated",
|
||||||
link_role="reader",
|
link_role="reader",
|
||||||
users=[user, other_user],
|
users=[user, other_user],
|
||||||
)
|
)
|
||||||
|
|
||||||
with django_assert_num_queries(2):
|
# Child
|
||||||
assert child3.compute_ancestors_links(user=user) == [
|
with django_assert_num_queries(1):
|
||||||
{"link_reach": root.link_reach, "link_role": root.link_role},
|
assert child.compute_ancestors_links_paths_mapping() == {
|
||||||
{"link_reach": child2.link_reach, "link_role": child2.link_role},
|
root.path: [{"link_reach": "restricted", "link_role": root.link_role}],
|
||||||
]
|
document.path: [
|
||||||
|
{"link_reach": "restricted", "link_role": root.link_role},
|
||||||
|
{"link_reach": document.link_reach, "link_role": document.link_role},
|
||||||
|
],
|
||||||
|
child.path: [
|
||||||
|
{"link_reach": "restricted", "link_role": root.link_role},
|
||||||
|
{"link_reach": document.link_reach, "link_role": document.link_role},
|
||||||
|
{"link_reach": child.link_reach, "link_role": child.link_role},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
with django_assert_num_queries(2):
|
# Sibling
|
||||||
assert child3.compute_ancestors_links(user=other_user) == [
|
with django_assert_num_queries(1):
|
||||||
{"link_reach": child2.link_reach, "link_role": child2.link_role},
|
assert sibling.compute_ancestors_links_paths_mapping() == {
|
||||||
]
|
root.path: [{"link_reach": "restricted", "link_role": root.link_role}],
|
||||||
|
sibling.path: [
|
||||||
|
{"link_reach": "restricted", "link_role": root.link_role},
|
||||||
|
{"link_reach": sibling.link_reach, "link_role": sibling.link_role},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
"""Test converter services."""
|
"""Test y-provider services."""
|
||||||
|
|
||||||
|
from base64 import b64decode
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from core.services.converter_services import (
|
from core.services.converter_services import (
|
||||||
InvalidResponseError,
|
|
||||||
MissingContentError,
|
|
||||||
ServiceUnavailableError,
|
ServiceUnavailableError,
|
||||||
ValidationError,
|
ValidationError,
|
||||||
YdocConverter,
|
YdocConverter,
|
||||||
@@ -18,18 +17,18 @@ def test_auth_header(settings):
|
|||||||
"""Test authentication header generation."""
|
"""Test authentication header generation."""
|
||||||
settings.Y_PROVIDER_API_KEY = "test-key"
|
settings.Y_PROVIDER_API_KEY = "test-key"
|
||||||
converter = YdocConverter()
|
converter = YdocConverter()
|
||||||
assert converter.auth_header == "test-key"
|
assert converter.auth_header == "Bearer test-key"
|
||||||
|
|
||||||
|
|
||||||
def test_convert_markdown_empty_text():
|
def test_convert_empty_text():
|
||||||
"""Should raise ValidationError when text is empty."""
|
"""Should raise ValidationError when text is empty."""
|
||||||
converter = YdocConverter()
|
converter = YdocConverter()
|
||||||
with pytest.raises(ValidationError, match="Input text cannot be empty"):
|
with pytest.raises(ValidationError, match="Input text cannot be empty"):
|
||||||
converter.convert_markdown("")
|
converter.convert("")
|
||||||
|
|
||||||
|
|
||||||
@patch("requests.post")
|
@patch("requests.post")
|
||||||
def test_convert_markdown_service_unavailable(mock_post):
|
def test_convert_service_unavailable(mock_post):
|
||||||
"""Should raise ServiceUnavailableError when service is unavailable."""
|
"""Should raise ServiceUnavailableError when service is unavailable."""
|
||||||
converter = YdocConverter()
|
converter = YdocConverter()
|
||||||
|
|
||||||
@@ -39,11 +38,11 @@ def test_convert_markdown_service_unavailable(mock_post):
|
|||||||
ServiceUnavailableError,
|
ServiceUnavailableError,
|
||||||
match="Failed to connect to conversion service",
|
match="Failed to connect to conversion service",
|
||||||
):
|
):
|
||||||
converter.convert_markdown("test text")
|
converter.convert("test text")
|
||||||
|
|
||||||
|
|
||||||
@patch("requests.post")
|
@patch("requests.post")
|
||||||
def test_convert_markdown_http_error(mock_post):
|
def test_convert_http_error(mock_post):
|
||||||
"""Should raise ServiceUnavailableError when HTTP error occurs."""
|
"""Should raise ServiceUnavailableError when HTTP error occurs."""
|
||||||
converter = YdocConverter()
|
converter = YdocConverter()
|
||||||
|
|
||||||
@@ -55,46 +54,11 @@ def test_convert_markdown_http_error(mock_post):
|
|||||||
ServiceUnavailableError,
|
ServiceUnavailableError,
|
||||||
match="Failed to connect to conversion service",
|
match="Failed to connect to conversion service",
|
||||||
):
|
):
|
||||||
converter.convert_markdown("test text")
|
converter.convert("test text")
|
||||||
|
|
||||||
|
|
||||||
@patch("requests.post")
|
@patch("requests.post")
|
||||||
def test_convert_markdown_invalid_json_response(mock_post):
|
def test_convert_full_integration(mock_post, settings):
|
||||||
"""Should raise InvalidResponseError when response is not valid JSON."""
|
|
||||||
converter = YdocConverter()
|
|
||||||
|
|
||||||
mock_response = MagicMock()
|
|
||||||
mock_response.json.side_effect = ValueError("Invalid JSON")
|
|
||||||
mock_post.return_value = mock_response
|
|
||||||
|
|
||||||
with pytest.raises(
|
|
||||||
InvalidResponseError,
|
|
||||||
match="Could not parse conversion service response",
|
|
||||||
):
|
|
||||||
converter.convert_markdown("test text")
|
|
||||||
|
|
||||||
|
|
||||||
@patch("requests.post")
|
|
||||||
def test_convert_markdown_missing_content_field(mock_post, settings):
|
|
||||||
"""Should raise MissingContentError when response is missing required field."""
|
|
||||||
|
|
||||||
settings.CONVERSION_API_CONTENT_FIELD = "expected_field"
|
|
||||||
|
|
||||||
converter = YdocConverter()
|
|
||||||
|
|
||||||
mock_response = MagicMock()
|
|
||||||
mock_response.json.return_value = {"wrong_field": "content"}
|
|
||||||
mock_post.return_value = mock_response
|
|
||||||
|
|
||||||
with pytest.raises(
|
|
||||||
MissingContentError,
|
|
||||||
match="Response missing required field: expected_field",
|
|
||||||
):
|
|
||||||
converter.convert_markdown("test text")
|
|
||||||
|
|
||||||
|
|
||||||
@patch("requests.post")
|
|
||||||
def test_convert_markdown_full_integration(mock_post, settings):
|
|
||||||
"""Test full integration with all settings."""
|
"""Test full integration with all settings."""
|
||||||
|
|
||||||
settings.Y_PROVIDER_API_BASE_URL = "http://test.com/"
|
settings.Y_PROVIDER_API_BASE_URL = "http://test.com/"
|
||||||
@@ -105,20 +69,22 @@ def test_convert_markdown_full_integration(mock_post, settings):
|
|||||||
|
|
||||||
converter = YdocConverter()
|
converter = YdocConverter()
|
||||||
|
|
||||||
expected_content = {"converted": "content"}
|
expected_content = b"converted content"
|
||||||
mock_response = MagicMock()
|
mock_response = MagicMock()
|
||||||
mock_response.json.return_value = {"content": expected_content}
|
mock_response.content = expected_content
|
||||||
mock_post.return_value = mock_response
|
mock_post.return_value = mock_response
|
||||||
|
|
||||||
result = converter.convert_markdown("test markdown")
|
result = converter.convert("test markdown")
|
||||||
|
|
||||||
|
assert b64decode(result) == expected_content
|
||||||
|
|
||||||
assert result == expected_content
|
|
||||||
mock_post.assert_called_once_with(
|
mock_post.assert_called_once_with(
|
||||||
"http://test.com/conversion-endpoint/",
|
"http://test.com/conversion-endpoint/",
|
||||||
json={"content": "test markdown"},
|
data="test markdown",
|
||||||
headers={
|
headers={
|
||||||
"Authorization": "test-key",
|
"Authorization": "Bearer test-key",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "text/markdown",
|
||||||
|
"Accept": "application/vnd.yjs.doc",
|
||||||
},
|
},
|
||||||
timeout=5,
|
timeout=5,
|
||||||
verify=False,
|
verify=False,
|
||||||
@@ -126,7 +92,42 @@ def test_convert_markdown_full_integration(mock_post, settings):
|
|||||||
|
|
||||||
|
|
||||||
@patch("requests.post")
|
@patch("requests.post")
|
||||||
def test_convert_markdown_timeout(mock_post):
|
def test_convert_full_integration_with_specific_headers(mock_post, settings):
|
||||||
|
"""Test successful conversion with specific content type and accept headers."""
|
||||||
|
settings.Y_PROVIDER_API_BASE_URL = "http://test.com/"
|
||||||
|
settings.Y_PROVIDER_API_KEY = "test-key"
|
||||||
|
settings.CONVERSION_API_ENDPOINT = "conversion-endpoint"
|
||||||
|
settings.CONVERSION_API_TIMEOUT = 5
|
||||||
|
settings.CONVERSION_API_SECURE = False
|
||||||
|
|
||||||
|
converter = YdocConverter()
|
||||||
|
|
||||||
|
expected_response = "# Test Document\n\nThis is test content."
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.text = expected_response
|
||||||
|
mock_response.raise_for_status.return_value = None
|
||||||
|
mock_post.return_value = mock_response
|
||||||
|
|
||||||
|
result = converter.convert(
|
||||||
|
b"test_content", "application/vnd.yjs.doc", "text/markdown"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == expected_response
|
||||||
|
mock_post.assert_called_once_with(
|
||||||
|
"http://test.com/conversion-endpoint/",
|
||||||
|
data=b"test_content",
|
||||||
|
headers={
|
||||||
|
"Authorization": "Bearer test-key",
|
||||||
|
"Content-Type": "application/vnd.yjs.doc",
|
||||||
|
"Accept": "text/markdown",
|
||||||
|
},
|
||||||
|
timeout=5,
|
||||||
|
verify=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@patch("requests.post")
|
||||||
|
def test_convert_timeout(mock_post):
|
||||||
"""Should raise ServiceUnavailableError when request times out."""
|
"""Should raise ServiceUnavailableError when request times out."""
|
||||||
converter = YdocConverter()
|
converter = YdocConverter()
|
||||||
|
|
||||||
@@ -136,12 +137,12 @@ def test_convert_markdown_timeout(mock_post):
|
|||||||
ServiceUnavailableError,
|
ServiceUnavailableError,
|
||||||
match="Failed to connect to conversion service",
|
match="Failed to connect to conversion service",
|
||||||
):
|
):
|
||||||
converter.convert_markdown("test text")
|
converter.convert("test text")
|
||||||
|
|
||||||
|
|
||||||
def test_convert_markdown_none_input():
|
def test_convert_none_input():
|
||||||
"""Should raise ValidationError when input is None."""
|
"""Should raise ValidationError when input is None."""
|
||||||
converter = YdocConverter()
|
converter = YdocConverter()
|
||||||
|
|
||||||
with pytest.raises(ValidationError, match="Input text cannot be empty"):
|
with pytest.raises(ValidationError, match="Input text cannot be empty"):
|
||||||
converter.convert_markdown(None)
|
converter.convert(None)
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ document_related_router.register(
|
|||||||
basename="invitations",
|
basename="invitations",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
document_related_router.register(
|
||||||
|
"ask-for-access",
|
||||||
|
viewsets.DocumentAskForAccessViewSet,
|
||||||
|
basename="ask_for_access",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# - Routes nested under a template
|
# - Routes nested under a template
|
||||||
template_related_router = DefaultRouter()
|
template_related_router = DefaultRouter()
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user