mirror of
https://github.com/suitenumerique/docs.git
synced 2026-05-06 15:12:27 +02:00
Compare commits
108 Commits
sbl-public
...
feat/e2ee-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
226e5c407b | ||
|
|
5e651190fd | ||
|
|
21e2658f61 | ||
|
|
a794bdf34d | ||
|
|
c9d09152fa | ||
|
|
e6403be62e | ||
|
|
ca3502ee4d | ||
|
|
8c5352103a | ||
|
|
3e3ee7e698 | ||
|
|
af1c40995b | ||
|
|
1da0f6600e | ||
|
|
a3fdb206ef | ||
|
|
da4d323144 | ||
|
|
3e45193a7c | ||
|
|
7a55e31a73 | ||
|
|
4baef38cae | ||
|
|
1eba8b77c0 | ||
|
|
579ff98a5a | ||
|
|
fe34b93249 | ||
|
|
205960106b | ||
|
|
d685b541c5 | ||
|
|
834ed4226f | ||
|
|
3f8e105035 | ||
|
|
431bec3970 | ||
|
|
54f2762e79 | ||
|
|
9c438eba06 | ||
|
|
bedb0573b8 | ||
|
|
9d3088d9db | ||
|
|
7cf42e6404 | ||
|
|
9903bd73e2 | ||
|
|
44b38347c4 | ||
|
|
709076067b | ||
|
|
db014cfc6f | ||
|
|
52cd76eb93 | ||
|
|
505b144968 | ||
|
|
009de5299f | ||
|
|
0fddabb354 | ||
|
|
cd25c3a63b | ||
|
|
adb216fbdf | ||
|
|
235c1828e6 | ||
|
|
4588c71e8a | ||
|
|
6b7fc915dd | ||
|
|
c3e83c6612 | ||
|
|
586089c8e4 | ||
|
|
1b5ce3ed10 | ||
|
|
989c70ed57 | ||
|
|
c6ded3f267 | ||
|
|
781f0815a8 | ||
|
|
325c7d9786 | ||
|
|
1083aac920 | ||
|
|
dcfb1115dd | ||
|
|
f64800727a | ||
|
|
65b67a29b1 | ||
|
|
b8bdcbf7ed | ||
|
|
be995fd211 | ||
|
|
dd5b6bd023 | ||
|
|
9345d8deab | ||
|
|
f0cc29e779 | ||
|
|
767710231d | ||
|
|
3480604359 | ||
|
|
2e6c39262d | ||
|
|
feb9f7d4a9 | ||
|
|
b547657efd | ||
|
|
61dbda0bf6 | ||
|
|
548f32bf4e | ||
|
|
dd02b9d940 | ||
|
|
f81db395ef | ||
|
|
668d7cd404 | ||
|
|
f199acf6c2 | ||
|
|
75f71368f4 | ||
|
|
21f5feab3e | ||
|
|
8ec89a8348 | ||
|
|
3b80ac7b4e | ||
|
|
68df717854 | ||
|
|
2f52dddc84 | ||
|
|
b1231cea7c | ||
|
|
f9f32db854 | ||
|
|
0d967aba48 | ||
|
|
5ec58cef99 | ||
|
|
1170bdbfc1 | ||
|
|
e807237dbe | ||
|
|
fa6f3e8b7c | ||
|
|
b1a18b2477 | ||
|
|
7823303d03 | ||
|
|
f84455728b | ||
|
|
5afc825109 | ||
|
|
55fe73d001 | ||
|
|
39b9c8b5a9 | ||
|
|
b56ebf19af | ||
|
|
03d4b2afbe | ||
|
|
2556823a69 | ||
|
|
f28da7c2c2 | ||
|
|
dd2d2862be | ||
|
|
c2387fcb02 | ||
|
|
80fdc72182 | ||
|
|
3636168a77 | ||
|
|
1034545b7c | ||
|
|
8901c6ee33 | ||
|
|
f7d697d9bd | ||
|
|
f9c9e444c9 | ||
|
|
e1d2d9e5c8 | ||
|
|
ab92fc43d6 | ||
|
|
3a3ed0453b | ||
|
|
43a1a76a2f | ||
|
|
62213812ee | ||
|
|
3d2b018927 | ||
|
|
bb0502b49b | ||
|
|
9893558c74 |
3
.github/workflows/docker-hub.yml
vendored
3
.github/workflows/docker-hub.yml
vendored
@@ -146,8 +146,9 @@ jobs:
|
||||
|
||||
notify-argocd:
|
||||
needs:
|
||||
- build-and-push-frontend
|
||||
- build-and-push-backend
|
||||
- build-and-push-frontend
|
||||
- build-and-push-y-provider
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview')
|
||||
steps:
|
||||
|
||||
39
.github/workflows/impress-frontend.yml
vendored
39
.github/workflows/impress-frontend.yml
vendored
@@ -19,6 +19,8 @@ jobs:
|
||||
test-front:
|
||||
needs: install-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -41,6 +43,8 @@ jobs:
|
||||
lint-front:
|
||||
runs-on: ubuntu-latest
|
||||
needs: install-dependencies
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -193,3 +197,38 @@ jobs:
|
||||
strip-hash: "[-_.][a-f0-9]{8,}(?=\\.(?:js|css|html)$)"
|
||||
omit-unchanged: true
|
||||
install-script: "yarn install --frozen-lockfile"
|
||||
|
||||
uikit-theme-checker:
|
||||
runs-on: ubuntu-latest
|
||||
needs: install-dependencies
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22.x"
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: "src/frontend/**/node_modules"
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
fail-on-cache-miss: true
|
||||
|
||||
- name: Build theme
|
||||
run: cd src/frontend/apps/impress && yarn build-theme
|
||||
|
||||
- name: Ensure theme is up to date
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ -n "$(git status --porcelain)" ]]; then
|
||||
echo "Error: build-theme produced git changes (tracked or untracked)."
|
||||
echo "--- git status --porcelain ---"
|
||||
git status --porcelain
|
||||
echo "--- git diff ---"
|
||||
git --no-pager diff
|
||||
exit 1
|
||||
fi
|
||||
|
||||
1
.tool-versions
Normal file
1
.tool-versions
Normal file
@@ -0,0 +1 @@
|
||||
nodejs 22.21.1
|
||||
181
CHANGELOG.md
181
CHANGELOG.md
@@ -6,6 +6,74 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
🐛(frontend) fix broadcast store sync #1846
|
||||
|
||||
## [v4.5.0] - 2026-01-28
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(frontend) integrate configurable Waffle #1795
|
||||
- ✨ Import of documents #1609
|
||||
- 🚨(CI) gives warning if theme not updated #1811
|
||||
- ✨(frontend) Add stat for Crisp #1824
|
||||
- ✨(auth) add silent login #1690
|
||||
- 🔧(project) add DJANGO_EMAIL_URL_APP environment variable #1825
|
||||
|
||||
### Changed
|
||||
|
||||
- ♿(frontend) improve accessibility:
|
||||
- ♿️(frontend) fix subdoc opening and emoji pick focus #1745
|
||||
- ✨(backend) add field for button label in email template #1817
|
||||
|
||||
### Fixed
|
||||
|
||||
- ✅(e2e) fix e2e test for other browsers #1799
|
||||
- 🐛(export) fix export column NaN #1819
|
||||
- 🐛(frontend) add fallback for unsupported Blocknote languages #1810
|
||||
- 🐛(frontend) fix emojipicker closing in tree #1808
|
||||
- 🐛(frontend) display children in favorite #1782
|
||||
- 🐛(frontend) preserve typed text after @ on escape #1833
|
||||
|
||||
### Removed
|
||||
|
||||
- 🔥(project) remove all code related to template #1780
|
||||
|
||||
### Security
|
||||
|
||||
- 🔒️(trivy) fix vulnerability about jaraco.context #1806
|
||||
|
||||
## [v4.4.0] - 2026-01-13
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(backend) add documents/all endpoint with descendants #1553
|
||||
- ✅(export) add PDF regression tests #1762
|
||||
- 📝(docs) Add language configuration documentation #1757
|
||||
- 🔒(helm) Set default security context #1750
|
||||
- ✨(backend) use langfuse to monitor AI actions #1776
|
||||
|
||||
### Changed
|
||||
|
||||
- ♿(frontend) improve accessibility:
|
||||
- ♿(frontend) make html export accessible to screen reader users #1743
|
||||
- ♿(frontend) add missing label and fix Axes errors to improve a11y #1693
|
||||
|
||||
### Fixed
|
||||
|
||||
- ✅(backend) reduce flakiness on backend test #1769
|
||||
- 🐛(frontend) fix clickable main content regression #1773
|
||||
- 🐛(backend) fix TRASHBIN_CUTOFF_DAYS type error #1778
|
||||
- 💄(frontend) fix icon position in callout block #1779
|
||||
|
||||
### Security
|
||||
|
||||
- 🔒️(backend) validate more strictly url used by cors-proxy endpoint #1768
|
||||
- 🔒️(frontend) fix props vulnerability in Interlinking #1792
|
||||
|
||||
## [v4.3.0] - 2026-01-05
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(helm) redirecting system #1697
|
||||
@@ -20,11 +88,10 @@ and this project adheres to
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(frontend) fix tables deletion #1752
|
||||
- 🐛(frontend) fix tables deletion #1739
|
||||
- 🐛(frontend) fix children not display when first resize #1753
|
||||
- 📝(doc) fix publiccode.yml syntax #1770
|
||||
|
||||
## [4.2.0] - 2025-12-17
|
||||
## [v4.2.0] - 2025-12-17
|
||||
|
||||
### Added
|
||||
|
||||
@@ -48,8 +115,7 @@ and this project adheres to
|
||||
- 🐛(frontend) Select text + Go back one page crash the app #1733
|
||||
- 🐛(frontend) fix versioning conflict #1742
|
||||
|
||||
|
||||
## [4.1.0] - 2025-12-09
|
||||
## [v4.1.0] - 2025-12-09
|
||||
|
||||
### Added
|
||||
|
||||
@@ -68,7 +134,7 @@ and this project adheres to
|
||||
- 🐛(nginx) fix / location to handle new static pages #1682
|
||||
- 🐛(frontend) rerendering during resize window #1715
|
||||
|
||||
## [4.0.0] - 2025-12-01
|
||||
## [v4.0.0] - 2025-12-01
|
||||
|
||||
### Added
|
||||
|
||||
@@ -91,7 +157,7 @@ and this project adheres to
|
||||
- 🐛(frontend) preserve left panel width on window resize #1588
|
||||
- 🐛(frontend) prevent duplicate as first character in title #1595
|
||||
|
||||
## [3.10.0] - 2025-11-18
|
||||
## [v3.10.0] - 2025-11-18
|
||||
|
||||
### Added
|
||||
|
||||
@@ -125,7 +191,7 @@ and this project adheres to
|
||||
|
||||
- 🔥(backend) remove api managing templates
|
||||
|
||||
## [3.9.0] - 2025-11-10
|
||||
## [v3.9.0] - 2025-11-10
|
||||
|
||||
### Added
|
||||
|
||||
@@ -151,13 +217,13 @@ and this project adheres to
|
||||
- 🐛(frontend) button new doc UI fix #1557
|
||||
- 🐛(frontend) interlinking UI fix #1557
|
||||
|
||||
## [3.8.2] - 2025-10-17
|
||||
## [v3.8.2] - 2025-10-17
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(service-worker) fix sw registration and page reload logic #1500
|
||||
|
||||
## [3.8.1] - 2025-10-17
|
||||
## [v3.8.1] - 2025-10-17
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -171,7 +237,7 @@ and this project adheres to
|
||||
|
||||
- 🔥(backend) remove treebeard form for the document admin #1470
|
||||
|
||||
## [3.8.0] - 2025-10-14
|
||||
## [v3.8.0] - 2025-10-14
|
||||
|
||||
### Added
|
||||
|
||||
@@ -224,7 +290,7 @@ and this project adheres to
|
||||
|
||||
- 🔥(frontend) remove custom DividerBlock ##1375
|
||||
|
||||
## [3.7.0] - 2025-09-12
|
||||
## [v3.7.0] - 2025-09-12
|
||||
|
||||
### Added
|
||||
|
||||
@@ -256,7 +322,7 @@ and this project adheres to
|
||||
|
||||
- 🐛(frontend) fix callout emoji list #1366
|
||||
|
||||
## [3.6.0] - 2025-09-04
|
||||
## [v3.6.0] - 2025-09-04
|
||||
|
||||
### Added
|
||||
|
||||
@@ -292,7 +358,7 @@ and this project adheres to
|
||||
- 🐛(frontend) fix display bug on homepage #1332
|
||||
- 🐛link role update #1287
|
||||
|
||||
## [3.5.0] - 2025-07-31
|
||||
## [v3.5.0] - 2025-07-31
|
||||
|
||||
### Added
|
||||
|
||||
@@ -320,7 +386,7 @@ and this project adheres to
|
||||
- 🐛(frontend) 401 redirection overridden #1214
|
||||
- 🐛(frontend) include root parent in search #1243
|
||||
|
||||
## [3.4.2] - 2025-07-18
|
||||
## [v3.4.2] - 2025-07-18
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -330,7 +396,7 @@ and this project adheres to
|
||||
|
||||
- 🐛(backend) improve prompt to not use code blocks delimiter #1188
|
||||
|
||||
## [3.4.1] - 2025-07-15
|
||||
## [v3.4.1] - 2025-07-15
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -341,7 +407,7 @@ and this project adheres to
|
||||
- 🐛(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
|
||||
## [v3.4.0] - 2025-07-09
|
||||
|
||||
### Added
|
||||
|
||||
@@ -385,7 +451,7 @@ and this project adheres to
|
||||
|
||||
- 🔥(frontend) remove Beta from logo #1095
|
||||
|
||||
## [3.3.0] - 2025-05-06
|
||||
## [v3.3.0] - 2025-05-06
|
||||
|
||||
### Added
|
||||
|
||||
@@ -417,14 +483,14 @@ and this project adheres to
|
||||
|
||||
- 🔥(back) remove footer endpoint #948
|
||||
|
||||
## [3.2.1] - 2025-05-06
|
||||
## [v3.2.1] - 2025-05-06
|
||||
|
||||
## Fixed
|
||||
|
||||
- 🐛(frontend) fix list copy paste #943
|
||||
- 📝(doc) update contributing policy (commit signatures are now mandatory) #895
|
||||
|
||||
## [3.2.0] - 2025-05-05
|
||||
## [v3.2.0] - 2025-05-05
|
||||
|
||||
## Added
|
||||
|
||||
@@ -451,7 +517,7 @@ and this project adheres to
|
||||
- 🐛(backend) race condition create doc #633
|
||||
- 🐛(frontend) fix breaklines in custom blocks #908
|
||||
|
||||
## [3.1.0] - 2025-04-07
|
||||
## [v3.1.0] - 2025-04-07
|
||||
|
||||
## Added
|
||||
|
||||
@@ -469,7 +535,7 @@ and this project adheres to
|
||||
- 🐛(back) validate document content in serializer #822
|
||||
- 🐛(frontend) fix selection click past end of content #840
|
||||
|
||||
## [3.0.0] - 2025-03-28
|
||||
## [v3.0.0] - 2025-03-28
|
||||
|
||||
## Added
|
||||
|
||||
@@ -485,7 +551,7 @@ and this project adheres to
|
||||
- 🐛(backend) compute ancestor_links in get_abilities if needed #725
|
||||
- 🔒️(back) restrict access to document accesses #801
|
||||
|
||||
## [2.6.0] - 2025-03-21
|
||||
## [v2.6.0] - 2025-03-21
|
||||
|
||||
## Added
|
||||
|
||||
@@ -503,7 +569,7 @@ and this project adheres to
|
||||
- 🔒️(back) throttle user list endpoint #636
|
||||
- 🔒️(back) remove pagination and limit to 5 for user list endpoint #636
|
||||
|
||||
## [2.5.0] - 2025-03-18
|
||||
## [v2.5.0] - 2025-03-18
|
||||
|
||||
## Added
|
||||
|
||||
@@ -533,7 +599,7 @@ and this project adheres to
|
||||
- 🚨(helm) fix helmfile lint #736
|
||||
- 🚚(frontend) redirect to 401 page when 401 error #759
|
||||
|
||||
## [2.4.0] - 2025-03-06
|
||||
## [v2.4.0] - 2025-03-06
|
||||
|
||||
## Added
|
||||
|
||||
@@ -547,7 +613,7 @@ and this project adheres to
|
||||
|
||||
- 🐛(frontend) fix collaboration error #684
|
||||
|
||||
## [2.3.0] - 2025-03-03
|
||||
## [v2.3.0] - 2025-03-03
|
||||
|
||||
## Added
|
||||
|
||||
@@ -574,7 +640,7 @@ and this project adheres to
|
||||
- ♻️(frontend) improve table pdf rendering
|
||||
- 🐛(email) invitation emails in receivers language
|
||||
|
||||
## [2.2.0] - 2025-02-10
|
||||
## [v2.2.0] - 2025-02-10
|
||||
|
||||
## Added
|
||||
|
||||
@@ -593,7 +659,7 @@ and this project adheres to
|
||||
- 🐛(frontend) fix cursor breakline #609
|
||||
- 🐛(frontend) fix style pdf export #609
|
||||
|
||||
## [2.1.0] - 2025-01-29
|
||||
## [v2.1.0] - 2025-01-29
|
||||
|
||||
## Added
|
||||
|
||||
@@ -622,14 +688,14 @@ and this project adheres to
|
||||
|
||||
- 🔥(backend) remove "content" field from list serializer # 516
|
||||
|
||||
## [2.0.1] - 2025-01-17
|
||||
## [v2.0.1] - 2025-01-17
|
||||
|
||||
## Fixed
|
||||
|
||||
-🐛(frontend) share modal is shown when you don't have the abilities #557
|
||||
-🐛(frontend) title copy break app #564
|
||||
|
||||
## [2.0.0] - 2025-01-13
|
||||
## [v2.0.0] - 2025-01-13
|
||||
|
||||
## Added
|
||||
|
||||
@@ -660,7 +726,7 @@ and this project adheres to
|
||||
- 🐛(frontend) hide search and create doc button if not authenticated #555
|
||||
- 🐛(backend) race condition creation issue #556
|
||||
|
||||
## [1.10.0] - 2024-12-17
|
||||
## [v1.10.0] - 2024-12-17
|
||||
|
||||
## Added
|
||||
|
||||
@@ -681,7 +747,7 @@ and this project adheres to
|
||||
- 🐛(frontend) update doc editor height #481
|
||||
- 💄(frontend) add doc search #485
|
||||
|
||||
## [1.9.0] - 2024-12-11
|
||||
## [v1.9.0] - 2024-12-11
|
||||
|
||||
## Added
|
||||
|
||||
@@ -702,19 +768,19 @@ and this project adheres to
|
||||
- 🐛(frontend) Fix hidden menu on Firefox #468
|
||||
- 🐛(backend) fix sanitize problem IA #490
|
||||
|
||||
## [1.8.2] - 2024-11-28
|
||||
## [v1.8.2] - 2024-11-28
|
||||
|
||||
## Changed
|
||||
|
||||
- ♻️(SW) change strategy html caching #460
|
||||
|
||||
## [1.8.1] - 2024-11-27
|
||||
## [v1.8.1] - 2024-11-27
|
||||
|
||||
## Fixed
|
||||
|
||||
- 🐛(frontend) link not clickable and flickering firefox #457
|
||||
|
||||
## [1.8.0] - 2024-11-25
|
||||
## [v1.8.0] - 2024-11-25
|
||||
|
||||
## Added
|
||||
|
||||
@@ -742,7 +808,7 @@ and this project adheres to
|
||||
- 🐛(frontend) users have view access when revoked #387
|
||||
- 🐛(frontend) fix placeholder editable when double clicks #454
|
||||
|
||||
## [1.7.0] - 2024-10-24
|
||||
## [v1.7.0] - 2024-10-24
|
||||
|
||||
## Added
|
||||
|
||||
@@ -769,7 +835,7 @@ and this project adheres to
|
||||
|
||||
- 🔥(helm) remove infra related codes #366
|
||||
|
||||
## [1.6.0] - 2024-10-17
|
||||
## [v1.6.0] - 2024-10-17
|
||||
|
||||
## Added
|
||||
|
||||
@@ -791,13 +857,13 @@ and this project adheres to
|
||||
- 🐛(backend) fix nginx docker container #340
|
||||
- 🐛(frontend) fix copy paste firefox #353
|
||||
|
||||
## [1.5.1] - 2024-10-10
|
||||
## [v1.5.1] - 2024-10-10
|
||||
|
||||
## Fixed
|
||||
|
||||
- 🐛(db) fix users duplicate #316
|
||||
|
||||
## [1.5.0] - 2024-10-09
|
||||
## [v1.5.0] - 2024-10-09
|
||||
|
||||
## Added
|
||||
|
||||
@@ -825,7 +891,7 @@ and this project adheres to
|
||||
- 🔧(backend) fix configuration to avoid different ssl warning #297
|
||||
- 🐛(frontend) fix editor break line not working #302
|
||||
|
||||
## [1.4.0] - 2024-09-17
|
||||
## [v1.4.0] - 2024-09-17
|
||||
|
||||
## Added
|
||||
|
||||
@@ -845,7 +911,7 @@ and this project adheres to
|
||||
- 🐛(backend) Fix forcing ID when creating a document via API endpoint #234
|
||||
- 🐛 Rebuild frontend dev container from makefile #248
|
||||
|
||||
## [1.3.0] - 2024-09-05
|
||||
## [v1.3.0] - 2024-09-05
|
||||
|
||||
## Added
|
||||
|
||||
@@ -869,14 +935,14 @@ and this project adheres to
|
||||
|
||||
- 🔥(frontend) remove saving modal #213
|
||||
|
||||
## [1.2.1] - 2024-08-23
|
||||
## [v1.2.1] - 2024-08-23
|
||||
|
||||
## Changed
|
||||
|
||||
- ♻️ Change ordering docs datagrid #195
|
||||
- 🔥(helm) use scaleway email #194
|
||||
|
||||
## [1.2.0] - 2024-08-22
|
||||
## [v1.2.0] - 2024-08-22
|
||||
|
||||
## Added
|
||||
|
||||
@@ -902,7 +968,7 @@ and this project adheres to
|
||||
|
||||
- 🔥(helm) remove htaccess #181
|
||||
|
||||
## [1.1.0] - 2024-07-15
|
||||
## [v1.1.0] - 2024-07-15
|
||||
|
||||
## Added
|
||||
|
||||
@@ -917,7 +983,7 @@ and this project adheres to
|
||||
- ♻️(frontend) create a doc from a modal #132
|
||||
- ♻️(frontend) manage members from the share modal #140
|
||||
|
||||
## [1.0.0] - 2024-07-02
|
||||
## [v1.0.0] - 2024-07-02
|
||||
|
||||
## Added
|
||||
|
||||
@@ -955,14 +1021,17 @@ and this project adheres to
|
||||
- 💚(CI) Remove trigger workflow on push tags on CI (#68)
|
||||
- 🔥(frontend) Remove coming soon page (#121)
|
||||
|
||||
## [0.1.0] - 2024-05-24
|
||||
## [v0.1.0] - 2024-05-24
|
||||
|
||||
## Added
|
||||
|
||||
- ✨(frontend) Coming Soon page (#67)
|
||||
- 🚀 Impress, project to manage your documents easily and collaboratively.
|
||||
|
||||
[unreleased]: https://github.com/suitenumerique/docs/compare/v4.2.0...main
|
||||
[unreleased]: https://github.com/suitenumerique/docs/compare/v4.5.0...main
|
||||
[v4.5.0]: https://github.com/suitenumerique/docs/releases/v4.5.0
|
||||
[v4.4.0]: https://github.com/suitenumerique/docs/releases/v4.4.0
|
||||
[v4.3.0]: https://github.com/suitenumerique/docs/releases/v4.3.0
|
||||
[v4.2.0]: https://github.com/suitenumerique/docs/releases/v4.2.0
|
||||
[v4.1.0]: https://github.com/suitenumerique/docs/releases/v4.1.0
|
||||
[v4.0.0]: https://github.com/suitenumerique/docs/releases/v4.0.0
|
||||
@@ -997,12 +1066,12 @@ and this project adheres to
|
||||
[v1.8.0]: https://github.com/suitenumerique/docs/releases/v1.8.0
|
||||
[v1.7.0]: https://github.com/suitenumerique/docs/releases/v1.7.0
|
||||
[v1.6.0]: https://github.com/suitenumerique/docs/releases/v1.6.0
|
||||
[1.5.1]: https://github.com/suitenumerique/docs/releases/v1.5.1
|
||||
[1.5.0]: https://github.com/suitenumerique/docs/releases/v1.5.0
|
||||
[1.4.0]: https://github.com/suitenumerique/docs/releases/v1.4.0
|
||||
[1.3.0]: https://github.com/suitenumerique/docs/releases/v1.3.0
|
||||
[1.2.1]: https://github.com/suitenumerique/docs/releases/v1.2.1
|
||||
[1.2.0]: https://github.com/suitenumerique/docs/releases/v1.2.0
|
||||
[1.1.0]: https://github.com/suitenumerique/docs/releases/v1.1.0
|
||||
[1.0.0]: https://github.com/suitenumerique/docs/releases/v1.0.0
|
||||
[0.1.0]: https://github.com/suitenumerique/docs/releases/v0.1.0
|
||||
[v1.5.1]: https://github.com/suitenumerique/docs/releases/v1.5.1
|
||||
[v1.5.0]: https://github.com/suitenumerique/docs/releases/v1.5.0
|
||||
[v1.4.0]: https://github.com/suitenumerique/docs/releases/v1.4.0
|
||||
[v1.3.0]: https://github.com/suitenumerique/docs/releases/v1.3.0
|
||||
[v1.2.1]: https://github.com/suitenumerique/docs/releases/v1.2.1
|
||||
[v1.2.0]: https://github.com/suitenumerique/docs/releases/v1.2.0
|
||||
[v1.1.0]: https://github.com/suitenumerique/docs/releases/v1.1.0
|
||||
[v1.0.0]: https://github.com/suitenumerique/docs/releases/v1.0.0
|
||||
[v0.1.0]: https://github.com/suitenumerique/docs/releases/v0.1.0
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
FROM python:3.13.3-alpine AS base
|
||||
|
||||
# Upgrade pip to its latest release to speed up dependencies installation
|
||||
RUN python -m pip install --upgrade pip setuptools
|
||||
RUN python -m pip install --upgrade pip
|
||||
|
||||
# Upgrade system packages to install security updates
|
||||
RUN apk update && apk upgrade --no-cache
|
||||
|
||||
1
Makefile
1
Makefile
@@ -213,6 +213,7 @@ logs: ## display app-dev logs (follow mode)
|
||||
.PHONY: logs
|
||||
|
||||
run-backend: ## Start only the backend application and all needed services
|
||||
@$(COMPOSE) up --force-recreate -d docspec
|
||||
@$(COMPOSE) up --force-recreate -d celery-dev
|
||||
@$(COMPOSE) up --force-recreate -d y-provider-development
|
||||
@$(COMPOSE) up --force-recreate -d nginx
|
||||
|
||||
@@ -8,6 +8,7 @@ docker_build(
|
||||
dockerfile='../Dockerfile',
|
||||
only=['./src/backend', './src/mail', './docker'],
|
||||
target = 'backend-production',
|
||||
build_args={'DOCKER_USER': '1000:1000'},
|
||||
live_update=[
|
||||
sync('../src/backend', '/app'),
|
||||
run(
|
||||
@@ -23,6 +24,7 @@ docker_build(
|
||||
dockerfile='../src/frontend/servers/y-provider/Dockerfile',
|
||||
only=['./src/frontend/', './docker/', './.dockerignore'],
|
||||
target = 'y-provider',
|
||||
build_args={'DOCKER_USER': '1000:1000'},
|
||||
live_update=[
|
||||
sync('../src/frontend/servers/y-provider/src', '/home/frontend/servers/y-provider/src'),
|
||||
]
|
||||
@@ -34,6 +36,7 @@ docker_build(
|
||||
dockerfile='../src/frontend/Dockerfile',
|
||||
only=['./src/frontend', './docker', './.dockerignore'],
|
||||
target = 'impress',
|
||||
build_args={'DOCKER_USER': '1000:1000'},
|
||||
live_update=[
|
||||
sync('../src/frontend', '/home/frontend'),
|
||||
]
|
||||
|
||||
@@ -231,6 +231,11 @@ services:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
|
||||
docspec:
|
||||
image: ghcr.io/docspecio/api:2.6.3
|
||||
ports:
|
||||
- "4000:4000"
|
||||
|
||||
networks:
|
||||
lasuite:
|
||||
name: lasuite-network
|
||||
|
||||
@@ -845,6 +845,32 @@
|
||||
"offline_access",
|
||||
"microprofile-jwt"
|
||||
]
|
||||
},
|
||||
{
|
||||
"clientId": "encryption",
|
||||
"name": "Encryption",
|
||||
"enabled": true,
|
||||
"clientAuthenticatorType": "client-secret",
|
||||
"standardFlowEnabled": true,
|
||||
"implicitFlowEnabled": false,
|
||||
"directAccessGrantsEnabled": false,
|
||||
"publicClient": true,
|
||||
"protocol": "openid-connect",
|
||||
"redirectUris": [
|
||||
"http://encryption.localhost:7200/auth/callback"
|
||||
],
|
||||
"webOrigins": [
|
||||
"http://encryption.localhost:7200"
|
||||
],
|
||||
"frontchannelLogout": true,
|
||||
"attributes": {},
|
||||
"defaultClientScopes": [
|
||||
"web-origins",
|
||||
"profile",
|
||||
"roles",
|
||||
"email"
|
||||
],
|
||||
"optionalClientScopes": []
|
||||
}
|
||||
],
|
||||
"clientScopes": [
|
||||
|
||||
BIN
docs/assets/waffle.png
Normal file
BIN
docs/assets/waffle.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
@@ -1,4 +1,6 @@
|
||||
# Runtime Theming 🎨
|
||||
# Customization Guide 🛠 ️
|
||||
|
||||
## Runtime Theming 🎨
|
||||
|
||||
### How to Use
|
||||
|
||||
@@ -32,7 +34,7 @@ Then, set the `FRONTEND_CSS_URL` environment variable to the URL of your custom
|
||||
|
||||
----
|
||||
|
||||
# Runtime JavaScript Injection 🚀
|
||||
## Runtime JavaScript Injection 🚀
|
||||
|
||||
### How to Use
|
||||
|
||||
@@ -87,7 +89,7 @@ Then, set the `FRONTEND_JS_URL` environment variable to the URL of your custom J
|
||||
|
||||
----
|
||||
|
||||
# **Your Docs icon** 📝
|
||||
## **Your Docs icon** 📝
|
||||
|
||||
You can add your own Docs icon in the header from the theme customization file.
|
||||
|
||||
@@ -105,7 +107,7 @@ This configuration is optional. If not set, the default icon will be used.
|
||||
|
||||
----
|
||||
|
||||
# **Footer Configuration** 📝
|
||||
## **Footer Configuration** 📝
|
||||
|
||||
The footer is configurable from the theme customization file.
|
||||
|
||||
@@ -128,7 +130,7 @@ Below is a visual example of a configured footer ⬇️:
|
||||
|
||||
----
|
||||
|
||||
# **Custom Translations** 📝
|
||||
## **Custom Translations** 📝
|
||||
|
||||
The translations can be partially overridden from the theme customization file.
|
||||
|
||||
@@ -140,4 +142,36 @@ 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
|
||||
The json must follow some rules: https://github.com/suitenumerique/docs/blob/main/src/helm/env.d/dev/configuration/theme/demo.json
|
||||
|
||||
----
|
||||
|
||||
## **Waffle Configuration** 🧇
|
||||
|
||||
The Waffle (La Gaufre) is a widget that displays a grid of services.
|
||||
|
||||

|
||||
|
||||
### Settings 🔧
|
||||
|
||||
```shellscript
|
||||
THEME_CUSTOMIZATION_FILE_PATH=<path>
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
The Waffle can be configured in the theme customization file with the `waffle` key.
|
||||
|
||||
### Available Properties
|
||||
|
||||
See: [LaGaufreV2Props](https://github.com/suitenumerique/ui-kit/blob/main/src/components/la-gaufre/LaGaufreV2.tsx#L49)
|
||||
|
||||
### Complete Example
|
||||
|
||||
From the theme customization file: https://github.com/suitenumerique/docs/blob/main/src/helm/env.d/dev/configuration/theme/demo.json
|
||||
|
||||
### Behavior
|
||||
|
||||
- If `data.services` is provided, the Waffle will display those services statically
|
||||
- If no data is provided, services can be fetched dynamically from an API endpoint thanks to the `apiUrl` property
|
||||
|
||||
10
docs/env.md
10
docs/env.md
@@ -21,9 +21,10 @@ These are the environment variables you can set for the `impress-backend` contai
|
||||
| AWS_S3_ENDPOINT_URL | S3 endpoint | |
|
||||
| AWS_S3_REGION_NAME | Region name for s3 endpoint | |
|
||||
| AWS_S3_SECRET_ACCESS_KEY | Access key for s3 endpoint | |
|
||||
| AWS_S3_SIGNATURE_VERSION | S3 signature version (`s3v4` or `s3`) | s3v4 |
|
||||
| AWS_STORAGE_BUCKET_NAME | Bucket name for s3 endpoint | impress-media-storage |
|
||||
| CACHES_DEFAULT_TIMEOUT | Cache default timeout | 30 |
|
||||
| CACHES_KEY_PREFIX | The prefix used to every cache keys. | docs |
|
||||
| CACHES_DEFAULT_KEY_PREFIX | The prefix used to every cache keys. | docs |
|
||||
| COLLABORATION_API_URL | Collaboration api host | |
|
||||
| COLLABORATION_SERVER_SECRET | Collaboration api secret | |
|
||||
| COLLABORATION_WS_NOT_CONNECTED_READY_ONLY | Users not connected to the collaboration server cannot edit | false |
|
||||
@@ -32,6 +33,8 @@ These are the environment variables you can set for the `impress-backend` contai
|
||||
| CONVERSION_API_ENDPOINT | Conversion API endpoint | convert |
|
||||
| CONVERSION_API_SECURE | Require secure conversion api | false |
|
||||
| CONVERSION_API_TIMEOUT | Conversion api timeout | 30 |
|
||||
| CONVERSION_FILE_MAX_SIZE | The file max size allowed when uploaded to convert it | 20971520 (20MB) |
|
||||
| CONVERSION_FILE_EXTENSIONS_ALLOWED | Extension list managed by the conversion service | [".docx", ".md"]
|
||||
| CRISP_WEBSITE_ID | Crisp website id for support | |
|
||||
| DB_ENGINE | Engine to use for database connections | django.db.backends.postgresql_psycopg2 |
|
||||
| DB_HOST | Host of the database | localhost |
|
||||
@@ -54,16 +57,21 @@ These are the environment variables you can set for the `impress-backend` contai
|
||||
| DJANGO_EMAIL_HOST_USER | User to authenticate with on the email host | |
|
||||
| DJANGO_EMAIL_LOGO_IMG | Logo for the email | |
|
||||
| DJANGO_EMAIL_PORT | Port used to connect to email host | |
|
||||
| DJANGO_EMAIL_URL_APP | Url used in the email to go to the app | |
|
||||
| DJANGO_EMAIL_USE_SSL | Use ssl for email host connection | false |
|
||||
| DJANGO_EMAIL_USE_TLS | Use tls for email host connection | false |
|
||||
| DJANGO_SECRET_KEY | Secret key | |
|
||||
| DJANGO_SERVER_TO_SERVER_API_TOKENS | | [] |
|
||||
| DOCSPEC_API_URL | URL to endpoint of DocSpec conversion API | |
|
||||
| DOCUMENT_IMAGE_MAX_SIZE | Maximum size of document in bytes | 10485760 |
|
||||
| FRONTEND_CSS_URL | To add a external css file to the app | |
|
||||
| FRONTEND_JS_URL | To add a external js file to the app | |
|
||||
| FRONTEND_HOMEPAGE_FEATURE_ENABLED | Frontend feature flag to display the homepage | false |
|
||||
| FRONTEND_THEME | Frontend theme to use | |
|
||||
| LANGUAGE_CODE | Default language | en-us |
|
||||
| LANGFUSE_SECRET_KEY | The Langfuse secret key used by the sdk | None |
|
||||
| LANGFUSE_PUBLIC_KEY | The Langfuse public key used by the sdk | None |
|
||||
| LANGFUSE_BASE_URL | The Langfuse base url used by the sdk | None |
|
||||
| LASUITE_MARKETING_BACKEND | Backend used when SIGNUP_NEW_USER_TO_MARKETING_EMAIL is True. See https://github.com/suitenumerique/django-lasuite/blob/main/documentation/how-to-use-marketing-backend.md | lasuite.marketing.backends.dummy.DummyBackend |
|
||||
| LASUITE_MARKETING_PARAMETERS | The parameters to configure LASUITE_MARKETING_BACKEND. See https://github.com/suitenumerique/django-lasuite/blob/main/documentation/how-to-use-marketing-backend.md | {} |
|
||||
| LOGGING_LEVEL_LOGGERS_APP | Application logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO |
|
||||
|
||||
@@ -27,6 +27,7 @@ backend:
|
||||
DJANGO_EMAIL_HOST: "mailcatcher"
|
||||
DJANGO_EMAIL_LOGO_IMG: https://docs.127.0.0.1.nip.io/assets/logo-suite-numerique.png
|
||||
DJANGO_EMAIL_PORT: 1025
|
||||
DJANGO_EMAIL_URL_APP: https://docs.127.0.0.1.nip.io
|
||||
DJANGO_EMAIL_USE_SSL: False
|
||||
LOGGING_LEVEL_HANDLERS_CONSOLE: ERROR
|
||||
LOGGING_LEVEL_LOGGERS_ROOT: INFO
|
||||
|
||||
@@ -127,6 +127,7 @@ DJANGO_EMAIL_FROM=<your email address>
|
||||
|
||||
DJANGO_EMAIL_BRAND_NAME=<brand name used in email templates> # e.g. "La Suite Numérique"
|
||||
DJANGO_EMAIL_LOGO_IMG=<logo image to use in email templates.> # e.g. "https://docs.yourdomain.tld/assets/logo-suite-numerique.png"
|
||||
DJANGO_EMAIL_URL_APP=<url used in email templates to go to the app> # e.g. "https://docs.yourdomain.tld"
|
||||
```
|
||||
|
||||
### AI
|
||||
|
||||
180
docs/languages-configuration.md
Normal file
180
docs/languages-configuration.md
Normal file
@@ -0,0 +1,180 @@
|
||||
# Language Configuration (2025-12)
|
||||
|
||||
This document explains how to configure and override the available languages in the Docs application.
|
||||
|
||||
## Default Languages
|
||||
|
||||
By default, the application supports the following languages (in priority order):
|
||||
|
||||
- English (en-us)
|
||||
- French (fr-fr)
|
||||
- German (de-de)
|
||||
- Dutch (nl-nl)
|
||||
- Spanish (es-es)
|
||||
|
||||
The default configuration is defined in `src/backend/impress/settings.py`:
|
||||
|
||||
```python
|
||||
LANGUAGES = values.SingleNestedTupleValue(
|
||||
(
|
||||
("en-us", "English"),
|
||||
("fr-fr", "Français"),
|
||||
("de-de", "Deutsch"),
|
||||
("nl-nl", "Nederlands"),
|
||||
("es-es", "Español"),
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
## Overriding Languages
|
||||
|
||||
### Using Environment Variables
|
||||
|
||||
You can override the available languages by setting the `DJANGO_LANGUAGES` environment variable. This is the recommended approach for customizing language support without modifying the source code.
|
||||
|
||||
#### Format
|
||||
|
||||
The `DJANGO_LANGUAGES` variable expects a semicolon-separated list of language configurations, where each language is defined as `code,Display Name`:
|
||||
|
||||
```
|
||||
DJANGO_LANGUAGES=code1,Name1;code2,Name2;code3,Name3
|
||||
```
|
||||
|
||||
#### Example Configurations
|
||||
|
||||
**Example 1: English and French only**
|
||||
|
||||
```bash
|
||||
DJANGO_LANGUAGES=en-us,English;fr-fr,Français
|
||||
```
|
||||
|
||||
**Example 2: Add Italian and Chinese**
|
||||
|
||||
```bash
|
||||
DJANGO_LANGUAGES=en-us,English;fr-fr,Français;de-de,Deutsch;it-it,Italiano;zh-cn,中文
|
||||
```
|
||||
|
||||
**Example 3: Custom subset of languages**
|
||||
|
||||
```bash
|
||||
DJANGO_LANGUAGES=fr-fr,Français;de-de,Deutsch;es-es,Español
|
||||
```
|
||||
|
||||
### Configuration Files
|
||||
|
||||
#### Development Environment
|
||||
|
||||
For local development, you can set the `DJANGO_LANGUAGES` variable in your environment configuration file:
|
||||
|
||||
**File:** `env.d/development/common.local`
|
||||
|
||||
```bash
|
||||
DJANGO_LANGUAGES=en-us,English;fr-fr,Français;de-de,Deutsch;it-it,Italiano;zh-cn,中文;
|
||||
```
|
||||
|
||||
#### Production Environment
|
||||
|
||||
For production deployments, add the variable to your production environment configuration:
|
||||
|
||||
**File:** `env.d/production.dist/common`
|
||||
|
||||
```bash
|
||||
DJANGO_LANGUAGES=en-us,English;fr-fr,Français
|
||||
```
|
||||
|
||||
#### Docker Compose
|
||||
|
||||
When using Docker Compose, you can set the environment variable in your `compose.yml` or `compose.override.yml` file:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
environment:
|
||||
- DJANGO_LANGUAGES=en-us,English;fr-fr,Français;de-de,Deutsch
|
||||
```
|
||||
|
||||
## Important Considerations
|
||||
|
||||
### Language Codes
|
||||
|
||||
- Use standard language codes (ISO 639-1 with optional region codes)
|
||||
- Format: `language-region` (e.g., `en-us`, `fr-fr`, `de-de`)
|
||||
- Use lowercase for language codes and region identifiers
|
||||
|
||||
### Priority Order
|
||||
|
||||
Languages are listed in priority order. The first language in the list is used as the fallback language throughout the application when a specific translation is not available.
|
||||
|
||||
### Translation Availability
|
||||
|
||||
Before adding a new language, ensure that:
|
||||
|
||||
1. Translation files exist for that language in the `src/backend/locale/` directory
|
||||
2. The frontend application has corresponding translation files
|
||||
3. All required messages have been translated
|
||||
|
||||
#### Available Languages
|
||||
|
||||
The following languages have translation files available in `src/backend/locale/`:
|
||||
|
||||
- `br_FR` - Breton (France)
|
||||
- `cn_CN` - Chinese (China) - *Note: Use `zh-cn` in DJANGO_LANGUAGES*
|
||||
- `de_DE` - German (Germany) - Use `de-de`
|
||||
- `en_US` - English (United States) - Use `en-us`
|
||||
- `es_ES` - Spanish (Spain) - Use `es-es`
|
||||
- `fr_FR` - French (France) - Use `fr-fr`
|
||||
- `it_IT` - Italian (Italy) - Use `it-it`
|
||||
- `nl_NL` - Dutch (Netherlands) - Use `nl-nl`
|
||||
- `pt_PT` - Portuguese (Portugal) - Use `pt-pt`
|
||||
- `ru_RU` - Russian (Russia) - Use `ru-ru`
|
||||
- `sl_SI` - Slovenian (Slovenia) - Use `sl-si`
|
||||
- `sv_SE` - Swedish (Sweden) - Use `sv-se`
|
||||
- `tr_TR` - Turkish (Turkey) - Use `tr-tr`
|
||||
- `uk_UA` - Ukrainian (Ukraine) - Use `uk-ua`
|
||||
- `zh_CN` - Chinese (China) - Use `zh-cn`
|
||||
|
||||
**Note:** When configuring `DJANGO_LANGUAGES`, use lowercase with hyphens (e.g., `pt-pt`, `ru-ru`) rather than the directory name format.
|
||||
|
||||
### Translation Management
|
||||
|
||||
We use [Crowdin](https://crowdin.com/) to manage translations for the Docs application. Crowdin allows our community to contribute translations and helps maintain consistency across all supported languages.
|
||||
|
||||
**Want to add a new language or improve existing translations?**
|
||||
|
||||
If you would like us to support a new language or want to contribute to translations, please get in touch with the project maintainers. We can add new languages to our Crowdin project and coordinate translation efforts with the community.
|
||||
|
||||
### Cookie and Session
|
||||
|
||||
The application stores the user's language preference in a cookie named `docs_language`. The cookie path is set to `/` by default.
|
||||
|
||||
## Testing Language Configuration
|
||||
|
||||
After changing the language configuration:
|
||||
|
||||
1. Restart the application services
|
||||
2. Verify the language selector displays the correct languages
|
||||
3. Test switching between different languages
|
||||
4. Confirm that content is displayed in the selected language
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Languages not appearing
|
||||
|
||||
- Verify the environment variable is correctly formatted (semicolon-separated, comma between code and name)
|
||||
- Check that there are no trailing spaces in language codes or names
|
||||
- Ensure the application was restarted after changing the configuration
|
||||
|
||||
### Missing translations
|
||||
|
||||
If you add a new language but see untranslated text:
|
||||
|
||||
1. Check if translation files exist in `src/backend/locale/<language_code>/LC_MESSAGES/`
|
||||
2. Run Django's `makemessages` and `compilemessages` commands to generate/update translations
|
||||
3. Verify frontend translation files are available
|
||||
|
||||
## Related Configuration
|
||||
|
||||
- `LANGUAGE_CODE`: Default language code (default: `en-us`)
|
||||
- `LANGUAGE_COOKIE_NAME`: Cookie name for storing user language preference (default: `docs_language`)
|
||||
- `LANGUAGE_COOKIE_PATH`: Cookie path (default: `/`)
|
||||
|
||||
@@ -20,6 +20,7 @@ DJANGO_EMAIL_BRAND_NAME="La Suite Numérique"
|
||||
DJANGO_EMAIL_HOST="mailcatcher"
|
||||
DJANGO_EMAIL_LOGO_IMG="http://localhost:3000/assets/logo-suite-numerique.png"
|
||||
DJANGO_EMAIL_PORT=1025
|
||||
DJANGO_EMAIL_URL_APP="http://localhost:3000"
|
||||
|
||||
# Backend url
|
||||
IMPRESS_BASE_URL="http://localhost:8072"
|
||||
@@ -47,11 +48,11 @@ LOGIN_REDIRECT_URL=http://localhost:3000
|
||||
LOGIN_REDIRECT_URL_FAILURE=http://localhost:3000
|
||||
LOGOUT_REDIRECT_URL=http://localhost:3000
|
||||
|
||||
OIDC_REDIRECT_ALLOWED_HOSTS=["http://localhost:8083", "http://localhost:3000"]
|
||||
OIDC_REDIRECT_ALLOWED_HOSTS="localhost:8083,localhost:3000"
|
||||
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
|
||||
|
||||
# Store OIDC tokens in the session. Needed by search/ endpoint.
|
||||
# OIDC_STORE_ACCESS_TOKEN = True
|
||||
# Store OIDC tokens in the session. Needed by search/ endpoint and encryption service.
|
||||
OIDC_STORE_ACCESS_TOKEN = True
|
||||
# OIDC_STORE_REFRESH_TOKEN = True # Store the encrypted refresh token in the session.
|
||||
|
||||
# Must be a valid Fernet key (32 url-safe base64-encoded bytes)
|
||||
@@ -76,6 +77,8 @@ 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
|
||||
|
||||
DOCSPEC_API_URL=http://docspec:4000/conversion
|
||||
|
||||
# Theme customization
|
||||
THEME_CUSTOMIZATION_CACHE_TIMEOUT=15
|
||||
|
||||
|
||||
@@ -6,4 +6,4 @@ Y_PROVIDER_API_BASE_URL=http://y-provider:4444/api/
|
||||
|
||||
# Throttle
|
||||
API_DOCUMENT_THROTTLE_RATE=1000/min
|
||||
API_CONFIG_THROTTLE_RATE=1000/min
|
||||
API_CONFIG_THROTTLE_RATE=1000/min
|
||||
|
||||
@@ -24,7 +24,8 @@ DJANGO_EMAIL_FROM=<your email address>
|
||||
#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"
|
||||
DJANGO_EMAIL_LOGO_IMG="https://${DOCS_HOST}/assets/logo-suite-numerique.png"
|
||||
DJANGO_EMAIL_URL_APP="https://${DOCS_HOST}"
|
||||
|
||||
# Media
|
||||
AWS_S3_ENDPOINT_URL=https://${S3_HOST}
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
publiccodeYmlVersion: "0.5.0"
|
||||
publiccodeYmlVersion: "2.4.0"
|
||||
name: Docs
|
||||
url: https://github.com/suitenumerique/docs
|
||||
landingURL: https://github.com/suitenumerique/docs
|
||||
creationDate: 2023-12-10
|
||||
logo: https://raw.githubusercontent.com/suitenumerique/docs/main/docs/assets/docs-logo.png
|
||||
usedBy:
|
||||
- Direction interministérielle du numérique (DINUM)
|
||||
- Direction interministériel du numérique (DINUM)
|
||||
fundedBy:
|
||||
- name: Direction interministérielle du numérique (DINUM)
|
||||
uri: https://www.numerique.gouv.fr
|
||||
- name: Direction interministériel du numérique (DINUM)
|
||||
url: https://www.numerique.gouv.fr
|
||||
roadmap: "https://github.com/orgs/suitenumerique/projects/2/views/1"
|
||||
softwareType: "standalone/other"
|
||||
platforms:
|
||||
- "web"
|
||||
developmentStatus: "stable"
|
||||
description:
|
||||
en:
|
||||
shortDescription: "The open source document editor where your notes can become knowledge through live collaboration"
|
||||
@@ -20,18 +18,10 @@ description:
|
||||
shortDescription: "L'éditeur de documents open source où vos notes peuvent devenir des connaissances grâce à la collaboration en direct."
|
||||
legal:
|
||||
license: MIT
|
||||
localisation:
|
||||
localisationReady: true
|
||||
availableLanguages:
|
||||
- de
|
||||
- en
|
||||
- es
|
||||
- fr
|
||||
- nl
|
||||
maintenance:
|
||||
type: internal
|
||||
contacts:
|
||||
- name: "Virgile Deville"
|
||||
email: "virgile.deville@numerique.gouv.fr"
|
||||
- name: "Samuel Paccoud"
|
||||
- name: "samuel.paccoud"
|
||||
email: "samuel.paccoud@numerique.gouv.fr"
|
||||
|
||||
@@ -25,18 +25,30 @@
|
||||
"matchPackageNames": ["pylint"],
|
||||
"allowedVersions": "<4.0.0"
|
||||
},
|
||||
{
|
||||
"groupName": "allowed django versions",
|
||||
"matchManagers": ["pep621"],
|
||||
"matchPackageNames": ["django"],
|
||||
"allowedVersions": "<6.0.0"
|
||||
},
|
||||
{
|
||||
"groupName": "allowed celery versions",
|
||||
"matchManagers": ["pep621"],
|
||||
"matchPackageNames": ["celery"],
|
||||
"allowedVersions": "<5.6.0"
|
||||
},
|
||||
{
|
||||
"enabled": false,
|
||||
"groupName": "ignored js dependencies",
|
||||
"matchManagers": ["npm"],
|
||||
"matchPackageNames": [
|
||||
"@next/eslint-plugin-next",
|
||||
"docx",
|
||||
"eslint-config-next",
|
||||
"fetch-mock",
|
||||
"next",
|
||||
"node",
|
||||
"node-fetch",
|
||||
"react-resizable-panels",
|
||||
"workbox-webpack-plugin"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -9,14 +9,6 @@ from treebeard.admin import TreeAdmin
|
||||
from . import models
|
||||
|
||||
|
||||
class TemplateAccessInline(admin.TabularInline):
|
||||
"""Inline admin class for template accesses."""
|
||||
|
||||
autocomplete_fields = ["user"]
|
||||
model = models.TemplateAccess
|
||||
extra = 0
|
||||
|
||||
|
||||
@admin.register(models.User)
|
||||
class UserAdmin(auth_admin.UserAdmin):
|
||||
"""Admin class for the User model"""
|
||||
@@ -69,7 +61,6 @@ class UserAdmin(auth_admin.UserAdmin):
|
||||
},
|
||||
),
|
||||
)
|
||||
inlines = (TemplateAccessInline,)
|
||||
list_display = (
|
||||
"id",
|
||||
"sub",
|
||||
@@ -104,15 +95,8 @@ class UserAdmin(auth_admin.UserAdmin):
|
||||
search_fields = ("id", "sub", "admin_email", "email", "full_name")
|
||||
|
||||
|
||||
@admin.register(models.Template)
|
||||
class TemplateAdmin(admin.ModelAdmin):
|
||||
"""Template admin interface declaration."""
|
||||
|
||||
inlines = (TemplateAccessInline,)
|
||||
|
||||
|
||||
class DocumentAccessInline(admin.TabularInline):
|
||||
"""Inline admin class for template accesses."""
|
||||
"""Inline admin class for document accesses."""
|
||||
|
||||
autocomplete_fields = ["user"]
|
||||
model = models.DocumentAccess
|
||||
|
||||
@@ -66,10 +66,13 @@ class ListDocumentFilter(DocumentFilter):
|
||||
is_favorite = django_filters.BooleanFilter(
|
||||
method="filter_is_favorite", label=_("Favorite")
|
||||
)
|
||||
is_encrypted = django_filters.BooleanFilter(
|
||||
method="filter_is_encrypted", label=_("Encrypted")
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = models.Document
|
||||
fields = ["is_creator_me", "is_favorite", "title"]
|
||||
fields = ["is_creator_me", "is_favorite", "is_encrypted", "title"]
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def filter_is_creator_me(self, queryset, name, value):
|
||||
@@ -110,6 +113,24 @@ class ListDocumentFilter(DocumentFilter):
|
||||
|
||||
return queryset.filter(is_favorite=bool(value))
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def filter_is_encrypted(self, queryset, name, value):
|
||||
"""
|
||||
Filter documents based on whether they are encrypted.
|
||||
|
||||
Example:
|
||||
- /api/v1.0/documents/?is_encrypted=true
|
||||
→ Filters documents encrypted
|
||||
- /api/v1.0/documents/?is_encrypted=false
|
||||
→ Filters documents not encrypted
|
||||
"""
|
||||
user = self.request.user
|
||||
|
||||
if not user.is_authenticated:
|
||||
return queryset
|
||||
|
||||
return queryset.filter(is_encrypted=bool(value))
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def filter_is_masked(self, queryset, name, value):
|
||||
"""
|
||||
|
||||
@@ -98,10 +98,10 @@ class CanCreateInvitationPermission(permissions.BasePermission):
|
||||
|
||||
|
||||
class ResourceWithAccessPermission(permissions.BasePermission):
|
||||
"""A permission class for templates and invitations."""
|
||||
"""A permission class for invitations."""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""check create permission for templates."""
|
||||
"""check create permission."""
|
||||
return request.user.is_authenticated or view.action != "create"
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import binascii
|
||||
import mimetypes
|
||||
from base64 import b64decode
|
||||
from os.path import splitext
|
||||
|
||||
from django.conf import settings
|
||||
from django.db.models import Q
|
||||
@@ -15,10 +16,11 @@ import magic
|
||||
from rest_framework import serializers
|
||||
|
||||
from core import choices, enums, models, utils, validators
|
||||
from core.services import mime_types
|
||||
from core.services.ai_services import AI_ACTIONS
|
||||
from core.services.converter_services import (
|
||||
ConversionError,
|
||||
YdocConverter,
|
||||
Converter,
|
||||
)
|
||||
|
||||
|
||||
@@ -27,11 +29,12 @@ class UserSerializer(serializers.ModelSerializer):
|
||||
|
||||
full_name = serializers.SerializerMethodField(read_only=True)
|
||||
short_name = serializers.SerializerMethodField(read_only=True)
|
||||
suite_user_id = serializers.CharField(source='sub', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = ["id", "email", "full_name", "short_name", "language"]
|
||||
read_only_fields = ["id", "email", "full_name", "short_name"]
|
||||
fields = ["id", "email", "full_name", "short_name", "language", "suite_user_id"]
|
||||
read_only_fields = ["id", "email", "full_name", "short_name", "suite_user_id"]
|
||||
|
||||
def get_full_name(self, instance):
|
||||
"""Return the full name of the user."""
|
||||
@@ -55,49 +58,36 @@ class UserLightSerializer(UserSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = ["full_name", "short_name"]
|
||||
read_only_fields = ["full_name", "short_name"]
|
||||
|
||||
|
||||
class TemplateAccessSerializer(serializers.ModelSerializer):
|
||||
"""Serialize template accesses."""
|
||||
|
||||
abilities = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.TemplateAccess
|
||||
resource_field_name = "template"
|
||||
fields = ["id", "user", "team", "role", "abilities"]
|
||||
read_only_fields = ["id", "abilities"]
|
||||
|
||||
def get_abilities(self, instance) -> dict:
|
||||
"""Return abilities of the logged-in user on the instance."""
|
||||
request = self.context.get("request")
|
||||
if request:
|
||||
return instance.get_abilities(request.user)
|
||||
return {}
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
"""Make "user" field is readonly but only on update."""
|
||||
validated_data.pop("user", None)
|
||||
return super().update(instance, validated_data)
|
||||
fields = ["id", "full_name", "short_name"]
|
||||
read_only_fields = ["id", "full_name", "short_name"]
|
||||
|
||||
|
||||
class ListDocumentSerializer(serializers.ModelSerializer):
|
||||
"""Serialize documents with limited fields for display in lists."""
|
||||
|
||||
is_favorite = serializers.BooleanField(read_only=True)
|
||||
is_encrypted = serializers.BooleanField(read_only=True)
|
||||
nb_accesses_ancestors = serializers.IntegerField(read_only=True)
|
||||
nb_accesses_direct = serializers.IntegerField(read_only=True)
|
||||
user_role = serializers.SerializerMethodField(read_only=True)
|
||||
abilities = serializers.SerializerMethodField(read_only=True)
|
||||
deleted_at = serializers.SerializerMethodField(read_only=True)
|
||||
accesses_user_ids = serializers.SerializerMethodField(read_only=True)
|
||||
accesses_fingerprints_per_user = serializers.SerializerMethodField(read_only=True)
|
||||
encrypted_document_symmetric_key_for_user = serializers.SerializerMethodField(
|
||||
read_only=True
|
||||
)
|
||||
is_pending_encryption_for_user = serializers.SerializerMethodField(
|
||||
read_only=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = models.Document
|
||||
fields = [
|
||||
"id",
|
||||
"abilities",
|
||||
"accesses_fingerprints_per_user",
|
||||
"accesses_user_ids",
|
||||
"ancestors_link_reach",
|
||||
"ancestors_link_role",
|
||||
"computed_link_reach",
|
||||
@@ -106,8 +96,11 @@ class ListDocumentSerializer(serializers.ModelSerializer):
|
||||
"creator",
|
||||
"deleted_at",
|
||||
"depth",
|
||||
"encrypted_document_symmetric_key_for_user",
|
||||
"excerpt",
|
||||
"is_favorite",
|
||||
"is_encrypted",
|
||||
"is_pending_encryption_for_user",
|
||||
"link_role",
|
||||
"link_reach",
|
||||
"nb_accesses_ancestors",
|
||||
@@ -121,6 +114,7 @@ class ListDocumentSerializer(serializers.ModelSerializer):
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"abilities",
|
||||
"accesses_user_ids",
|
||||
"ancestors_link_reach",
|
||||
"ancestors_link_role",
|
||||
"computed_link_reach",
|
||||
@@ -129,8 +123,11 @@ class ListDocumentSerializer(serializers.ModelSerializer):
|
||||
"creator",
|
||||
"deleted_at",
|
||||
"depth",
|
||||
"encrypted_document_symmetric_key_for_user",
|
||||
"excerpt",
|
||||
"is_favorite",
|
||||
"is_encrypted",
|
||||
"is_pending_encryption_for_user",
|
||||
"link_role",
|
||||
"link_reach",
|
||||
"nb_accesses_ancestors",
|
||||
@@ -173,6 +170,59 @@ class ListDocumentSerializer(serializers.ModelSerializer):
|
||||
"""Return the deleted_at of the current document."""
|
||||
return instance.ancestors_deleted_at
|
||||
|
||||
def get_accesses_user_ids(self, instance):
|
||||
"""Return user IDs of members with access to this document.
|
||||
The frontend uses these to fetch public keys from the encryption service."""
|
||||
request = self.context.get("request")
|
||||
if not request or not request.user.is_authenticated:
|
||||
return None
|
||||
return [str(uid) for uid in instance.accesses_user_ids]
|
||||
|
||||
def get_accesses_fingerprints_per_user(self, instance):
|
||||
"""Return fingerprints of users' public keys at share time."""
|
||||
request = self.context.get("request")
|
||||
if not request or not request.user.is_authenticated:
|
||||
return None
|
||||
if not instance.is_encrypted:
|
||||
return None
|
||||
return instance.accesses_fingerprints_per_user
|
||||
|
||||
def get_encrypted_document_symmetric_key_for_user(self, instance):
|
||||
"""Return the encrypted symmetric key for the current user."""
|
||||
request = self.context.get("request")
|
||||
if not request or not request.user.is_authenticated:
|
||||
return None
|
||||
if not instance.is_encrypted:
|
||||
return None
|
||||
try:
|
||||
access = models.DocumentAccess.objects.get(
|
||||
document=instance, user=request.user
|
||||
)
|
||||
return access.encrypted_document_symmetric_key_for_user
|
||||
except models.DocumentAccess.DoesNotExist:
|
||||
return None
|
||||
|
||||
def get_is_pending_encryption_for_user(self, instance):
|
||||
"""True when the current user has a DocumentAccess row on this
|
||||
encrypted document with no wrapped key — i.e. they were added
|
||||
to the access list but haven't completed their encryption
|
||||
onboarding yet.
|
||||
|
||||
Clients use this to avoid attempting to decrypt (which would
|
||||
fail with a meaningless key error) and render a "waiting for
|
||||
acceptance" panel directly instead.
|
||||
"""
|
||||
if not instance.is_encrypted:
|
||||
return False
|
||||
request = self.context.get("request")
|
||||
if not request or not request.user.is_authenticated:
|
||||
return False
|
||||
return models.DocumentAccess.objects.filter(
|
||||
document=instance,
|
||||
user=request.user,
|
||||
encrypted_document_symmetric_key_for_user__isnull=True,
|
||||
).exists()
|
||||
|
||||
|
||||
class DocumentLightSerializer(serializers.ModelSerializer):
|
||||
"""Minial document serializer for nesting in document accesses."""
|
||||
@@ -187,24 +237,35 @@ class DocumentSerializer(ListDocumentSerializer):
|
||||
"""Serialize documents with all fields for display in detail views."""
|
||||
|
||||
content = serializers.CharField(required=False)
|
||||
contentEncrypted = serializers.BooleanField(required=False, write_only=True)
|
||||
websocket = serializers.BooleanField(required=False, write_only=True)
|
||||
file = serializers.FileField(
|
||||
required=False, write_only=True, allow_null=True, max_length=255
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = models.Document
|
||||
fields = [
|
||||
"id",
|
||||
"abilities",
|
||||
"accesses_fingerprints_per_user",
|
||||
"accesses_user_ids",
|
||||
"ancestors_link_reach",
|
||||
"ancestors_link_role",
|
||||
"computed_link_reach",
|
||||
"computed_link_role",
|
||||
"content",
|
||||
"contentEncrypted",
|
||||
"created_at",
|
||||
"creator",
|
||||
"deleted_at",
|
||||
"depth",
|
||||
"excerpt",
|
||||
"encrypted_document_symmetric_key_for_user",
|
||||
"file",
|
||||
"is_favorite",
|
||||
"is_encrypted",
|
||||
"is_pending_encryption_for_user",
|
||||
"link_role",
|
||||
"link_reach",
|
||||
"nb_accesses_ancestors",
|
||||
@@ -227,7 +288,10 @@ class DocumentSerializer(ListDocumentSerializer):
|
||||
"creator",
|
||||
"deleted_at",
|
||||
"depth",
|
||||
"encrypted_document_symmetric_key_for_user",
|
||||
"is_favorite",
|
||||
"is_encrypted",
|
||||
"is_pending_encryption_for_user",
|
||||
"link_role",
|
||||
"link_reach",
|
||||
"nb_accesses_ancestors",
|
||||
@@ -246,6 +310,11 @@ class DocumentSerializer(ListDocumentSerializer):
|
||||
if request and request.method == "POST":
|
||||
fields["id"].read_only = False
|
||||
|
||||
# if user is not authenticated remove public keys information since he can still retrieve the document
|
||||
if request and not request.user.is_authenticated:
|
||||
fields.pop("accesses_user_ids", None)
|
||||
fields.pop("encrypted_document_symmetric_key_for_user", None)
|
||||
|
||||
return fields
|
||||
|
||||
def validate_id(self, value):
|
||||
@@ -273,13 +342,45 @@ class DocumentSerializer(ListDocumentSerializer):
|
||||
|
||||
return value
|
||||
|
||||
def validate_file(self, file):
|
||||
"""Add file size and type constraints as defined in settings."""
|
||||
if not file:
|
||||
return None
|
||||
|
||||
# Validate file size
|
||||
if file.size > settings.CONVERSION_FILE_MAX_SIZE:
|
||||
max_size = settings.CONVERSION_FILE_MAX_SIZE // (1024 * 1024)
|
||||
raise serializers.ValidationError(
|
||||
f"File size exceeds the maximum limit of {max_size:d} MB."
|
||||
)
|
||||
|
||||
_name, extension = splitext(file.name)
|
||||
|
||||
if extension.lower() not in settings.CONVERSION_FILE_EXTENSIONS_ALLOWED:
|
||||
raise serializers.ValidationError(
|
||||
(
|
||||
f"File extension {extension} is not allowed. Allowed extensions"
|
||||
f" are: {settings.CONVERSION_FILE_EXTENSIONS_ALLOWED}."
|
||||
)
|
||||
)
|
||||
|
||||
return file
|
||||
|
||||
def save(self, **kwargs):
|
||||
"""
|
||||
Process the content field to extract attachment keys and update the document's
|
||||
"attachments" field for access control.
|
||||
"""
|
||||
content = self.validated_data.get("content", "")
|
||||
extracted_attachments = set(utils.extract_attachments(content))
|
||||
|
||||
# Encrypted content cannot be parsed as a Yjs update
|
||||
# TODO: for now skip attachment extraction for encrypted documents but we should have them
|
||||
is_encrypted = self.validated_data.get(
|
||||
"is_encrypted", self.instance and self.instance.is_encrypted
|
||||
)
|
||||
extracted_attachments = (
|
||||
set() if is_encrypted else set(utils.extract_attachments(content))
|
||||
)
|
||||
|
||||
existing_attachments = (
|
||||
set(self.instance.attachments or []) if self.instance else set()
|
||||
@@ -337,6 +438,14 @@ class DocumentAccessSerializer(serializers.ModelSerializer):
|
||||
abilities = serializers.SerializerMethodField(read_only=True)
|
||||
max_ancestors_role = serializers.SerializerMethodField(read_only=True)
|
||||
max_role = serializers.SerializerMethodField(read_only=True)
|
||||
encrypted_document_symmetric_key_for_user = serializers.CharField(
|
||||
required=False, allow_blank=True, write_only=True
|
||||
)
|
||||
# TODO: REQUIRED!!!
|
||||
encryption_public_key_fingerprint = serializers.CharField(
|
||||
required=False, allow_blank=True, max_length=16
|
||||
)
|
||||
is_pending_encryption = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.DocumentAccess
|
||||
@@ -351,6 +460,9 @@ class DocumentAccessSerializer(serializers.ModelSerializer):
|
||||
"abilities",
|
||||
"max_ancestors_role",
|
||||
"max_role",
|
||||
"encrypted_document_symmetric_key_for_user",
|
||||
"encryption_public_key_fingerprint",
|
||||
"is_pending_encryption",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
@@ -358,8 +470,46 @@ class DocumentAccessSerializer(serializers.ModelSerializer):
|
||||
"abilities",
|
||||
"max_ancestors_role",
|
||||
"max_role",
|
||||
"is_pending_encryption",
|
||||
]
|
||||
|
||||
def get_is_pending_encryption(self, instance):
|
||||
"""True when the parent document is encrypted but this access has
|
||||
no wrapped key — the user was added before completing their
|
||||
encryption onboarding. A validated collaborator must "accept"
|
||||
them (re-wrap the key) before they can decrypt.
|
||||
"""
|
||||
document = instance.document
|
||||
return bool(
|
||||
getattr(document, "is_encrypted", False)
|
||||
and instance.encrypted_document_symmetric_key_for_user is None
|
||||
)
|
||||
|
||||
def get_fields(self):
|
||||
"""Dynamically adjust encryption fields based on document state.
|
||||
|
||||
For encrypted documents the key is OPTIONAL at serializer level:
|
||||
the viewset decides whether omitting it is legitimate (invitee
|
||||
has no public key yet → access created pending) or a 400 (field
|
||||
provided against a non-encrypted document). For non-encrypted
|
||||
documents the field is hidden entirely.
|
||||
"""
|
||||
fields = super().get_fields()
|
||||
|
||||
# Get the document from context (if available)
|
||||
document = None
|
||||
if "view" in self.context and hasattr(self.context["view"], "document"):
|
||||
document = self.context["view"].document
|
||||
|
||||
if (
|
||||
document
|
||||
and not getattr(document, "is_encrypted", False)
|
||||
and "encrypted_document_symmetric_key_for_user" in fields
|
||||
):
|
||||
fields.pop("encrypted_document_symmetric_key_for_user", None)
|
||||
|
||||
return fields
|
||||
|
||||
def get_abilities(self, instance) -> dict:
|
||||
"""Return abilities of the logged-in user on the instance."""
|
||||
request = self.context.get("request")
|
||||
@@ -461,7 +611,9 @@ class ServerCreateDocumentSerializer(serializers.Serializer):
|
||||
language = user.language or language
|
||||
|
||||
try:
|
||||
document_content = YdocConverter().convert(validated_data["content"])
|
||||
document_content = Converter().convert(
|
||||
validated_data["content"], mime_types.MARKDOWN, mime_types.YJS
|
||||
)
|
||||
except ConversionError as err:
|
||||
raise serializers.ValidationError(
|
||||
{"content": ["Could not convert content"]}
|
||||
@@ -608,6 +760,7 @@ class FileUploadSerializer(serializers.Serializer):
|
||||
"""Receive file upload requests."""
|
||||
|
||||
file = serializers.FileField()
|
||||
is_encrypted = serializers.BooleanField(default=False, required=False)
|
||||
|
||||
def validate_file(self, file):
|
||||
"""Add file size and type constraints as defined in settings."""
|
||||
@@ -618,6 +771,22 @@ class FileUploadSerializer(serializers.Serializer):
|
||||
f"File size exceeds the maximum limit of {max_size:d} MB."
|
||||
)
|
||||
|
||||
# For encrypted files, the content is ciphertext so MIME detection
|
||||
# is not possible. Trust the original filename extension.
|
||||
if self.initial_data.get("is_encrypted") in ("true", "True", True):
|
||||
extension = (
|
||||
file.name.rpartition(".")[-1] if "." in file.name else None
|
||||
)
|
||||
if extension is None or len(extension) > 5:
|
||||
raise serializers.ValidationError(
|
||||
"Could not determine file extension."
|
||||
)
|
||||
self.context["expected_extension"] = extension
|
||||
self.context["content_type"] = "application/octet-stream"
|
||||
self.context["is_unsafe"] = False
|
||||
self.context["file_name"] = file.name
|
||||
return file
|
||||
|
||||
extension = file.name.rpartition(".")[-1] if "." in file.name else None
|
||||
|
||||
# Read the first few bytes to determine the MIME type accurately
|
||||
@@ -660,52 +829,6 @@ class FileUploadSerializer(serializers.Serializer):
|
||||
return attrs
|
||||
|
||||
|
||||
class TemplateSerializer(serializers.ModelSerializer):
|
||||
"""Serialize templates."""
|
||||
|
||||
abilities = serializers.SerializerMethodField(read_only=True)
|
||||
accesses = TemplateAccessSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.Template
|
||||
fields = [
|
||||
"id",
|
||||
"title",
|
||||
"accesses",
|
||||
"abilities",
|
||||
"css",
|
||||
"code",
|
||||
"is_public",
|
||||
]
|
||||
read_only_fields = ["id", "accesses", "abilities"]
|
||||
|
||||
def get_abilities(self, document) -> dict:
|
||||
"""Return abilities of the logged-in user on the instance."""
|
||||
request = self.context.get("request")
|
||||
if request:
|
||||
return document.get_abilities(request.user)
|
||||
return {}
|
||||
|
||||
|
||||
# pylint: disable=abstract-method
|
||||
class DocumentGenerationSerializer(serializers.Serializer):
|
||||
"""Serializer to receive a request to generate a document on a template."""
|
||||
|
||||
body = serializers.CharField(label=_("Body"))
|
||||
body_type = serializers.ChoiceField(
|
||||
choices=["html", "markdown"],
|
||||
label=_("Body type"),
|
||||
required=False,
|
||||
default="html",
|
||||
)
|
||||
format = serializers.ChoiceField(
|
||||
choices=["pdf", "docx"],
|
||||
label=_("Format"),
|
||||
required=False,
|
||||
default="pdf",
|
||||
)
|
||||
|
||||
|
||||
class InvitationSerializer(serializers.ModelSerializer):
|
||||
"""Serialize invitations."""
|
||||
|
||||
@@ -894,6 +1017,126 @@ class MoveDocumentSerializer(serializers.Serializer):
|
||||
)
|
||||
|
||||
|
||||
class EncryptDocumentSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for encrypting a document.
|
||||
|
||||
Fields:
|
||||
- content (CharField): The encrypted content of the document.
|
||||
This field is required.
|
||||
- encryptedSymmetricKeyPerUser (DictField): Mapping of user IDs to their encrypted symmetric keys.
|
||||
This field is required.
|
||||
|
||||
Example:
|
||||
Input payload for encrypting a document:
|
||||
{
|
||||
"content": "<encrypted_content>",
|
||||
"encryptedSymmetricKeyPerUser": {
|
||||
"user1_id": "encrypted_key_1",
|
||||
"user2_id": "encrypted_key_2"
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
content = serializers.CharField(required=True)
|
||||
# Value is either a base64 wrapped key (validated user) or explicit
|
||||
# null (user is on the access list but has no public key yet — access
|
||||
# row is created pending, to be "accepted" later by another validated
|
||||
# collaborator via PATCH /accesses/{id}/encryption-key/).
|
||||
encryptedSymmetricKeyPerUser = serializers.DictField(
|
||||
child=serializers.CharField(allow_null=True),
|
||||
required=True,
|
||||
help_text=(
|
||||
"Mapping of user OIDC sub → wrapped symmetric key (base64), "
|
||||
"or null to mark the user as pending their encryption "
|
||||
"onboarding. The caller's own sub must always be a wrapped "
|
||||
"key, never null."
|
||||
),
|
||||
)
|
||||
# Required: matched to the wrapped-key map. Every user sub present
|
||||
# in `encryptedSymmetricKeyPerUser` must also appear here with the
|
||||
# fingerprint of the public key used to wrap their copy (or null
|
||||
# for pending users with no public key yet). Stored on the access
|
||||
# row verbatim so clients can later tell which key each user's
|
||||
# wrapped key was produced for — used by the key-mismatch panel
|
||||
# to display "Fingerprint at the time it was shared with you".
|
||||
#
|
||||
# Not security-sensitive in the crypto sense — the actual wrap is
|
||||
# the wrapped key itself. The fingerprint is a display hint; a
|
||||
# malicious client could send wrong values but the worst it
|
||||
# achieves is confusing the user whose client was lying.
|
||||
encryptionPublicKeyFingerprintPerUser = serializers.DictField(
|
||||
child=serializers.CharField(
|
||||
allow_null=True, allow_blank=True, max_length=16
|
||||
),
|
||||
required=True,
|
||||
help_text=(
|
||||
"Mapping of user OIDC sub → fingerprint of their public key "
|
||||
"at encryption time. Must cover the same set of users as "
|
||||
"`encryptedSymmetricKeyPerUser`; null is valid for pending "
|
||||
"users."
|
||||
),
|
||||
)
|
||||
attachmentKeyMapping = serializers.DictField(
|
||||
child=serializers.CharField(),
|
||||
required=False,
|
||||
default=dict,
|
||||
help_text="Mapping of original attachment key to new encrypted attachment key. "
|
||||
"During encryption, existing attachments are uploaded encrypted under new keys. "
|
||||
"This mapping tells the backend to copy each new key over the original and clean up.",
|
||||
)
|
||||
|
||||
|
||||
# pylint: disable=abstract-method
|
||||
class AcceptEncryptionAccessSerializer(serializers.Serializer):
|
||||
"""Payload for PATCH /accesses/{id}/encryption-key/ — "accept" a
|
||||
pending collaborator by re-wrapping the document's symmetric key
|
||||
against their (now-available) public key.
|
||||
"""
|
||||
|
||||
encrypted_document_symmetric_key_for_user = serializers.CharField(
|
||||
required=True,
|
||||
allow_null=False,
|
||||
allow_blank=False,
|
||||
help_text=(
|
||||
"Wrapped symmetric key for the pending user, base64-encoded. "
|
||||
"Null / empty is not allowed: this endpoint only flips "
|
||||
"pending → validated. To revert, delete the access row."
|
||||
),
|
||||
)
|
||||
encryption_public_key_fingerprint = serializers.CharField(
|
||||
required=True,
|
||||
allow_blank=False,
|
||||
max_length=16,
|
||||
)
|
||||
|
||||
|
||||
class RemoveEncryptionSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for removing encryption from a document.
|
||||
|
||||
Fields:
|
||||
- content (CharField): The decrypted content of the document.
|
||||
This field is required.
|
||||
|
||||
Example:
|
||||
Input payload for removing encryption from a document:
|
||||
{
|
||||
"content": "<decrypted_content>"
|
||||
}
|
||||
"""
|
||||
|
||||
content = serializers.CharField(required=True)
|
||||
attachmentKeyMapping = serializers.DictField(
|
||||
child=serializers.CharField(),
|
||||
required=False,
|
||||
default=dict,
|
||||
help_text="Mapping of old encrypted attachment key to new decrypted attachment key. "
|
||||
"During decryption, encrypted attachments are re-uploaded decrypted under new keys. "
|
||||
"This mapping tells the backend to remove the old keys and clean up.",
|
||||
)
|
||||
|
||||
|
||||
class ReactionSerializer(serializers.ModelSerializer):
|
||||
"""Serialize reactions."""
|
||||
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
# pylint: disable=too-many-lines
|
||||
|
||||
import base64
|
||||
import ipaddress
|
||||
import json
|
||||
import logging
|
||||
import socket
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from urllib.parse import unquote, urlencode, urlparse
|
||||
@@ -41,17 +43,19 @@ from rest_framework.permissions import AllowAny
|
||||
|
||||
from core import authentication, choices, enums, models
|
||||
from core.api.filters import remove_accents
|
||||
from core.services import mime_types
|
||||
from core.services.ai_services import AIService
|
||||
from core.services.collaboration_services import CollaborationService
|
||||
from core.services.converter_services import (
|
||||
ConversionError,
|
||||
Converter,
|
||||
)
|
||||
from core.services.converter_services import (
|
||||
ServiceUnavailableError as YProviderServiceUnavailableError,
|
||||
)
|
||||
from core.services.converter_services import (
|
||||
ValidationError as YProviderValidationError,
|
||||
)
|
||||
from core.services.converter_services import (
|
||||
YdocConverter,
|
||||
)
|
||||
from core.services.search_indexers import (
|
||||
get_document_indexer,
|
||||
get_visited_document_ids_of,
|
||||
@@ -164,6 +168,10 @@ class UserViewSet(
|
||||
):
|
||||
"""User ViewSet"""
|
||||
|
||||
#
|
||||
# TODO: adjust update public key
|
||||
#
|
||||
|
||||
permission_classes = [permissions.IsSelf]
|
||||
queryset = models.User.objects.filter(is_active=True)
|
||||
serializer_class = serializers.UserSerializer
|
||||
@@ -350,6 +358,19 @@ class DocumentViewSet(
|
||||
Returns: JSON response with the translated text.
|
||||
Throttled by: AIDocumentRateThrottle, AIUserRateThrottle.
|
||||
|
||||
12. **Encrypt**: Encrypt a document.
|
||||
Example: PATCH /documents/{id}/encrypt/
|
||||
Expected data:
|
||||
- content (str): The encrypted content.
|
||||
- encryptedSymmetricKeyPerUser (dict): Mapping of user IDs to encrypted symmetric keys.
|
||||
Returns: JSON response with the updated document.
|
||||
|
||||
13. **Remove Encryption**: Remove encryption from a document.
|
||||
Example: PATCH /documents/{id}/remove-encryption/
|
||||
Expected data:
|
||||
- content (str): The decrypted content.
|
||||
Returns: JSON response with the updated document.
|
||||
|
||||
### Ordering: created_at, updated_at, is_favorite, title
|
||||
|
||||
Example:
|
||||
@@ -361,11 +382,18 @@ class DocumentViewSet(
|
||||
- `is_creator_me=false`: Returns documents created by other users.
|
||||
- `is_favorite=true`: Returns documents marked as favorite by the current user
|
||||
- `is_favorite=false`: Returns documents not marked as favorite by the current user
|
||||
- `is_encrypted=true`: Returns documents encrypted
|
||||
- `is_encrypted=false`: Returns documents not encrypted
|
||||
- `title=hello`: Returns documents which title contains the "hello" string
|
||||
|
||||
Example:
|
||||
- GET /api/v1.0/documents/?is_creator_me=true&is_favorite=true
|
||||
- GET /api/v1.0/documents/?is_creator_me=false&title=hello
|
||||
- GET /api/v1.0/documents/?is_creator_me=false&title=hello&is_encrypted=false
|
||||
|
||||
### Encryption Management:
|
||||
The encryption status of documents can be managed using the dedicated endpoints:
|
||||
- PATCH /documents/{id}/encrypt/ - Set is_encrypted to true
|
||||
- PATCH /documents/{id}/remove-encryption/ - Set is_encrypted to false
|
||||
|
||||
### Annotations:
|
||||
1. **is_favorite**: Indicates whether the document is marked as favorite by the current user.
|
||||
@@ -388,6 +416,7 @@ class DocumentViewSet(
|
||||
queryset = models.Document.objects.select_related("creator").all()
|
||||
serializer_class = serializers.DocumentSerializer
|
||||
ai_translate_serializer_class = serializers.AITranslateSerializer
|
||||
all_serializer_class = serializers.ListDocumentSerializer
|
||||
children_serializer_class = serializers.ListDocumentSerializer
|
||||
descendants_serializer_class = serializers.ListDocumentSerializer
|
||||
list_serializer_class = serializers.ListDocumentSerializer
|
||||
@@ -524,6 +553,28 @@ class DocumentViewSet(
|
||||
"IN SHARE ROW EXCLUSIVE MODE;"
|
||||
)
|
||||
|
||||
# Remove file from validated_data as it's not a model field
|
||||
# Process it if present
|
||||
uploaded_file = serializer.validated_data.pop("file", None)
|
||||
|
||||
# If a file is uploaded, convert it to Yjs format and set as content
|
||||
if uploaded_file:
|
||||
try:
|
||||
file_content = uploaded_file.read()
|
||||
|
||||
converter = Converter()
|
||||
converted_content = converter.convert(
|
||||
file_content,
|
||||
content_type=uploaded_file.content_type,
|
||||
accept=mime_types.YJS,
|
||||
)
|
||||
serializer.validated_data["content"] = converted_content
|
||||
serializer.validated_data["title"] = uploaded_file.name
|
||||
except ConversionError as err:
|
||||
raise drf.exceptions.ValidationError(
|
||||
{"file": ["Could not convert file content"]}
|
||||
) from err
|
||||
|
||||
obj = models.Document.add_root(
|
||||
creator=self.request.user,
|
||||
**serializer.validated_data,
|
||||
@@ -586,6 +637,20 @@ class DocumentViewSet(
|
||||
|
||||
def perform_update(self, serializer):
|
||||
"""Check rules about collaboration."""
|
||||
content_encrypted = serializer.validated_data.pop("contentEncrypted", None)
|
||||
if (
|
||||
content_encrypted is not None
|
||||
and content_encrypted != serializer.instance.is_encrypted
|
||||
):
|
||||
raise drf.exceptions.ValidationError(
|
||||
{
|
||||
"contentEncrypted": (
|
||||
"Content encryption status does not match the document's "
|
||||
"current state. Please refresh and try again."
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
if (
|
||||
serializer.validated_data.get("websocket", False)
|
||||
or not settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY
|
||||
@@ -625,12 +690,29 @@ class DocumentViewSet(
|
||||
"""Get list of favorite documents for the current user."""
|
||||
user = request.user
|
||||
|
||||
queryset = self.get_queryset()
|
||||
|
||||
# Among the results, we may have documents that are ancestors/descendants
|
||||
# of each other. In this case we want to keep only the highest ancestors.
|
||||
root_paths = utils.filter_root_paths(
|
||||
queryset.order_by("path").values_list("path", flat=True),
|
||||
skip_sorting=True,
|
||||
)
|
||||
|
||||
path_list = db.Q()
|
||||
for path in root_paths:
|
||||
path_list |= db.Q(path__startswith=path)
|
||||
|
||||
favorite_documents_ids = models.DocumentFavorite.objects.filter(
|
||||
user=user
|
||||
).values_list("document_id", flat=True)
|
||||
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
queryset = self.queryset.filter(path_list)
|
||||
queryset = queryset.filter(id__in=favorite_documents_ids)
|
||||
queryset = queryset.annotate_user_roles(user)
|
||||
queryset = queryset.annotate(
|
||||
is_favorite=db.Value(True, output_field=db.BooleanField())
|
||||
)
|
||||
return self.get_response_for_queryset(queryset)
|
||||
|
||||
@drf.decorators.action(
|
||||
@@ -858,6 +940,60 @@ class DocumentViewSet(
|
||||
},
|
||||
)
|
||||
|
||||
@drf.decorators.action(
|
||||
detail=False,
|
||||
methods=["get"],
|
||||
)
|
||||
def all(self, request, *args, **kwargs):
|
||||
"""
|
||||
Returns all documents (including descendants) that the user has access to.
|
||||
|
||||
Unlike the list endpoint which only returns top-level documents, this endpoint
|
||||
returns all documents including children, grandchildren, etc.
|
||||
"""
|
||||
user = self.request.user
|
||||
|
||||
accessible_documents = self.get_queryset()
|
||||
accessible_paths = list(accessible_documents.values_list("path", flat=True))
|
||||
|
||||
if not accessible_paths:
|
||||
return self.get_response_for_queryset(self.queryset.none())
|
||||
|
||||
# Build query to include all descendants using path prefix matching
|
||||
descendants_clause = db.Q()
|
||||
for path in accessible_paths:
|
||||
descendants_clause |= db.Q(path__startswith=path)
|
||||
|
||||
queryset = self.queryset.filter(
|
||||
descendants_clause, ancestors_deleted_at__isnull=True
|
||||
)
|
||||
|
||||
# Apply existing filters
|
||||
filterset = ListDocumentFilter(
|
||||
self.request.GET, queryset=queryset, request=self.request
|
||||
)
|
||||
if not filterset.is_valid():
|
||||
raise drf.exceptions.ValidationError(filterset.errors)
|
||||
filter_data = filterset.form.cleaned_data
|
||||
|
||||
# Filter as early as possible on fields that are available on the model
|
||||
for field in ["is_creator_me", "title"]:
|
||||
queryset = filterset.filters[field].filter(queryset, filter_data[field])
|
||||
|
||||
queryset = queryset.annotate_user_roles(user)
|
||||
|
||||
# Annotate favorite status and filter if applicable as late as possible
|
||||
queryset = queryset.annotate_is_favorite(user)
|
||||
for field in ["is_favorite", "is_masked"]:
|
||||
queryset = filterset.filters[field].filter(queryset, filter_data[field])
|
||||
|
||||
# Apply ordering only now that everything is filtered and annotated
|
||||
queryset = filters.OrderingFilter().filter_queryset(
|
||||
self.request, queryset, self
|
||||
)
|
||||
|
||||
return self.get_response_for_queryset(queryset)
|
||||
|
||||
@drf.decorators.action(
|
||||
detail=True,
|
||||
methods=["get"],
|
||||
@@ -1249,6 +1385,14 @@ class DocumentViewSet(
|
||||
# Check permissions first
|
||||
document = self.get_object()
|
||||
|
||||
if document.is_encrypted:
|
||||
raise drf.exceptions.ValidationError(
|
||||
{
|
||||
"detail": "Visibility cannot be changed for encrypted documents. "
|
||||
"Encrypted documents must remain restricted.",
|
||||
}
|
||||
)
|
||||
|
||||
# Deserialize and validate the data
|
||||
serializer = serializers.LinkDocumentSerializer(
|
||||
document, data=request.data, partial=True
|
||||
@@ -1344,18 +1488,34 @@ class DocumentViewSet(
|
||||
serializer = serializers.FileUploadSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
# Generate a generic yet unique filename to store the image in object storage
|
||||
file_id = uuid.uuid4()
|
||||
ext = serializer.validated_data["expected_extension"]
|
||||
# Normally encrypted attachments would be only allowed on encrypted documents and vice-versa
|
||||
# but since during encryption/decryption we upload all attachments before the switch, we cannot enforce this rule
|
||||
is_file_encrypted = serializer.validated_data.get("is_encrypted", False)
|
||||
|
||||
# For encrypted files, set status to READY immediately since the server
|
||||
# cannot inspect ciphertext for malware scanning.
|
||||
initial_status = (
|
||||
enums.DocumentAttachmentStatus.READY
|
||||
if is_file_encrypted
|
||||
else enums.DocumentAttachmentStatus.PROCESSING
|
||||
)
|
||||
|
||||
# Prepare metadata for storage
|
||||
extra_args = {
|
||||
"Metadata": {
|
||||
"owner": str(request.user.id),
|
||||
"status": enums.DocumentAttachmentStatus.PROCESSING,
|
||||
"status": initial_status,
|
||||
},
|
||||
"ContentType": serializer.validated_data["content_type"],
|
||||
}
|
||||
|
||||
if is_file_encrypted:
|
||||
extra_args["Metadata"]["is_encrypted"] = "true"
|
||||
|
||||
# Generate a generic yet unique filename to store the image in object storage
|
||||
file_id = uuid.uuid4()
|
||||
ext = serializer.validated_data["expected_extension"]
|
||||
|
||||
file_unsafe = ""
|
||||
if serializer.validated_data["is_unsafe"]:
|
||||
extra_args["Metadata"]["is_unsafe"] = "true"
|
||||
@@ -1385,7 +1545,9 @@ class DocumentViewSet(
|
||||
document.attachments.append(key)
|
||||
document.save()
|
||||
|
||||
malware_detection.analyse_file(key, document_id=document.id)
|
||||
# Only run malware scan for unencrypted files
|
||||
if not is_file_encrypted:
|
||||
malware_detection.analyse_file(key, document_id=document.id)
|
||||
|
||||
url = reverse(
|
||||
"documents-media-check",
|
||||
@@ -1600,6 +1762,101 @@ class DocumentViewSet(
|
||||
|
||||
return drf.response.Response(response, status=drf.status.HTTP_200_OK)
|
||||
|
||||
def _reject_invalid_ips(self, ips):
|
||||
"""
|
||||
Check if an IP address is safe from SSRF attacks.
|
||||
|
||||
Raises:
|
||||
drf.exceptions.ValidationError: If the IP is unsafe
|
||||
"""
|
||||
for ip in ips:
|
||||
# Block loopback addresses (check before private,
|
||||
# as 127.0.0.1 might be considered private)
|
||||
if ip.is_loopback:
|
||||
raise drf.exceptions.ValidationError(
|
||||
"Access to loopback addresses is not allowed"
|
||||
)
|
||||
|
||||
# Block link-local addresses (169.254.0.0/16) - check before private
|
||||
if ip.is_link_local:
|
||||
raise drf.exceptions.ValidationError(
|
||||
"Access to link-local addresses is not allowed"
|
||||
)
|
||||
|
||||
# Block private IP ranges
|
||||
if ip.is_private:
|
||||
raise drf.exceptions.ValidationError(
|
||||
"Access to private IP addresses is not allowed"
|
||||
)
|
||||
|
||||
# Block multicast addresses
|
||||
if ip.is_multicast:
|
||||
raise drf.exceptions.ValidationError(
|
||||
"Access to multicast addresses is not allowed"
|
||||
)
|
||||
|
||||
# Block reserved addresses (including 0.0.0.0)
|
||||
if ip.is_reserved:
|
||||
raise drf.exceptions.ValidationError(
|
||||
"Access to reserved IP addresses is not allowed"
|
||||
)
|
||||
|
||||
def _validate_url_against_ssrf(self, url):
|
||||
"""
|
||||
Validate that a URL is safe from SSRF (Server-Side Request Forgery) attacks.
|
||||
|
||||
Blocks:
|
||||
- localhost and its variations
|
||||
- Private IP ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)
|
||||
- Link-local addresses (169.254.0.0/16)
|
||||
- Loopback addresses
|
||||
|
||||
Raises:
|
||||
drf.exceptions.ValidationError: If the URL is unsafe
|
||||
"""
|
||||
parsed = urlparse(url)
|
||||
hostname = parsed.hostname
|
||||
|
||||
if not hostname:
|
||||
raise drf.exceptions.ValidationError("Invalid hostname")
|
||||
|
||||
# Resolve hostname to IP address(es)
|
||||
# Check all resolved IPs to prevent DNS rebinding attacks
|
||||
try:
|
||||
# Try to parse as IP address first (if hostname is already an IP)
|
||||
try:
|
||||
ip = ipaddress.ip_address(hostname)
|
||||
resolved_ips = [ip]
|
||||
except ValueError:
|
||||
# Resolve hostname to IP addresses (supports both IPv4 and IPv6)
|
||||
resolved_ips = []
|
||||
try:
|
||||
# Get all address info (IPv4 and IPv6)
|
||||
addr_info = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC)
|
||||
for family, _, _, _, sockaddr in addr_info:
|
||||
if family == socket.AF_INET:
|
||||
# IPv4
|
||||
ip = ipaddress.ip_address(sockaddr[0])
|
||||
resolved_ips.append(ip)
|
||||
elif family == socket.AF_INET6:
|
||||
# IPv6
|
||||
ip = ipaddress.ip_address(sockaddr[0])
|
||||
resolved_ips.append(ip)
|
||||
except (socket.gaierror, OSError) as e:
|
||||
raise drf.exceptions.ValidationError(
|
||||
f"Failed to resolve hostname: {str(e)}"
|
||||
) from e
|
||||
|
||||
if not resolved_ips:
|
||||
raise drf.exceptions.ValidationError(
|
||||
"No IP addresses found for hostname"
|
||||
) from None
|
||||
except ValueError as e:
|
||||
raise drf.exceptions.ValidationError(f"Invalid IP address: {str(e)}") from e
|
||||
|
||||
# Check all resolved IPs to ensure none are private/internal
|
||||
self._reject_invalid_ips(resolved_ips)
|
||||
|
||||
@drf.decorators.action(
|
||||
detail=True,
|
||||
methods=["get"],
|
||||
@@ -1633,6 +1890,16 @@ class DocumentViewSet(
|
||||
status=drf.status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Validate URL against SSRF attacks
|
||||
try:
|
||||
self._validate_url_against_ssrf(url)
|
||||
except drf.exceptions.ValidationError as e:
|
||||
logger.error("Potential SSRF attack detected: %s", e)
|
||||
return drf.response.Response(
|
||||
{"detail": "Invalid URL used."},
|
||||
status=drf.status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
response = requests.get(
|
||||
url,
|
||||
@@ -1641,13 +1908,15 @@ class DocumentViewSet(
|
||||
"User-Agent": request.headers.get("User-Agent", ""),
|
||||
"Accept": request.headers.get("Accept", ""),
|
||||
},
|
||||
allow_redirects=False,
|
||||
timeout=10,
|
||||
)
|
||||
response.raise_for_status()
|
||||
content_type = response.headers.get("Content-Type", "")
|
||||
|
||||
if not content_type.startswith("image/"):
|
||||
return drf.response.Response(
|
||||
status=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE
|
||||
{"detail": "Invalid URL used."}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Use StreamingHttpResponse with the response's iter_content to properly stream the data
|
||||
@@ -1665,7 +1934,7 @@ class DocumentViewSet(
|
||||
except requests.RequestException as e:
|
||||
logger.exception(e)
|
||||
return drf.response.Response(
|
||||
{"error": f"Failed to fetch resource from {url}"},
|
||||
{"detail": "Invalid URL used."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@@ -1700,14 +1969,14 @@ class DocumentViewSet(
|
||||
if base64_content is not None:
|
||||
# Convert using the y-provider service
|
||||
try:
|
||||
yprovider = YdocConverter()
|
||||
yprovider = Converter()
|
||||
result = yprovider.convert(
|
||||
base64.b64decode(base64_content),
|
||||
"application/vnd.yjs.doc",
|
||||
mime_types.YJS,
|
||||
{
|
||||
"markdown": "text/markdown",
|
||||
"html": "text/html",
|
||||
"json": "application/json",
|
||||
"markdown": mime_types.MARKDOWN,
|
||||
"html": mime_types.HTML,
|
||||
"json": mime_types.JSON,
|
||||
}[content_format],
|
||||
)
|
||||
content = result
|
||||
@@ -1732,6 +2001,250 @@ class DocumentViewSet(
|
||||
}
|
||||
)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
"""
|
||||
Perform update with safety check for encryption state changes.
|
||||
|
||||
If contentEncrypted parameter is provided, it must match the current
|
||||
is_encrypted state to prevent accidental content overrides during
|
||||
encryption state transitions.
|
||||
"""
|
||||
document = self.get_object()
|
||||
|
||||
# Prevent direct changes to is_encrypted field via PATCH
|
||||
# (encryption state should only be changed via /encrypt/ or /remove-encryption/ endpoints)
|
||||
if 'is_encrypted' in serializer.validated_data:
|
||||
raise drf.exceptions.ValidationError({
|
||||
'is_encrypted':
|
||||
'Cannot modify is_encrypted directly. '
|
||||
'Use the /encrypt/ or /remove-encryption/ endpoints to manage encryption.'
|
||||
})
|
||||
|
||||
# Check if contentEncrypted parameter was provided
|
||||
content_encrypted = serializer.validated_data.get('contentEncrypted')
|
||||
|
||||
if content_encrypted is not None:
|
||||
# Get the current document instance
|
||||
document = self.get_object()
|
||||
|
||||
# Safety check: contentEncrypted must match current is_encrypted state
|
||||
if content_encrypted != document.is_encrypted:
|
||||
raise drf.exceptions.ValidationError({
|
||||
'contentEncrypted':
|
||||
f'contentEncrypted must match current encryption state. '
|
||||
f'Current: is_encrypted={document.is_encrypted}, '
|
||||
f'Provided: contentEncrypted={content_encrypted}'
|
||||
})
|
||||
|
||||
# Proceed with normal update
|
||||
return super().perform_update(serializer)
|
||||
|
||||
@transaction.atomic
|
||||
@drf.decorators.action(
|
||||
detail=True,
|
||||
methods=["patch"],
|
||||
name="Encrypt a document",
|
||||
url_path="encrypt",
|
||||
)
|
||||
def encrypt(self, request, *args, **kwargs):
|
||||
"""
|
||||
PATCH /api/v1.0/documents/<resource_id>/encrypt/
|
||||
with expected data:
|
||||
- content: str (encrypted content)
|
||||
- encryptedSymmetricKeyPerUser: dict (user_id -> encrypted_key)
|
||||
Updates the document's content and marks it as encrypted.
|
||||
"""
|
||||
document = self.get_object()
|
||||
|
||||
serializer = serializers.EncryptDocumentSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
content = serializer.validated_data["content"]
|
||||
encryptedSymmetricKeyPerUser = serializer.validated_data["encryptedSymmetricKeyPerUser"]
|
||||
attachment_key_mapping = serializer.validated_data.get("attachmentKeyMapping", {})
|
||||
|
||||
# Prevent encryption if the document is not restricted (private)
|
||||
if document.computed_link_reach != models.LinkReachChoices.RESTRICTED:
|
||||
raise drf.exceptions.ValidationError({
|
||||
'non_field_errors':
|
||||
'Cannot encrypt a document that is not private. '
|
||||
'Please set the document access to "Restricted" before encrypting.'
|
||||
})
|
||||
|
||||
# Prevent encryption if there are pending invitations
|
||||
if document.invitations.exists():
|
||||
raise drf.exceptions.ValidationError({
|
||||
'non_field_errors':
|
||||
'Cannot encrypt a document with pending invitations. '
|
||||
'Please resolve all invitations before encrypting.'
|
||||
})
|
||||
|
||||
# Validate that we have encrypted symmetric keys for all users with access.
|
||||
# Keys in encryptedSymmetricKeyPerUser are keyed by the user's OIDC sub (suite_user_id).
|
||||
# Values may be a wrapped key (validated) or explicit null (pending —
|
||||
# user hasn't completed their encryption onboarding yet).
|
||||
document_accesses = models.DocumentAccess.objects.filter(
|
||||
document=document, user__isnull=False
|
||||
).select_related('user')
|
||||
|
||||
users_with_access = {str(access.user.sub) for access in document_accesses}
|
||||
|
||||
# Check that encryptedSymmetricKeyPerUser contains all required users
|
||||
provided_user_ids = set(encryptedSymmetricKeyPerUser.keys())
|
||||
missing_users = users_with_access - provided_user_ids
|
||||
|
||||
if missing_users:
|
||||
raise drf.exceptions.ValidationError({
|
||||
'encryptedSymmetricKeyPerUser':
|
||||
f'Missing encrypted keys for users with document access: {missing_users}. '
|
||||
f'All users must have an entry (either a wrapped key or null) when encrypting.'
|
||||
})
|
||||
|
||||
# Check for extra users that don't have access
|
||||
extra_users = provided_user_ids - users_with_access
|
||||
if extra_users:
|
||||
raise drf.exceptions.ValidationError({
|
||||
'encryptedSymmetricKeyPerUser':
|
||||
f'Encrypted keys provided for users without document access: {extra_users}. '
|
||||
f'Only users with access should have encrypted symmetric keys.'
|
||||
})
|
||||
|
||||
# The caller is the one performing the encryption — they must
|
||||
# hold the key. Explicit null for themselves is never legitimate.
|
||||
caller_sub = str(request.user.sub)
|
||||
if (
|
||||
caller_sub in encryptedSymmetricKeyPerUser
|
||||
and encryptedSymmetricKeyPerUser[caller_sub] is None
|
||||
):
|
||||
raise drf.exceptions.ValidationError({
|
||||
'encryptedSymmetricKeyPerUser':
|
||||
'You cannot mark yourself as pending encryption onboarding — '
|
||||
'provide a wrapped key for your own user.'
|
||||
})
|
||||
|
||||
# Per-user fingerprint map — required, keyed on the same user
|
||||
# subs as the wrapped-key map. Stored verbatim on the access
|
||||
# row so clients can later tell which key each user's wrapped
|
||||
# key was produced for.
|
||||
fingerprint_per_user = serializer.validated_data[
|
||||
'encryptionPublicKeyFingerprintPerUser'
|
||||
]
|
||||
fingerprint_subs = set(fingerprint_per_user.keys())
|
||||
if fingerprint_subs != provided_user_ids:
|
||||
raise drf.exceptions.ValidationError({
|
||||
'encryptionPublicKeyFingerprintPerUser':
|
||||
'Must cover the same set of users as encryptedSymmetricKeyPerUser. '
|
||||
f'Missing: {provided_user_ids - fingerprint_subs}. '
|
||||
f'Extra: {fingerprint_subs - provided_user_ids}.'
|
||||
})
|
||||
|
||||
# Remove old unencrypted attachment keys from the allowed list.
|
||||
# The frontend uploaded encrypted copies under new keys and updated the
|
||||
# Yjs content to reference them.
|
||||
if attachment_key_mapping:
|
||||
old_keys = set(attachment_key_mapping.keys())
|
||||
document.attachments = [
|
||||
k for k in (document.attachments or []) if k not in old_keys
|
||||
]
|
||||
|
||||
# Update the document content and encryption status
|
||||
document.content = content # This will be cached and saved to object storage
|
||||
document.is_encrypted = True
|
||||
document.save()
|
||||
|
||||
# Clean up old S3 objects only after the DB transaction has committed,
|
||||
# so a deletion failure can never affect the encrypt operation.
|
||||
if attachment_key_mapping:
|
||||
def _cleanup_old_attachments():
|
||||
s3_client = default_storage.connection.meta.client
|
||||
bucket_name = default_storage.bucket_name
|
||||
for old_key in attachment_key_mapping:
|
||||
try:
|
||||
s3_client.delete_object(Bucket=bucket_name, Key=old_key)
|
||||
except ClientError:
|
||||
logger.warning("Failed to delete old attachment %s", old_key)
|
||||
|
||||
transaction.on_commit(_cleanup_old_attachments)
|
||||
|
||||
# Store the encrypted symmetric keys + fingerprints in
|
||||
# DocumentAccess for each user. Keys are keyed by the user's
|
||||
# OIDC `sub`, so look up by user__sub.
|
||||
for sub, encrypted_key in encryptedSymmetricKeyPerUser.items():
|
||||
try:
|
||||
access = models.DocumentAccess.objects.get(
|
||||
document=document, user__sub=sub,
|
||||
)
|
||||
access.encrypted_document_symmetric_key_for_user = encrypted_key
|
||||
access.encryption_public_key_fingerprint = (
|
||||
fingerprint_per_user.get(sub) or None
|
||||
)
|
||||
access.save()
|
||||
except models.DocumentAccess.DoesNotExist:
|
||||
# This should not happen due to our validation above, but keep as safety
|
||||
pass
|
||||
|
||||
# Return the updated document
|
||||
serializer = self.get_serializer(document)
|
||||
return drf.response.Response(serializer.data, status=drf.status.HTTP_200_OK)
|
||||
|
||||
@transaction.atomic
|
||||
@drf.decorators.action(
|
||||
detail=True,
|
||||
methods=["patch"],
|
||||
name="Remove encryption from a document",
|
||||
url_path="remove-encryption",
|
||||
)
|
||||
def remove_encryption(self, request, *args, **kwargs):
|
||||
"""
|
||||
PATCH /api/v1.0/documents/<resource_id>/remove-encryption/
|
||||
with expected data:
|
||||
- content: str (decrypted content)
|
||||
Updates the document's content and marks it as not encrypted.
|
||||
"""
|
||||
document = self.get_object()
|
||||
|
||||
serializer = serializers.RemoveEncryptionSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
content = serializer.validated_data["content"]
|
||||
attachment_key_mapping = serializer.validated_data.get("attachmentKeyMapping", {})
|
||||
|
||||
# Remove old encrypted attachment keys from the allowed list.
|
||||
# The frontend uploaded decrypted copies under new keys and updated
|
||||
# the Yjs content to reference them.
|
||||
if attachment_key_mapping:
|
||||
old_keys = set(attachment_key_mapping.keys())
|
||||
document.attachments = [
|
||||
k for k in (document.attachments or []) if k not in old_keys
|
||||
]
|
||||
|
||||
# Update the document content and encryption status
|
||||
document.content = content # This will be cached and saved to object storage
|
||||
document.is_encrypted = False
|
||||
document.save()
|
||||
|
||||
# Clean up any stored encrypted keys
|
||||
models.DocumentAccess.objects.filter(document=document).update(
|
||||
encrypted_document_symmetric_key_for_user=None
|
||||
)
|
||||
|
||||
# Clean up old S3 objects only after the DB transaction has committed
|
||||
if attachment_key_mapping:
|
||||
def _cleanup_old_attachments():
|
||||
s3_client = default_storage.connection.meta.client
|
||||
bucket_name = default_storage.bucket_name
|
||||
for old_key in attachment_key_mapping:
|
||||
try:
|
||||
s3_client.delete_object(Bucket=bucket_name, Key=old_key)
|
||||
except ClientError:
|
||||
logger.warning("Failed to delete old attachment %s", old_key)
|
||||
|
||||
transaction.on_commit(_cleanup_old_attachments)
|
||||
|
||||
# Return the updated document
|
||||
serializer = self.get_serializer(document)
|
||||
return drf.response.Response(serializer.data, status=drf.status.HTTP_200_OK)
|
||||
|
||||
|
||||
class DocumentAccessViewSet(
|
||||
ResourceAccessViewsetMixin,
|
||||
@@ -1893,6 +2406,31 @@ class DocumentAccessViewSet(
|
||||
"Only owners of a document can assign other users as owners."
|
||||
)
|
||||
|
||||
# Handle encrypted_document_symmetric_key_for_user during
|
||||
# creation. For encrypted documents the key is OPTIONAL: if the
|
||||
# invitee has no public key yet (pending onboarding) the caller
|
||||
# legitimately has nothing to wrap. The access row is then
|
||||
# created pending (key column NULL) and can be "accepted" later
|
||||
# via PATCH /accesses/{id}/encryption-key/. Whether the invitee
|
||||
# actually has a public key is a client-side concern — the
|
||||
# backend only enforces "key provided ⇒ document must be encrypted".
|
||||
if 'encrypted_document_symmetric_key_for_user' in serializer.validated_data:
|
||||
key_value = serializer.validated_data[
|
||||
'encrypted_document_symmetric_key_for_user'
|
||||
]
|
||||
if key_value and not self.document.is_encrypted:
|
||||
raise drf.exceptions.ValidationError({
|
||||
'encrypted_document_symmetric_key_for_user':
|
||||
'This field can only be provided when the document is encrypted.'
|
||||
})
|
||||
# Normalise "" → None so the DB row uses NULL consistently
|
||||
# and `is_pending_encryption` (which tests IS NULL) is
|
||||
# reliable downstream.
|
||||
if not key_value:
|
||||
serializer.validated_data[
|
||||
'encrypted_document_symmetric_key_for_user'
|
||||
] = None
|
||||
|
||||
access = serializer.save(document_id=self.kwargs["resource_id"])
|
||||
|
||||
if access.user:
|
||||
@@ -1907,6 +2445,14 @@ class DocumentAccessViewSet(
|
||||
|
||||
def perform_update(self, serializer):
|
||||
"""Update an access to the document and notify the collaboration server."""
|
||||
# Prevent direct modification of encrypted_document_symmetric_key_for_user
|
||||
# This field should only be managed at access creation or when rotating the document key
|
||||
if 'encrypted_document_symmetric_key_for_user' in serializer.validated_data:
|
||||
raise drf.exceptions.ValidationError({
|
||||
'encrypted_document_symmetric_key_for_user':
|
||||
'This field cannot be modified directly.'
|
||||
})
|
||||
|
||||
access = serializer.save()
|
||||
|
||||
access_user_id = None
|
||||
@@ -1920,6 +2466,13 @@ class DocumentAccessViewSet(
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
"""Delete an access to the document and notify the collaboration server."""
|
||||
# Strand-prevention: on an encrypted document, removing the last
|
||||
# access row that holds a wrapped key while other rows are
|
||||
# pending (`encrypted_document_symmetric_key_for_user IS NULL`)
|
||||
# would leave the document undecryptable by anyone — nobody
|
||||
# could "accept" the pending users afterwards.
|
||||
self._raise_if_would_strand_pending_users(instance)
|
||||
|
||||
instance.delete()
|
||||
|
||||
# Notify collaboration server about the access removed
|
||||
@@ -1927,63 +2480,122 @@ class DocumentAccessViewSet(
|
||||
str(instance.document.id), str(instance.user.id)
|
||||
)
|
||||
|
||||
def _raise_if_would_strand_pending_users(self, instance):
|
||||
"""Reject delete if it would leave pending users with nobody
|
||||
able to accept them. See the docstring in `perform_destroy`.
|
||||
"""
|
||||
document = instance.document
|
||||
if not getattr(document, "is_encrypted", False):
|
||||
return
|
||||
# Removing a row that's itself pending never strands anyone.
|
||||
if not instance.encrypted_document_symmetric_key_for_user:
|
||||
return
|
||||
|
||||
class TemplateViewSet(
|
||||
drf.mixins.RetrieveModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""Template ViewSet"""
|
||||
|
||||
filter_backends = [drf.filters.OrderingFilter]
|
||||
permission_classes = [
|
||||
permissions.IsAuthenticatedOrSafe,
|
||||
permissions.ResourceWithAccessPermission,
|
||||
]
|
||||
throttle_scope = "template"
|
||||
ordering = ["-created_at"]
|
||||
ordering_fields = ["created_at", "updated_at", "title"]
|
||||
serializer_class = serializers.TemplateSerializer
|
||||
queryset = models.Template.objects.all()
|
||||
|
||||
def get_queryset(self):
|
||||
"""Custom queryset to get user related templates."""
|
||||
queryset = super().get_queryset()
|
||||
user = self.request.user
|
||||
|
||||
if not user.is_authenticated:
|
||||
return queryset
|
||||
|
||||
user_roles_query = (
|
||||
models.TemplateAccess.objects.filter(
|
||||
db.Q(user=user) | db.Q(team__in=user.teams),
|
||||
template_id=db.OuterRef("pk"),
|
||||
other_accesses = models.DocumentAccess.objects.filter(
|
||||
document=document
|
||||
).exclude(pk=instance.pk)
|
||||
remaining_validated = (
|
||||
other_accesses.filter(
|
||||
encrypted_document_symmetric_key_for_user__isnull=False,
|
||||
)
|
||||
.values("template")
|
||||
.annotate(roles_array=ArrayAgg("role"))
|
||||
.values("roles_array")
|
||||
.exclude(encrypted_document_symmetric_key_for_user="")
|
||||
.exists()
|
||||
)
|
||||
return queryset.annotate(user_roles=db.Subquery(user_roles_query)).distinct()
|
||||
has_pending = other_accesses.filter(
|
||||
encrypted_document_symmetric_key_for_user__isnull=True,
|
||||
).exists()
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
"""Restrict templates returned by the list endpoint"""
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
user = self.request.user
|
||||
if user.is_authenticated:
|
||||
queryset = queryset.filter(
|
||||
db.Q(accesses__user=user)
|
||||
| db.Q(accesses__team__in=user.teams)
|
||||
| db.Q(is_public=True)
|
||||
if has_pending and not remaining_validated:
|
||||
raise drf.exceptions.ValidationError({
|
||||
"detail": (
|
||||
"Removing this user would leave pending collaborators "
|
||||
"unable to decrypt the document. Either wait for them "
|
||||
"to finish their encryption onboarding, or remove "
|
||||
"encryption from the document first."
|
||||
),
|
||||
"code": "would_strand_pending_users",
|
||||
})
|
||||
|
||||
@drf.decorators.action(
|
||||
detail=True, methods=["patch"], url_path="encryption-key"
|
||||
)
|
||||
def encryption_key(self, request, *args, **kwargs):
|
||||
"""Accept a pending collaborator by re-wrapping the document's
|
||||
symmetric key against their public key.
|
||||
|
||||
Strictly pending → validated. To revoke a user, delete the access
|
||||
row instead. The viewset-level permission already enforces that
|
||||
the caller is a privileged user on the document (admin/owner);
|
||||
here we additionally require the caller to currently hold a
|
||||
wrapped key themselves — without that they have no plaintext
|
||||
subtree key to re-wrap from.
|
||||
"""
|
||||
access = self.get_object()
|
||||
document = access.document
|
||||
|
||||
if not getattr(document, "is_encrypted", False):
|
||||
return drf.response.Response(
|
||||
{"detail": "Document is not encrypted."},
|
||||
status=drf.status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
else:
|
||||
queryset = queryset.filter(is_public=True)
|
||||
|
||||
page = self.paginate_queryset(queryset)
|
||||
if page is not None:
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
return self.get_paginated_response(serializer.data)
|
||||
if access.encrypted_document_symmetric_key_for_user:
|
||||
return drf.response.Response(
|
||||
{
|
||||
"detail": (
|
||||
"This access is not pending encryption onboarding. "
|
||||
"Delete the access row instead if you want to "
|
||||
"revoke it."
|
||||
),
|
||||
"code": "access_not_pending",
|
||||
},
|
||||
status=drf.status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return drf.response.Response(serializer.data)
|
||||
caller_has_key = models.DocumentAccess.objects.filter(
|
||||
document=document,
|
||||
user=request.user,
|
||||
encrypted_document_symmetric_key_for_user__isnull=False,
|
||||
).exclude(encrypted_document_symmetric_key_for_user="").exists()
|
||||
if not caller_has_key:
|
||||
return drf.response.Response(
|
||||
{
|
||||
"detail": (
|
||||
"You do not currently hold a decryption key for "
|
||||
"this document, so you cannot accept another "
|
||||
"user on it."
|
||||
),
|
||||
},
|
||||
status=drf.status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
serializer = serializers.AcceptEncryptionAccessSerializer(
|
||||
data=request.data
|
||||
)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
access.encrypted_document_symmetric_key_for_user = (
|
||||
serializer.validated_data[
|
||||
"encrypted_document_symmetric_key_for_user"
|
||||
]
|
||||
)
|
||||
access.encryption_public_key_fingerprint = (
|
||||
serializer.validated_data["encryption_public_key_fingerprint"]
|
||||
)
|
||||
access.save(
|
||||
update_fields=[
|
||||
"encrypted_document_symmetric_key_for_user",
|
||||
"encryption_public_key_fingerprint",
|
||||
]
|
||||
)
|
||||
|
||||
CollaborationService().reset_connections(
|
||||
str(document.id),
|
||||
str(access.user.id) if access.user else None,
|
||||
)
|
||||
|
||||
output = self.get_serializer(access)
|
||||
return drf.response.Response(output.data)
|
||||
|
||||
|
||||
class InvitationViewset(
|
||||
@@ -2074,6 +2686,15 @@ class InvitationViewset(
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Save invitation to a document then send an email to the invited user."""
|
||||
# Prevent invitation creation for encrypted documents
|
||||
document = models.Document.objects.get(pk=self.kwargs["resource_id"])
|
||||
if document.is_encrypted:
|
||||
raise drf.exceptions.ValidationError({
|
||||
'non_field_errors':
|
||||
'Cannot create invitations for encrypted documents. '
|
||||
'All invitations must be resolved before encrypting a document.'
|
||||
})
|
||||
|
||||
invitation = serializer.save()
|
||||
|
||||
invitation.document.send_invitation_email(
|
||||
@@ -2083,6 +2704,19 @@ class InvitationViewset(
|
||||
self.request.user.language or settings.LANGUAGE_CODE,
|
||||
)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
"""Update an invitation to a document."""
|
||||
# Prevent invitation updates for encrypted documents
|
||||
document = models.Document.objects.get(pk=self.kwargs["resource_id"])
|
||||
if document.is_encrypted:
|
||||
raise drf.exceptions.ValidationError({
|
||||
'non_field_errors':
|
||||
'Cannot update invitations for encrypted documents. '
|
||||
'All invitations must be resolved before encrypting a document.'
|
||||
})
|
||||
|
||||
return super().perform_update(serializer)
|
||||
|
||||
|
||||
class DocumentAskForAccessViewSet(
|
||||
drf.mixins.ListModelMixin,
|
||||
@@ -2193,11 +2827,14 @@ class ConfigView(drf.views.APIView):
|
||||
"AI_FEATURE_ENABLED",
|
||||
"COLLABORATION_WS_URL",
|
||||
"COLLABORATION_WS_NOT_CONNECTED_READY_ONLY",
|
||||
"CONVERSION_FILE_EXTENSIONS_ALLOWED",
|
||||
"CONVERSION_FILE_MAX_SIZE",
|
||||
"CRISP_WEBSITE_ID",
|
||||
"ENVIRONMENT",
|
||||
"FRONTEND_CSS_URL",
|
||||
"FRONTEND_HOMEPAGE_FEATURE_ENABLED",
|
||||
"FRONTEND_JS_URL",
|
||||
"FRONTEND_SILENT_LOGIN_ENABLED",
|
||||
"FRONTEND_THEME",
|
||||
"MEDIA_BASE_URL",
|
||||
"POSTHOG_KEY",
|
||||
|
||||
@@ -53,15 +53,6 @@ class UserFactory(factory.django.DjangoModelFactory):
|
||||
if create and (extracted is True):
|
||||
UserDocumentAccessFactory(user=self, role="owner")
|
||||
|
||||
@factory.post_generation
|
||||
def with_owned_template(self, create, extracted, **kwargs):
|
||||
"""
|
||||
Create a template for which the user is owner to check
|
||||
that there is no interference
|
||||
"""
|
||||
if create and (extracted is True):
|
||||
UserTemplateAccessFactory(user=self, role="owner")
|
||||
|
||||
|
||||
class ParentNodeFactory(factory.declarations.ParameteredAttribute):
|
||||
"""Custom factory attribute for setting the parent node."""
|
||||
@@ -202,50 +193,6 @@ class DocumentAskForAccessFactory(factory.django.DjangoModelFactory):
|
||||
role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices])
|
||||
|
||||
|
||||
class TemplateFactory(factory.django.DjangoModelFactory):
|
||||
"""A factory to create templates"""
|
||||
|
||||
class Meta:
|
||||
model = models.Template
|
||||
django_get_or_create = ("title",)
|
||||
skip_postgeneration_save = True
|
||||
|
||||
title = factory.Sequence(lambda n: f"template{n}")
|
||||
is_public = factory.Faker("boolean")
|
||||
|
||||
@factory.post_generation
|
||||
def users(self, create, extracted, **kwargs):
|
||||
"""Add users to template from a given list of users with or without roles."""
|
||||
if create and extracted:
|
||||
for item in extracted:
|
||||
if isinstance(item, models.User):
|
||||
UserTemplateAccessFactory(template=self, user=item)
|
||||
else:
|
||||
UserTemplateAccessFactory(template=self, user=item[0], role=item[1])
|
||||
|
||||
|
||||
class UserTemplateAccessFactory(factory.django.DjangoModelFactory):
|
||||
"""Create fake template user accesses for testing."""
|
||||
|
||||
class Meta:
|
||||
model = models.TemplateAccess
|
||||
|
||||
template = factory.SubFactory(TemplateFactory)
|
||||
user = factory.SubFactory(UserFactory)
|
||||
role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices])
|
||||
|
||||
|
||||
class TeamTemplateAccessFactory(factory.django.DjangoModelFactory):
|
||||
"""Create fake template team accesses for testing."""
|
||||
|
||||
class Meta:
|
||||
model = models.TemplateAccess
|
||||
|
||||
template = factory.SubFactory(TemplateFactory)
|
||||
team = factory.Sequence(lambda n: f"team{n}")
|
||||
role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices])
|
||||
|
||||
|
||||
class InvitationFactory(factory.django.DjangoModelFactory):
|
||||
"""A factory to create invitations for a user"""
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# Generated by Django 5.2.9 on 2026-01-09 14:18
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("core", "0027_auto_20251120_0956"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="templateaccess",
|
||||
name="template",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="templateaccess",
|
||||
name="user",
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name="Template",
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name="TemplateAccess",
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 5.2.10 on 2026-02-23 10:17
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0028_remove_templateaccess_template_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='document',
|
||||
name='is_encrypted',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='documentaccess',
|
||||
name='encrypted_document_symmetric_key_for_user',
|
||||
field=models.TextField(blank=True, help_text='Encrypted symmetric key for this document, specific to this user.', null=True, verbose_name='encrypted document symmetric key'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='encryption_public_key',
|
||||
field=models.TextField(blank=True, help_text='Public key for end-to-end encryption.', null=True, verbose_name='encryption public key'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Add encryption_public_key_fingerprint to BaseAccess (DocumentAccess).
|
||||
|
||||
Stores the fingerprint of the user's public key at the time of sharing,
|
||||
allowing the frontend to detect key changes without relying solely on
|
||||
client-side TOFU. If the user's current key fingerprint differs from
|
||||
this stored value, the document access needs re-encryption.
|
||||
"""
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("core", "0029_document_is_encrypted_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="documentaccess",
|
||||
name="encryption_public_key_fingerprint",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
help_text=(
|
||||
"Fingerprint of the user's public key at the time of sharing. "
|
||||
"Used to detect key changes — if the user's current public key "
|
||||
"fingerprint differs from this value, the access needs re-encryption."
|
||||
),
|
||||
max_length=16,
|
||||
null=True,
|
||||
verbose_name="encryption public key fingerprint",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Remove encryption_public_key from User model.
|
||||
|
||||
Public keys are now managed by the centralized encryption service.
|
||||
Products should fetch public keys from the encryption service's API
|
||||
when needed (e.g. for encrypting a document for multiple users).
|
||||
|
||||
The fingerprint of the public key at share time is stored on
|
||||
DocumentAccess.encryption_public_key_fingerprint (added in 0030).
|
||||
"""
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("core", "0030_baseaccess_encryption_public_key_fingerprint"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="user",
|
||||
name="encryption_public_key",
|
||||
),
|
||||
]
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Declare and configure the models for the impress core application
|
||||
"""
|
||||
|
||||
# pylint: disable=too-many-lines
|
||||
|
||||
import hashlib
|
||||
@@ -278,6 +279,23 @@ class BaseAccess(BaseModel):
|
||||
role = models.CharField(
|
||||
max_length=20, choices=RoleChoices.choices, default=RoleChoices.READER
|
||||
)
|
||||
encrypted_document_symmetric_key_for_user = models.TextField(
|
||||
_("encrypted document symmetric key"),
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text=_("Encrypted symmetric key for this document, specific to this user."),
|
||||
)
|
||||
encryption_public_key_fingerprint = models.CharField(
|
||||
_("encryption public key fingerprint"),
|
||||
max_length=16,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text=_(
|
||||
"Fingerprint of the user's public key at the time of sharing. "
|
||||
"Used to detect key changes — if the user's current public key "
|
||||
"fingerprint differs from this value, the access needs re-encryption."
|
||||
),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
@@ -360,6 +378,7 @@ class Document(MP_Node, BaseModel):
|
||||
|
||||
title = models.CharField(_("title"), max_length=255, null=True, blank=True)
|
||||
excerpt = models.TextField(_("excerpt"), max_length=300, null=True, blank=True)
|
||||
is_encrypted = models.BooleanField(default=False)
|
||||
link_reach = models.CharField(
|
||||
max_length=20,
|
||||
choices=LinkReachChoices.choices,
|
||||
@@ -717,6 +736,39 @@ class Document(MP_Node, BaseModel):
|
||||
"""Actual link role on the document."""
|
||||
return self.computed_link_definition["link_role"]
|
||||
|
||||
@property
|
||||
def accesses_user_ids(self):
|
||||
"""
|
||||
Return the list of user IDs with access to this document.
|
||||
The frontend uses these IDs to fetch public keys from the
|
||||
centralized encryption service.
|
||||
"""
|
||||
return list(
|
||||
DocumentAccess.objects
|
||||
.filter(document=self, user__isnull=False)
|
||||
.values_list('user__sub', flat=True)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
@property
|
||||
def accesses_fingerprints_per_user(self):
|
||||
"""
|
||||
Return the fingerprint of each user's public key at the time of sharing.
|
||||
This allows the frontend to detect key changes by comparing the
|
||||
fingerprint stored at share time with the current public key fingerprint.
|
||||
"""
|
||||
accesses = (
|
||||
DocumentAccess.objects
|
||||
.filter(document=self, user__isnull=False, encryption_public_key_fingerprint__isnull=False)
|
||||
.values_list('user__sub', 'encryption_public_key_fingerprint')
|
||||
)
|
||||
|
||||
return {
|
||||
str(sub): fingerprint
|
||||
for sub, fingerprint in accesses
|
||||
if fingerprint
|
||||
}
|
||||
|
||||
def get_abilities(self, user):
|
||||
"""
|
||||
Compute and return abilities for a given user on the document.
|
||||
@@ -796,12 +848,14 @@ class Document(MP_Node, BaseModel):
|
||||
"descendants": can_get,
|
||||
"destroy": can_destroy,
|
||||
"duplicate": can_get and user.is_authenticated,
|
||||
"encrypt": is_owner_or_admin,
|
||||
"favorite": can_get and user.is_authenticated,
|
||||
"link_configuration": is_owner_or_admin,
|
||||
"invite_owner": is_owner and not is_deleted,
|
||||
"mask": can_get and user.is_authenticated,
|
||||
"move": is_owner_or_admin and not is_deleted,
|
||||
"partial_update": can_update,
|
||||
"remove_encryption": is_owner_or_admin,
|
||||
"restore": is_owner,
|
||||
"retrieve": retrieve,
|
||||
"media_auth": can_get,
|
||||
@@ -816,7 +870,7 @@ class Document(MP_Node, BaseModel):
|
||||
def send_email(self, subject, emails, context=None, language=None):
|
||||
"""Generate and send email from a template."""
|
||||
context = context or {}
|
||||
domain = Site.objects.get_current().domain
|
||||
domain = settings.EMAIL_URL_APP or Site.objects.get_current().domain
|
||||
language = language or get_language()
|
||||
context.update(
|
||||
{
|
||||
@@ -824,7 +878,8 @@ class Document(MP_Node, BaseModel):
|
||||
"document": self,
|
||||
"domain": domain,
|
||||
"link": f"{domain}/docs/{self.id}/",
|
||||
"document_title": self.title or str(_("Untitled Document")),
|
||||
"link_label": self.title or str(_("Untitled Document")),
|
||||
"button_label": _("Open"),
|
||||
"logo_img": settings.EMAIL_LOGO_IMG,
|
||||
}
|
||||
)
|
||||
@@ -1174,12 +1229,21 @@ class DocumentAccess(BaseAccess):
|
||||
if len(set_role_to) == 1:
|
||||
set_role_to = []
|
||||
|
||||
# "encryption_key" gates the PATCH
|
||||
# /accesses/{id}/encryption-key/ Accept endpoint. The viewset
|
||||
# additionally enforces that the caller holds a wrapped key on
|
||||
# the document (otherwise they have nothing to re-wrap), so at
|
||||
# this layer the rule just mirrors "can manage accesses on
|
||||
# this document" — same privileged-role check as update, minus
|
||||
# the role-change prerequisites which aren't relevant when
|
||||
# re-wrapping a key.
|
||||
return {
|
||||
"destroy": can_delete,
|
||||
"update": bool(set_role_to) and is_owner_or_admin,
|
||||
"partial_update": bool(set_role_to) and is_owner_or_admin,
|
||||
"retrieve": (self.user and self.user.id == user.id) or is_owner_or_admin,
|
||||
"set_role_to": set_role_to,
|
||||
"encryption_key": is_owner_or_admin,
|
||||
}
|
||||
|
||||
|
||||
@@ -1428,163 +1492,6 @@ class Reaction(BaseModel):
|
||||
return f"Reaction {self.emoji} on comment {self.comment.id}"
|
||||
|
||||
|
||||
class Template(BaseModel):
|
||||
"""HTML and CSS code used for formatting the print around the MarkDown body."""
|
||||
|
||||
title = models.CharField(_("title"), max_length=255)
|
||||
description = models.TextField(_("description"), blank=True)
|
||||
code = models.TextField(_("code"), blank=True)
|
||||
css = models.TextField(_("css"), blank=True)
|
||||
is_public = models.BooleanField(
|
||||
_("public"),
|
||||
default=False,
|
||||
help_text=_("Whether this template is public for anyone to use."),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "impress_template"
|
||||
ordering = ("title",)
|
||||
verbose_name = _("Template")
|
||||
verbose_name_plural = _("Templates")
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
def get_role(self, user):
|
||||
"""Return the roles a user has on a resource as an iterable."""
|
||||
if not user.is_authenticated:
|
||||
return None
|
||||
|
||||
try:
|
||||
roles = self.user_roles or []
|
||||
except AttributeError:
|
||||
try:
|
||||
roles = self.accesses.filter(
|
||||
models.Q(user=user) | models.Q(team__in=user.teams),
|
||||
).values_list("role", flat=True)
|
||||
except (models.ObjectDoesNotExist, IndexError):
|
||||
roles = []
|
||||
|
||||
return RoleChoices.max(*roles)
|
||||
|
||||
def get_abilities(self, user):
|
||||
"""
|
||||
Compute and return abilities for a given user on the template.
|
||||
"""
|
||||
role = self.get_role(user)
|
||||
is_owner_or_admin = role in PRIVILEGED_ROLES
|
||||
can_get = self.is_public or bool(role)
|
||||
can_update = is_owner_or_admin or role == RoleChoices.EDITOR
|
||||
|
||||
return {
|
||||
"destroy": role == RoleChoices.OWNER,
|
||||
"generate_document": can_get,
|
||||
"accesses_manage": is_owner_or_admin,
|
||||
"update": can_update,
|
||||
"partial_update": can_update,
|
||||
"retrieve": can_get,
|
||||
}
|
||||
|
||||
|
||||
class TemplateAccess(BaseAccess):
|
||||
"""Relation model to give access to a template for a user or a team with a role."""
|
||||
|
||||
template = models.ForeignKey(
|
||||
Template,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="accesses",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "impress_template_access"
|
||||
ordering = ("-created_at",)
|
||||
verbose_name = _("Template/user relation")
|
||||
verbose_name_plural = _("Template/user relations")
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["user", "template"],
|
||||
condition=models.Q(user__isnull=False), # Exclude null users
|
||||
name="unique_template_user",
|
||||
violation_error_message=_("This user is already in this template."),
|
||||
),
|
||||
models.UniqueConstraint(
|
||||
fields=["team", "template"],
|
||||
condition=models.Q(team__gt=""), # Exclude empty string teams
|
||||
name="unique_template_team",
|
||||
violation_error_message=_("This team is already in this template."),
|
||||
),
|
||||
models.CheckConstraint(
|
||||
condition=models.Q(user__isnull=False, team="")
|
||||
| models.Q(user__isnull=True, team__gt=""),
|
||||
name="check_template_access_either_user_or_team",
|
||||
violation_error_message=_("Either user or team must be set, not both."),
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
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):
|
||||
"""
|
||||
Compute and return abilities for a given user on the template access.
|
||||
"""
|
||||
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):
|
||||
"""User invitation to a document."""
|
||||
|
||||
|
||||
@@ -3,10 +3,14 @@
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
from core import enums
|
||||
|
||||
if settings.LANGFUSE_PUBLIC_KEY:
|
||||
from langfuse.openai import OpenAI
|
||||
else:
|
||||
from openai import OpenAI
|
||||
|
||||
|
||||
AI_ACTIONS = {
|
||||
"prompt": (
|
||||
"Answer the prompt using markdown formatting for structure and emphasis. "
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
"""Y-Provider API services."""
|
||||
|
||||
import logging
|
||||
import typing
|
||||
from base64 import b64encode
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
import requests
|
||||
|
||||
from core.services import mime_types
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ConversionError(Exception):
|
||||
"""Base exception for conversion-related errors."""
|
||||
@@ -19,8 +25,81 @@ class ServiceUnavailableError(ConversionError):
|
||||
"""Raised when the conversion service is unavailable."""
|
||||
|
||||
|
||||
class ConverterProtocol(typing.Protocol):
|
||||
"""Protocol for converter classes."""
|
||||
|
||||
def convert(self, data, content_type, accept):
|
||||
"""Convert content from one format to another."""
|
||||
|
||||
|
||||
class Converter:
|
||||
"""Orchestrates conversion between different formats using specialized converters."""
|
||||
|
||||
docspec: ConverterProtocol
|
||||
ydoc: ConverterProtocol
|
||||
|
||||
def __init__(self):
|
||||
self.docspec = DocSpecConverter()
|
||||
self.ydoc = YdocConverter()
|
||||
|
||||
def convert(self, data, content_type, accept):
|
||||
"""Convert input into other formats using external microservices."""
|
||||
|
||||
if content_type == mime_types.DOCX and accept == mime_types.YJS:
|
||||
blocknote_data = self.docspec.convert(
|
||||
data, mime_types.DOCX, mime_types.BLOCKNOTE
|
||||
)
|
||||
return self.ydoc.convert(
|
||||
blocknote_data, mime_types.BLOCKNOTE, mime_types.YJS
|
||||
)
|
||||
|
||||
return self.ydoc.convert(data, content_type, accept)
|
||||
|
||||
|
||||
class DocSpecConverter:
|
||||
"""Service class for DocSpec conversion-related operations."""
|
||||
|
||||
def _request(self, url, data, content_type):
|
||||
"""Make a request to the DocSpec API."""
|
||||
|
||||
response = requests.post(
|
||||
url,
|
||||
headers={"Accept": mime_types.BLOCKNOTE},
|
||||
files={"file": ("document.docx", data, content_type)},
|
||||
timeout=settings.CONVERSION_API_TIMEOUT,
|
||||
verify=settings.CONVERSION_API_SECURE,
|
||||
)
|
||||
if not response.ok:
|
||||
logger.error(
|
||||
"DocSpec API error: url=%s, status=%d, response=%s",
|
||||
url,
|
||||
response.status_code,
|
||||
response.text[:200] if response.text else "empty",
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response
|
||||
|
||||
def convert(self, data, content_type, accept):
|
||||
"""Convert a Document to BlockNote."""
|
||||
if not data:
|
||||
raise ValidationError("Input data cannot be empty")
|
||||
|
||||
if content_type != mime_types.DOCX or accept != mime_types.BLOCKNOTE:
|
||||
raise ValidationError(
|
||||
f"Conversion from {content_type} to {accept} is not supported."
|
||||
)
|
||||
|
||||
try:
|
||||
return self._request(settings.DOCSPEC_API_URL, data, content_type).content
|
||||
except requests.RequestException as err:
|
||||
logger.exception("DocSpec service error: url=%s", settings.DOCSPEC_API_URL)
|
||||
raise ServiceUnavailableError(
|
||||
"Failed to connect to DocSpec conversion service",
|
||||
) from err
|
||||
|
||||
|
||||
class YdocConverter:
|
||||
"""Service class for conversion-related operations."""
|
||||
"""Service class for YDoc conversion-related operations."""
|
||||
|
||||
@property
|
||||
def auth_header(self):
|
||||
@@ -41,32 +120,34 @@ class YdocConverter:
|
||||
timeout=settings.CONVERSION_API_TIMEOUT,
|
||||
verify=settings.CONVERSION_API_SECURE,
|
||||
)
|
||||
if not response.ok:
|
||||
logger.error(
|
||||
"Y-Provider API error: url=%s, status=%d, response=%s",
|
||||
url,
|
||||
response.status_code,
|
||||
response.text[:200] if response.text else "empty",
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response
|
||||
|
||||
def convert(
|
||||
self, text, content_type="text/markdown", accept="application/vnd.yjs.doc"
|
||||
):
|
||||
def convert(self, data, content_type=mime_types.MARKDOWN, accept=mime_types.YJS):
|
||||
"""Convert a Markdown text into our internal format using an external microservice."""
|
||||
|
||||
if not text:
|
||||
raise ValidationError("Input text cannot be empty")
|
||||
if not data:
|
||||
raise ValidationError("Input data cannot be empty")
|
||||
|
||||
url = f"{settings.Y_PROVIDER_API_BASE_URL}{settings.CONVERSION_API_ENDPOINT}/"
|
||||
try:
|
||||
response = self._request(
|
||||
f"{settings.Y_PROVIDER_API_BASE_URL}{settings.CONVERSION_API_ENDPOINT}/",
|
||||
text,
|
||||
content_type,
|
||||
accept,
|
||||
)
|
||||
if accept == "application/vnd.yjs.doc":
|
||||
response = self._request(url, data, content_type, accept)
|
||||
if accept == mime_types.YJS:
|
||||
return b64encode(response.content).decode("utf-8")
|
||||
if accept in {"text/markdown", "text/html"}:
|
||||
if accept in {mime_types.MARKDOWN, "text/html"}:
|
||||
return response.text
|
||||
if accept == "application/json":
|
||||
if accept == mime_types.JSON:
|
||||
return response.json()
|
||||
raise ValidationError("Unsupported format")
|
||||
except requests.RequestException as err:
|
||||
logger.exception("Y-Provider service error: url=%s", url)
|
||||
raise ServiceUnavailableError(
|
||||
"Failed to connect to conversion service",
|
||||
f"Failed to connect to YDoc conversion service {content_type}, {accept}",
|
||||
) from err
|
||||
|
||||
8
src/backend/core/services/mime_types.py
Normal file
8
src/backend/core/services/mime_types.py
Normal file
@@ -0,0 +1,8 @@
|
||||
"""MIME type constants for document conversion."""
|
||||
|
||||
BLOCKNOTE = "application/vnd.blocknote+json"
|
||||
YJS = "application/vnd.yjs.doc"
|
||||
MARKDOWN = "text/markdown"
|
||||
JSON = "application/json"
|
||||
DOCX = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
HTML = "text/html"
|
||||
@@ -244,7 +244,12 @@ class SearchIndexer(BaseDocumentIndexer):
|
||||
"""
|
||||
doc_path = document.path
|
||||
doc_content = document.content
|
||||
text_content = utils.base64_yjs_to_text(doc_content) if doc_content else ""
|
||||
|
||||
# Encrypted content is ciphertext and it should never be indexed for search
|
||||
if document.is_encrypted:
|
||||
text_content = ""
|
||||
else:
|
||||
text_content = utils.base64_yjs_to_text(doc_content) if doc_content else ""
|
||||
|
||||
return {
|
||||
"id": str(document.id),
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Generate Document</title>
|
||||
</head>
|
||||
<body>
|
||||
<h2>Generate Document</h2>
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
{{ form.as_p }}
|
||||
<button type="submit">Generate PDF</button>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Custom template tags for the core application of People."""
|
||||
"""Custom template tags for the core application of Docs."""
|
||||
|
||||
import base64
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import re
|
||||
from unittest import mock
|
||||
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.test.utils import override_settings
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
@@ -323,85 +322,6 @@ def test_authentication_getter_new_user_with_email(monkeypatch):
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
|
||||
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
|
||||
@responses.activate
|
||||
def test_authentication_get_userinfo_json_response():
|
||||
"""Test get_userinfo method with a JSON response."""
|
||||
|
||||
responses.add(
|
||||
responses.GET,
|
||||
re.compile(r".*/userinfo"),
|
||||
json={
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
"email": "john.doe@example.com",
|
||||
},
|
||||
status=200,
|
||||
)
|
||||
|
||||
oidc_backend = OIDCAuthenticationBackend()
|
||||
result = oidc_backend.get_userinfo("fake_access_token", None, None)
|
||||
|
||||
assert result["first_name"] == "John"
|
||||
assert result["last_name"] == "Doe"
|
||||
assert result["email"] == "john.doe@example.com"
|
||||
|
||||
|
||||
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
|
||||
@responses.activate
|
||||
def test_authentication_get_userinfo_token_response(monkeypatch, settings):
|
||||
"""Test get_userinfo method with a token response."""
|
||||
settings.OIDC_RP_SIGN_ALGO = "HS256" # disable JWKS URL call
|
||||
responses.add(
|
||||
responses.GET,
|
||||
re.compile(r".*/userinfo"),
|
||||
body="fake.jwt.token",
|
||||
status=200,
|
||||
content_type="application/jwt",
|
||||
)
|
||||
|
||||
def mock_verify_token(self, token): # pylint: disable=unused-argument
|
||||
return {
|
||||
"first_name": "Jane",
|
||||
"last_name": "Doe",
|
||||
"email": "jane.doe@example.com",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "verify_token", mock_verify_token)
|
||||
|
||||
oidc_backend = OIDCAuthenticationBackend()
|
||||
result = oidc_backend.get_userinfo("fake_access_token", None, None)
|
||||
|
||||
assert result["first_name"] == "Jane"
|
||||
assert result["last_name"] == "Doe"
|
||||
assert result["email"] == "jane.doe@example.com"
|
||||
|
||||
|
||||
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
|
||||
@responses.activate
|
||||
def test_authentication_get_userinfo_invalid_response(settings):
|
||||
"""
|
||||
Test get_userinfo method with an invalid JWT response that
|
||||
causes verify_token to raise an error.
|
||||
"""
|
||||
settings.OIDC_RP_SIGN_ALGO = "HS256" # disable JWKS URL call
|
||||
responses.add(
|
||||
responses.GET,
|
||||
re.compile(r".*/userinfo"),
|
||||
body="fake.jwt.token",
|
||||
status=200,
|
||||
content_type="application/jwt",
|
||||
)
|
||||
|
||||
oidc_backend = OIDCAuthenticationBackend()
|
||||
|
||||
with pytest.raises(
|
||||
SuspiciousOperation,
|
||||
match="User info response was not valid JWT",
|
||||
):
|
||||
oidc_backend.get_userinfo("fake_access_token", None, None)
|
||||
|
||||
|
||||
def test_authentication_getter_existing_disabled_user_via_sub(
|
||||
django_assert_num_queries, monkeypatch
|
||||
):
|
||||
|
||||
428
src/backend/core/tests/documents/test_api_documents_all.py
Normal file
428
src/backend/core/tests/documents/test_api_documents_all.py
Normal file
@@ -0,0 +1,428 @@
|
||||
"""
|
||||
Tests for Documents API endpoint in impress's core app: all
|
||||
|
||||
The 'all' endpoint returns ALL documents (including descendants) that the user has access to.
|
||||
This is different from the 'list' endpoint which only returns top-level documents.
|
||||
"""
|
||||
|
||||
from datetime import timedelta
|
||||
from unittest import mock
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", models.LinkRoleChoices.values)
|
||||
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
|
||||
def test_api_documents_all_anonymous(reach, role):
|
||||
"""
|
||||
Anonymous users should not be able to list any documents via the all endpoint
|
||||
whatever the link reach and link role.
|
||||
"""
|
||||
parent = factories.DocumentFactory(link_reach=reach, link_role=role)
|
||||
factories.DocumentFactory(parent=parent, link_reach=reach, link_role=role)
|
||||
|
||||
response = APIClient().get("/api/v1.0/documents/all/")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 0
|
||||
|
||||
|
||||
def test_api_documents_all_authenticated_with_children():
|
||||
"""
|
||||
Authenticated users should see all documents including children,
|
||||
even though children don't have DocumentAccess records.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Create a document tree: parent -> child -> grandchild
|
||||
parent = factories.DocumentFactory()
|
||||
factories.UserDocumentAccessFactory(document=parent, user=user, role="owner")
|
||||
|
||||
child = factories.DocumentFactory(parent=parent)
|
||||
grandchild = factories.DocumentFactory(parent=child)
|
||||
|
||||
# Verify setup
|
||||
assert models.DocumentAccess.objects.filter(document=parent).count() == 1
|
||||
assert models.DocumentAccess.objects.filter(document=child).count() == 0
|
||||
assert models.DocumentAccess.objects.filter(document=grandchild).count() == 0
|
||||
|
||||
response = client.get("/api/v1.0/documents/all/")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
|
||||
# All three documents should be returned (parent + child + grandchild)
|
||||
assert len(results) == 3
|
||||
results_ids = {result["id"] for result in results}
|
||||
assert results_ids == {str(parent.id), str(child.id), str(grandchild.id)}
|
||||
|
||||
depths = {result["depth"] for result in results}
|
||||
assert depths == {1, 2, 3}
|
||||
|
||||
|
||||
def test_api_documents_all_authenticated_multiple_trees():
|
||||
"""
|
||||
Users should see all accessible documents from multiple document trees.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Tree 1: User has access
|
||||
tree1_parent = factories.DocumentFactory()
|
||||
factories.UserDocumentAccessFactory(document=tree1_parent, user=user)
|
||||
tree1_child = factories.DocumentFactory(parent=tree1_parent)
|
||||
|
||||
# Tree 2: User has access
|
||||
tree2_parent = factories.DocumentFactory()
|
||||
factories.UserDocumentAccessFactory(document=tree2_parent, user=user)
|
||||
tree2_child1 = factories.DocumentFactory(parent=tree2_parent)
|
||||
tree2_child2 = factories.DocumentFactory(parent=tree2_parent)
|
||||
|
||||
# Tree 3: User does NOT have access
|
||||
tree3_parent = factories.DocumentFactory()
|
||||
factories.DocumentFactory(parent=tree3_parent)
|
||||
|
||||
response = client.get("/api/v1.0/documents/all/")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
|
||||
# Should return 5 documents (tree1: 2, tree2: 3, tree3: 0)
|
||||
assert len(results) == 5
|
||||
results_ids = {result["id"] for result in results}
|
||||
expected_ids = {
|
||||
str(tree1_parent.id),
|
||||
str(tree1_child.id),
|
||||
str(tree2_parent.id),
|
||||
str(tree2_child1.id),
|
||||
str(tree2_child2.id),
|
||||
}
|
||||
assert results_ids == expected_ids
|
||||
|
||||
|
||||
def test_api_documents_all_authenticated_explicit_access_to_parent_and_child():
|
||||
"""
|
||||
When a user has explicit DocumentAccess to both parent AND child,
|
||||
both should appear in the 'all' endpoint results (unlike 'list' which deduplicates).
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Parent with explicit access
|
||||
parent = factories.DocumentFactory()
|
||||
factories.UserDocumentAccessFactory(document=parent, user=user)
|
||||
|
||||
# Child also has explicit access (e.g., shared separately)
|
||||
child = factories.DocumentFactory(parent=parent)
|
||||
factories.UserDocumentAccessFactory(document=child, user=user)
|
||||
|
||||
# Grandchild has no explicit access
|
||||
grandchild = factories.DocumentFactory(parent=child)
|
||||
|
||||
# Verify setup
|
||||
assert models.DocumentAccess.objects.filter(document=parent).count() == 1
|
||||
assert models.DocumentAccess.objects.filter(document=child).count() == 1
|
||||
assert models.DocumentAccess.objects.filter(document=grandchild).count() == 0
|
||||
|
||||
response = client.get("/api/v1.0/documents/all/")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
|
||||
# All three should appear
|
||||
assert len(results) == 3
|
||||
results_ids = {result["id"] for result in results}
|
||||
assert results_ids == {str(parent.id), str(child.id), str(grandchild.id)}
|
||||
|
||||
# Each document should appear exactly once (no duplicates)
|
||||
results_ids_list = [result["id"] for result in results]
|
||||
assert len(results_ids_list) == len(set(results_ids_list)) # No duplicates
|
||||
|
||||
|
||||
def test_api_documents_all_authenticated_via_team(mock_user_teams):
|
||||
"""
|
||||
Users should see all documents (including descendants) for documents accessed via teams.
|
||||
"""
|
||||
mock_user_teams.return_value = ["team1", "team2"]
|
||||
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Document tree via team1
|
||||
parent1 = factories.DocumentFactory()
|
||||
factories.TeamDocumentAccessFactory(document=parent1, team="team1")
|
||||
child1 = factories.DocumentFactory(parent=parent1)
|
||||
|
||||
# Document tree via team2
|
||||
parent2 = factories.DocumentFactory()
|
||||
factories.TeamDocumentAccessFactory(document=parent2, team="team2")
|
||||
child2 = factories.DocumentFactory(parent=parent2)
|
||||
|
||||
# Document tree via unknown team
|
||||
parent3 = factories.DocumentFactory()
|
||||
factories.TeamDocumentAccessFactory(document=parent3, team="team3")
|
||||
factories.DocumentFactory(parent=parent3)
|
||||
|
||||
response = client.get("/api/v1.0/documents/all/")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
|
||||
# Should return 4 documents (team1: 2, team2: 2, team3: 0)
|
||||
assert len(results) == 4
|
||||
results_ids = {result["id"] for result in results}
|
||||
expected_ids = {
|
||||
str(parent1.id),
|
||||
str(child1.id),
|
||||
str(parent2.id),
|
||||
str(child2.id),
|
||||
}
|
||||
assert results_ids == expected_ids
|
||||
|
||||
|
||||
def test_api_documents_all_authenticated_soft_deleted():
|
||||
"""
|
||||
Soft-deleted documents and their descendants should not be included.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Active tree
|
||||
active_parent = factories.DocumentFactory()
|
||||
factories.UserDocumentAccessFactory(document=active_parent, user=user)
|
||||
active_child = factories.DocumentFactory(parent=active_parent)
|
||||
|
||||
# Soft-deleted tree
|
||||
deleted_parent = factories.DocumentFactory()
|
||||
factories.UserDocumentAccessFactory(document=deleted_parent, user=user)
|
||||
_deleted_child = factories.DocumentFactory(parent=deleted_parent)
|
||||
deleted_parent.soft_delete()
|
||||
|
||||
response = client.get("/api/v1.0/documents/all/")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
|
||||
# Should only return active documents
|
||||
assert len(results) == 2
|
||||
results_ids = {result["id"] for result in results}
|
||||
assert results_ids == {str(active_parent.id), str(active_child.id)}
|
||||
|
||||
|
||||
def test_api_documents_all_authenticated_permanently_deleted():
|
||||
"""
|
||||
Permanently deleted documents should not be included.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Active tree
|
||||
active_parent = factories.DocumentFactory()
|
||||
factories.UserDocumentAccessFactory(document=active_parent, user=user)
|
||||
active_child = factories.DocumentFactory(parent=active_parent)
|
||||
|
||||
# Permanently deleted tree (deleted > 30 days ago)
|
||||
deleted_parent = factories.DocumentFactory()
|
||||
factories.UserDocumentAccessFactory(document=deleted_parent, user=user)
|
||||
_deleted_child = factories.DocumentFactory(parent=deleted_parent)
|
||||
|
||||
fourty_days_ago = timezone.now() - timedelta(days=40)
|
||||
with mock.patch("django.utils.timezone.now", return_value=fourty_days_ago):
|
||||
deleted_parent.soft_delete()
|
||||
|
||||
response = client.get("/api/v1.0/documents/all/")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
|
||||
# Should only return active documents
|
||||
assert len(results) == 2
|
||||
results_ids = {result["id"] for result in results}
|
||||
assert results_ids == {str(active_parent.id), str(active_child.id)}
|
||||
|
||||
|
||||
def test_api_documents_all_authenticated_link_reach_restricted():
|
||||
"""
|
||||
Documents with link_reach=restricted accessed via LinkTrace should not appear
|
||||
in the all endpoint results.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Document with direct access (should appear)
|
||||
parent_with_access = factories.DocumentFactory()
|
||||
factories.UserDocumentAccessFactory(document=parent_with_access, user=user)
|
||||
child_with_access = factories.DocumentFactory(parent=parent_with_access)
|
||||
|
||||
# Document with only LinkTrace and restricted reach (should NOT appear)
|
||||
parent_restricted = factories.DocumentFactory(
|
||||
link_reach="restricted", link_traces=[user]
|
||||
)
|
||||
factories.DocumentFactory(parent=parent_restricted)
|
||||
|
||||
response = client.get("/api/v1.0/documents/all/")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
|
||||
# Only documents with direct access should appear
|
||||
assert len(results) == 2
|
||||
results_ids = {result["id"] for result in results}
|
||||
assert results_ids == {str(parent_with_access.id), str(child_with_access.id)}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reach", ["public", "authenticated"])
|
||||
def test_api_documents_all_authenticated_link_reach_public_or_authenticated(reach):
|
||||
"""
|
||||
Documents with link_reach=public or authenticated accessed via LinkTrace
|
||||
should appear with all their descendants.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Document accessed via LinkTrace with non-restricted reach
|
||||
parent = factories.DocumentFactory(link_reach=reach, link_traces=[user])
|
||||
child = factories.DocumentFactory(parent=parent)
|
||||
grandchild = factories.DocumentFactory(parent=child)
|
||||
|
||||
response = client.get("/api/v1.0/documents/all/")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
|
||||
# All descendants should be included
|
||||
assert len(results) == 3
|
||||
results_ids = {result["id"] for result in results}
|
||||
assert results_ids == {str(parent.id), str(child.id), str(grandchild.id)}
|
||||
|
||||
|
||||
def test_api_documents_all_format():
|
||||
"""Validate the format of documents as returned by the all endpoint."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
access = factories.UserDocumentAccessFactory(document=document, user=user)
|
||||
child = factories.DocumentFactory(parent=document)
|
||||
|
||||
response = client.get("/api/v1.0/documents/all/")
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
results = content.pop("results")
|
||||
|
||||
# Check pagination structure
|
||||
assert content == {
|
||||
"count": 2,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
}
|
||||
|
||||
# Verify parent document format
|
||||
parent_result = [r for r in results if r["id"] == str(document.id)][0]
|
||||
assert parent_result == {
|
||||
"id": str(document.id),
|
||||
"abilities": document.get_abilities(user),
|
||||
"ancestors_link_reach": None,
|
||||
"ancestors_link_role": None,
|
||||
"computed_link_reach": document.computed_link_reach,
|
||||
"computed_link_role": document.computed_link_role,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(document.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 1,
|
||||
"excerpt": document.excerpt,
|
||||
"is_favorite": False,
|
||||
"is_encrypted": document.is_encrypted,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 1,
|
||||
"numchild": 1,
|
||||
"path": document.path,
|
||||
"title": document.title,
|
||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": access.role,
|
||||
}
|
||||
|
||||
# Verify child document format
|
||||
child_result = [r for r in results if r["id"] == str(child.id)][0]
|
||||
assert child_result["depth"] == 2
|
||||
assert child_result["user_role"] == access.role # Inherited from parent
|
||||
assert child_result["nb_accesses_direct"] == 0 # No direct access on child
|
||||
|
||||
|
||||
def test_api_documents_all_distinct():
|
||||
"""
|
||||
A document should only appear once even if the user has multiple access paths to it.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
other_user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Document with multiple accesses for the same user
|
||||
document = factories.DocumentFactory(users=[user, other_user])
|
||||
child = factories.DocumentFactory(parent=document)
|
||||
|
||||
response = client.get("/api/v1.0/documents/all/")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
|
||||
# Should return 2 documents (parent + child), each appearing once
|
||||
assert len(results) == 2
|
||||
results_ids = [result["id"] for result in results]
|
||||
assert results_ids.count(str(document.id)) == 1
|
||||
assert results_ids.count(str(child.id)) == 1
|
||||
|
||||
|
||||
def test_api_documents_all_comparison_with_list():
|
||||
"""
|
||||
The 'all' endpoint should return more documents than 'list' when there are children.
|
||||
'list' returns only top-level documents, 'all' returns all descendants.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Create a document tree
|
||||
parent = factories.DocumentFactory()
|
||||
factories.UserDocumentAccessFactory(document=parent, user=user)
|
||||
child = factories.DocumentFactory(parent=parent)
|
||||
grandchild = factories.DocumentFactory(parent=child)
|
||||
|
||||
# Call list endpoint
|
||||
list_response = client.get("/api/v1.0/documents/")
|
||||
list_results = list_response.json()["results"]
|
||||
|
||||
# Call all endpoint
|
||||
all_response = client.get("/api/v1.0/documents/all/")
|
||||
all_results = all_response.json()["results"]
|
||||
|
||||
# list should return only parent
|
||||
assert len(list_results) == 1
|
||||
assert list_results[0]["id"] == str(parent.id)
|
||||
|
||||
# all should return parent + child + grandchild
|
||||
assert len(all_results) == 3
|
||||
all_ids = {result["id"] for result in all_results}
|
||||
assert all_ids == {str(parent.id), str(child.id), str(grandchild.id)}
|
||||
@@ -46,6 +46,7 @@ def test_api_documents_children_list_anonymous_public_standalone(
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child1.is_encrypted,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 0,
|
||||
@@ -69,6 +70,7 @@ def test_api_documents_children_list_anonymous_public_standalone(
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child2.is_encrypted,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
@@ -122,6 +124,7 @@ def test_api_documents_children_list_anonymous_public_parent(django_assert_num_q
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child1.is_encrypted,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 0,
|
||||
@@ -145,6 +148,7 @@ def test_api_documents_children_list_anonymous_public_parent(django_assert_num_q
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child2.is_encrypted,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
@@ -217,6 +221,7 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child1.is_encrypted,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 0,
|
||||
@@ -240,6 +245,7 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child2.is_encrypted,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
@@ -298,6 +304,7 @@ def test_api_documents_children_list_authenticated_public_or_authenticated_paren
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child1.is_encrypted,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 0,
|
||||
@@ -321,6 +328,7 @@ def test_api_documents_children_list_authenticated_public_or_authenticated_paren
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child2.is_encrypted,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
@@ -406,6 +414,7 @@ def test_api_documents_children_list_authenticated_related_direct(
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child1.is_encrypted,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 0,
|
||||
@@ -429,6 +438,7 @@ def test_api_documents_children_list_authenticated_related_direct(
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child2.is_encrypted,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
@@ -490,6 +500,7 @@ def test_api_documents_children_list_authenticated_related_parent(
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child1.is_encrypted,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 0,
|
||||
@@ -513,6 +524,7 @@ def test_api_documents_children_list_authenticated_related_parent(
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child2.is_encrypted,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
@@ -626,6 +638,7 @@ def test_api_documents_children_list_authenticated_related_team_members(
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child1.is_encrypted,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 0,
|
||||
@@ -649,6 +662,7 @@ def test_api_documents_children_list_authenticated_related_team_members(
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child2.is_encrypted,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
"""Test on the CORS proxy API for documents."""
|
||||
|
||||
import socket
|
||||
import unittest.mock
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
from requests.exceptions import RequestException
|
||||
@@ -10,11 +13,17 @@ from core import factories
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
|
||||
@responses.activate
|
||||
def test_api_docs_cors_proxy_valid_url():
|
||||
def test_api_docs_cors_proxy_valid_url(mock_getaddrinfo):
|
||||
"""Test the CORS proxy API for documents with a valid URL."""
|
||||
document = factories.DocumentFactory(link_reach="public")
|
||||
|
||||
# Mock DNS resolution to return a public IP address
|
||||
mock_getaddrinfo.return_value = [
|
||||
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("8.8.8.8", 0))
|
||||
]
|
||||
|
||||
client = APIClient()
|
||||
url_to_fetch = "https://external-url.com/assets/logo-gouv.png"
|
||||
responses.get(url_to_fetch, body=b"", status=200, content_type="image/png")
|
||||
@@ -56,11 +65,17 @@ def test_api_docs_cors_proxy_without_url_query_string():
|
||||
assert response.json() == {"detail": "Missing 'url' query parameter"}
|
||||
|
||||
|
||||
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
|
||||
@responses.activate
|
||||
def test_api_docs_cors_proxy_anonymous_document_not_public():
|
||||
def test_api_docs_cors_proxy_anonymous_document_not_public(mock_getaddrinfo):
|
||||
"""Test the CORS proxy API for documents with an anonymous user and a non-public document."""
|
||||
document = factories.DocumentFactory(link_reach="authenticated")
|
||||
|
||||
# Mock DNS resolution to return a public IP address
|
||||
mock_getaddrinfo.return_value = [
|
||||
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("8.8.8.8", 0))
|
||||
]
|
||||
|
||||
client = APIClient()
|
||||
url_to_fetch = "https://external-url.com/assets/logo-gouv.png"
|
||||
responses.get(url_to_fetch, body=b"", status=200, content_type="image/png")
|
||||
@@ -73,14 +88,22 @@ def test_api_docs_cors_proxy_anonymous_document_not_public():
|
||||
}
|
||||
|
||||
|
||||
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
|
||||
@responses.activate
|
||||
def test_api_docs_cors_proxy_authenticated_user_accessing_protected_doc():
|
||||
def test_api_docs_cors_proxy_authenticated_user_accessing_protected_doc(
|
||||
mock_getaddrinfo,
|
||||
):
|
||||
"""
|
||||
Test the CORS proxy API for documents with an authenticated user accessing a protected
|
||||
document.
|
||||
"""
|
||||
document = factories.DocumentFactory(link_reach="authenticated")
|
||||
|
||||
# Mock DNS resolution to return a public IP address
|
||||
mock_getaddrinfo.return_value = [
|
||||
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("8.8.8.8", 0))
|
||||
]
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
@@ -115,14 +138,22 @@ def test_api_docs_cors_proxy_authenticated_user_accessing_protected_doc():
|
||||
assert response.streaming_content
|
||||
|
||||
|
||||
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
|
||||
@responses.activate
|
||||
def test_api_docs_cors_proxy_authenticated_not_accessing_restricted_doc():
|
||||
def test_api_docs_cors_proxy_authenticated_not_accessing_restricted_doc(
|
||||
mock_getaddrinfo,
|
||||
):
|
||||
"""
|
||||
Test the CORS proxy API for documents with an authenticated user not accessing a restricted
|
||||
document.
|
||||
"""
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
|
||||
# Mock DNS resolution to return a public IP address
|
||||
mock_getaddrinfo.return_value = [
|
||||
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("8.8.8.8", 0))
|
||||
]
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
@@ -138,18 +169,72 @@ def test_api_docs_cors_proxy_authenticated_not_accessing_restricted_doc():
|
||||
}
|
||||
|
||||
|
||||
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
|
||||
@responses.activate
|
||||
def test_api_docs_cors_proxy_unsupported_media_type():
|
||||
def test_api_docs_cors_proxy_unsupported_media_type(mock_getaddrinfo):
|
||||
"""Test the CORS proxy API for documents with an unsupported media type."""
|
||||
document = factories.DocumentFactory(link_reach="public")
|
||||
|
||||
# Mock DNS resolution to return a public IP address
|
||||
mock_getaddrinfo.return_value = [
|
||||
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("8.8.8.8", 0))
|
||||
]
|
||||
|
||||
client = APIClient()
|
||||
url_to_fetch = "https://external-url.com/assets/index.html"
|
||||
responses.get(url_to_fetch, body=b"", status=200, content_type="text/html")
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
|
||||
)
|
||||
assert response.status_code == 415
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"detail": "Invalid URL used."}
|
||||
|
||||
|
||||
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
|
||||
@responses.activate
|
||||
def test_api_docs_cors_proxy_redirect(mock_getaddrinfo):
|
||||
"""Test the CORS proxy API for documents with a redirect."""
|
||||
document = factories.DocumentFactory(link_reach="public")
|
||||
|
||||
# Mock DNS resolution to return a public IP address
|
||||
mock_getaddrinfo.return_value = [
|
||||
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("8.8.8.8", 0))
|
||||
]
|
||||
|
||||
client = APIClient()
|
||||
url_to_fetch = "https://external-url.com/assets/index.html"
|
||||
responses.get(
|
||||
url_to_fetch,
|
||||
body=b"",
|
||||
status=302,
|
||||
headers={"Location": "https://external-url.com/other/assets/index.html"},
|
||||
)
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"detail": "Invalid URL used."}
|
||||
|
||||
|
||||
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
|
||||
@responses.activate
|
||||
def test_api_docs_cors_proxy_url_not_returning_200(mock_getaddrinfo):
|
||||
"""Test the CORS proxy API for documents with a URL that does not return 200."""
|
||||
document = factories.DocumentFactory(link_reach="public")
|
||||
|
||||
# Mock DNS resolution to return a public IP address
|
||||
mock_getaddrinfo.return_value = [
|
||||
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("8.8.8.8", 0))
|
||||
]
|
||||
|
||||
client = APIClient()
|
||||
url_to_fetch = "https://external-url.com/assets/index.html"
|
||||
responses.get(url_to_fetch, body=b"", status=404)
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"detail": "Invalid URL used."}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -173,11 +258,17 @@ def test_api_docs_cors_proxy_invalid_url(url_to_fetch):
|
||||
assert response.json() == ["Enter a valid URL."]
|
||||
|
||||
|
||||
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
|
||||
@responses.activate
|
||||
def test_api_docs_cors_proxy_request_failed():
|
||||
def test_api_docs_cors_proxy_request_failed(mock_getaddrinfo):
|
||||
"""Test the CORS proxy API for documents with a request failed."""
|
||||
document = factories.DocumentFactory(link_reach="public")
|
||||
|
||||
# Mock DNS resolution to return a public IP address
|
||||
mock_getaddrinfo.return_value = [
|
||||
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("8.8.8.8", 0))
|
||||
]
|
||||
|
||||
client = APIClient()
|
||||
url_to_fetch = "https://external-url.com/assets/index.html"
|
||||
responses.get(url_to_fetch, body=RequestException("Connection refused"))
|
||||
@@ -185,6 +276,164 @@ def test_api_docs_cors_proxy_request_failed():
|
||||
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {
|
||||
"error": "Failed to fetch resource from https://external-url.com/assets/index.html"
|
||||
}
|
||||
assert response.json() == {"detail": "Invalid URL used."}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url_to_fetch",
|
||||
[
|
||||
"http://localhost/image.png",
|
||||
"https://localhost/image.png",
|
||||
"http://127.0.0.1/image.png",
|
||||
"https://127.0.0.1/image.png",
|
||||
"http://0.0.0.0/image.png",
|
||||
"https://0.0.0.0/image.png",
|
||||
"http://[::1]/image.png",
|
||||
"https://[::1]/image.png",
|
||||
"http://[0:0:0:0:0:0:0:1]/image.png",
|
||||
"https://[0:0:0:0:0:0:0:1]/image.png",
|
||||
],
|
||||
)
|
||||
def test_api_docs_cors_proxy_blocks_localhost(url_to_fetch):
|
||||
"""Test that the CORS proxy API blocks localhost variations."""
|
||||
document = factories.DocumentFactory(link_reach="public")
|
||||
|
||||
client = APIClient()
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"] == "Invalid URL used."
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url_to_fetch",
|
||||
[
|
||||
"http://10.0.0.1/image.png",
|
||||
"https://10.0.0.1/image.png",
|
||||
"http://172.16.0.1/image.png",
|
||||
"https://172.16.0.1/image.png",
|
||||
"http://192.168.1.1/image.png",
|
||||
"https://192.168.1.1/image.png",
|
||||
"http://10.255.255.255/image.png",
|
||||
"https://10.255.255.255/image.png",
|
||||
"http://172.31.255.255/image.png",
|
||||
"https://172.31.255.255/image.png",
|
||||
"http://192.168.255.255/image.png",
|
||||
"https://192.168.255.255/image.png",
|
||||
],
|
||||
)
|
||||
def test_api_docs_cors_proxy_blocks_private_ips(url_to_fetch):
|
||||
"""Test that the CORS proxy API blocks private IP addresses."""
|
||||
document = factories.DocumentFactory(link_reach="public")
|
||||
|
||||
client = APIClient()
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"] == "Invalid URL used."
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url_to_fetch",
|
||||
[
|
||||
"http://169.254.1.1/image.png",
|
||||
"https://169.254.1.1/image.png",
|
||||
"http://169.254.255.255/image.png",
|
||||
"https://169.254.255.255/image.png",
|
||||
],
|
||||
)
|
||||
def test_api_docs_cors_proxy_blocks_link_local(url_to_fetch):
|
||||
"""Test that the CORS proxy API blocks link-local addresses."""
|
||||
document = factories.DocumentFactory(link_reach="public")
|
||||
|
||||
client = APIClient()
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"] == "Invalid URL used."
|
||||
|
||||
|
||||
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
|
||||
@responses.activate
|
||||
def test_api_docs_cors_proxy_blocks_dns_rebinding_to_private_ip(mock_getaddrinfo):
|
||||
"""Test that the CORS proxy API blocks DNS rebinding attacks to private IPs."""
|
||||
document = factories.DocumentFactory(link_reach="public")
|
||||
|
||||
# Mock DNS resolution to return a private IP address
|
||||
mock_getaddrinfo.return_value = [
|
||||
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("192.168.1.1", 0))
|
||||
]
|
||||
|
||||
client = APIClient()
|
||||
url_to_fetch = "https://malicious-domain.com/image.png"
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"] == "Invalid URL used."
|
||||
mock_getaddrinfo.assert_called_once()
|
||||
|
||||
|
||||
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
|
||||
@responses.activate
|
||||
def test_api_docs_cors_proxy_blocks_dns_rebinding_to_localhost(mock_getaddrinfo):
|
||||
"""Test that the CORS proxy API blocks DNS rebinding attacks to localhost."""
|
||||
document = factories.DocumentFactory(link_reach="public")
|
||||
|
||||
# Mock DNS resolution to return localhost
|
||||
mock_getaddrinfo.return_value = [
|
||||
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("127.0.0.1", 0))
|
||||
]
|
||||
|
||||
client = APIClient()
|
||||
url_to_fetch = "https://malicious-domain.com/image.png"
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"] == "Invalid URL used."
|
||||
mock_getaddrinfo.assert_called_once()
|
||||
|
||||
|
||||
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
|
||||
def test_api_docs_cors_proxy_handles_dns_resolution_failure(mock_getaddrinfo):
|
||||
"""Test that the CORS proxy API handles DNS resolution failures gracefully."""
|
||||
document = factories.DocumentFactory(link_reach="public")
|
||||
|
||||
# Mock DNS resolution to fail
|
||||
mock_getaddrinfo.side_effect = socket.gaierror("Name or service not known")
|
||||
|
||||
client = APIClient()
|
||||
url_to_fetch = "https://nonexistent-domain-12345.com/image.png"
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"] == "Invalid URL used."
|
||||
mock_getaddrinfo.assert_called_once()
|
||||
|
||||
|
||||
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
|
||||
def test_api_docs_cors_proxy_blocks_multiple_resolved_ips_if_any_private(
|
||||
mock_getaddrinfo,
|
||||
):
|
||||
"""Test that the CORS proxy API blocks if any resolved IP is private."""
|
||||
document = factories.DocumentFactory(link_reach="public")
|
||||
|
||||
# Mock DNS resolution to return both public and private IPs
|
||||
mock_getaddrinfo.return_value = [
|
||||
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("8.8.8.8", 0)),
|
||||
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("192.168.1.1", 0)),
|
||||
]
|
||||
|
||||
client = APIClient()
|
||||
url_to_fetch = "https://example.com/image.png"
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"] == "Invalid URL used."
|
||||
mock_getaddrinfo.assert_called_once()
|
||||
|
||||
@@ -16,6 +16,7 @@ from rest_framework.test import APIClient
|
||||
from core import factories
|
||||
from core.api.serializers import ServerCreateDocumentSerializer
|
||||
from core.models import Document, Invitation, User
|
||||
from core.services import mime_types
|
||||
from core.services.converter_services import ConversionError, YdocConverter
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
@@ -191,7 +192,9 @@ def test_api_documents_create_for_owner_existing(mock_convert_md):
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
mock_convert_md.assert_called_once_with("Document content")
|
||||
mock_convert_md.assert_called_once_with(
|
||||
"Document content", mime_types.MARKDOWN, mime_types.YJS
|
||||
)
|
||||
|
||||
document = Document.objects.get()
|
||||
assert response.json() == {"id": str(document.id)}
|
||||
@@ -236,7 +239,9 @@ def test_api_documents_create_for_owner_new_user(mock_convert_md):
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
mock_convert_md.assert_called_once_with("Document content")
|
||||
mock_convert_md.assert_called_once_with(
|
||||
"Document content", mime_types.MARKDOWN, mime_types.YJS
|
||||
)
|
||||
|
||||
document = Document.objects.get()
|
||||
assert response.json() == {"id": str(document.id)}
|
||||
@@ -297,7 +302,9 @@ def test_api_documents_create_for_owner_existing_user_email_no_sub_with_fallback
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
mock_convert_md.assert_called_once_with("Document content")
|
||||
mock_convert_md.assert_called_once_with(
|
||||
"Document content", mime_types.MARKDOWN, mime_types.YJS
|
||||
)
|
||||
|
||||
document = Document.objects.get()
|
||||
assert response.json() == {"id": str(document.id)}
|
||||
@@ -393,7 +400,9 @@ def test_api_documents_create_for_owner_new_user_no_sub_no_fallback_allow_duplic
|
||||
HTTP_AUTHORIZATION="Bearer DummyToken",
|
||||
)
|
||||
assert response.status_code == 201
|
||||
mock_convert_md.assert_called_once_with("Document content")
|
||||
mock_convert_md.assert_called_once_with(
|
||||
"Document content", mime_types.MARKDOWN, mime_types.YJS
|
||||
)
|
||||
|
||||
document = Document.objects.get()
|
||||
assert response.json() == {"id": str(document.id)}
|
||||
@@ -474,7 +483,9 @@ def test_api_documents_create_for_owner_with_default_language(
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
mock_convert_md.assert_called_once_with("Document content")
|
||||
mock_convert_md.assert_called_once_with(
|
||||
"Document content", mime_types.MARKDOWN, mime_types.YJS
|
||||
)
|
||||
assert mock_send.call_args[0][3] == "de-de"
|
||||
|
||||
|
||||
@@ -501,7 +512,9 @@ def test_api_documents_create_for_owner_with_custom_language(mock_convert_md):
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
mock_convert_md.assert_called_once_with("Document content")
|
||||
mock_convert_md.assert_called_once_with(
|
||||
"Document content", mime_types.MARKDOWN, mime_types.YJS
|
||||
)
|
||||
|
||||
assert len(mail.outbox) == 1
|
||||
email = mail.outbox[0]
|
||||
@@ -537,7 +550,9 @@ def test_api_documents_create_for_owner_with_custom_subject_and_message(
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
mock_convert_md.assert_called_once_with("Document content")
|
||||
mock_convert_md.assert_called_once_with(
|
||||
"Document content", mime_types.MARKDOWN, mime_types.YJS
|
||||
)
|
||||
|
||||
assert len(mail.outbox) == 1
|
||||
email = mail.outbox[0]
|
||||
@@ -571,7 +586,9 @@ def test_api_documents_create_for_owner_with_converter_exception(
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer DummyToken",
|
||||
)
|
||||
mock_convert_md.assert_called_once_with("Document content")
|
||||
mock_convert_md.assert_called_once_with(
|
||||
"Document content", mime_types.MARKDOWN, mime_types.YJS
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"content": ["Could not convert content"]}
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
"""
|
||||
Tests for Documents API endpoint in impress's core app: create with file upload
|
||||
"""
|
||||
|
||||
from base64 import b64decode, binascii
|
||||
from io import BytesIO
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
from core.models import Document
|
||||
from core.services import mime_types
|
||||
from core.services.converter_services import (
|
||||
ConversionError,
|
||||
ServiceUnavailableError,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_documents_create_with_file_anonymous():
|
||||
"""Anonymous users should not be allowed to create documents with file upload."""
|
||||
# Create a fake DOCX file
|
||||
file_content = b"fake docx content"
|
||||
file = BytesIO(file_content)
|
||||
file.name = "test_document.docx"
|
||||
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/",
|
||||
{
|
||||
"file": file,
|
||||
},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert not Document.objects.exists()
|
||||
|
||||
|
||||
@patch("core.services.converter_services.Converter.convert")
|
||||
def test_api_documents_create_with_docx_file_success(mock_convert):
|
||||
"""
|
||||
Authenticated users should be able to create documents by uploading a DOCX file.
|
||||
The file should be converted to YJS format and the title should be set from filename.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Mock the conversion
|
||||
converted_yjs = "base64encodedyjscontent"
|
||||
mock_convert.return_value = converted_yjs
|
||||
|
||||
# Create a fake DOCX file
|
||||
file_content = b"fake docx content"
|
||||
file = BytesIO(file_content)
|
||||
file.name = "My Important Document.docx"
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/documents/",
|
||||
{
|
||||
"file": file,
|
||||
},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
document = Document.objects.get()
|
||||
assert document.title == "My Important Document.docx"
|
||||
assert document.content == converted_yjs
|
||||
assert document.accesses.filter(role="owner", user=user).exists()
|
||||
|
||||
# Verify the converter was called correctly
|
||||
mock_convert.assert_called_once_with(
|
||||
file_content,
|
||||
content_type=mime_types.DOCX,
|
||||
accept=mime_types.YJS,
|
||||
)
|
||||
|
||||
|
||||
@patch("core.services.converter_services.Converter.convert")
|
||||
def test_api_documents_create_with_markdown_file_success(mock_convert):
|
||||
"""
|
||||
Authenticated users should be able to create documents by uploading a Markdown file.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Mock the conversion
|
||||
converted_yjs = "base64encodedyjscontent"
|
||||
mock_convert.return_value = converted_yjs
|
||||
|
||||
# Create a fake Markdown file
|
||||
file_content = b"# Test Document\n\nThis is a test."
|
||||
file = BytesIO(file_content)
|
||||
file.name = "readme.md"
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/documents/",
|
||||
{
|
||||
"file": file,
|
||||
},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
document = Document.objects.get()
|
||||
assert document.title == "readme.md"
|
||||
assert document.content == converted_yjs
|
||||
assert document.accesses.filter(role="owner", user=user).exists()
|
||||
|
||||
# Verify the converter was called correctly
|
||||
mock_convert.assert_called_once_with(
|
||||
file_content,
|
||||
content_type=mime_types.MARKDOWN,
|
||||
accept=mime_types.YJS,
|
||||
)
|
||||
|
||||
|
||||
@patch("core.services.converter_services.Converter.convert")
|
||||
def test_api_documents_create_with_file_and_explicit_title(mock_convert):
|
||||
"""
|
||||
When both file and title are provided, the filename should override the title.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Mock the conversion
|
||||
converted_yjs = "base64encodedyjscontent"
|
||||
mock_convert.return_value = converted_yjs
|
||||
|
||||
# Create a fake DOCX file
|
||||
file_content = b"fake docx content"
|
||||
file = BytesIO(file_content)
|
||||
file.name = "Uploaded Document.docx"
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/documents/",
|
||||
{
|
||||
"file": file,
|
||||
"title": "This should be overridden",
|
||||
},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
document = Document.objects.get()
|
||||
# The filename should take precedence
|
||||
assert document.title == "Uploaded Document.docx"
|
||||
|
||||
|
||||
def test_api_documents_create_with_empty_file():
|
||||
"""
|
||||
Creating a document with an empty file should fail with a validation error.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Create an empty file
|
||||
file = BytesIO(b"")
|
||||
file.name = "empty.docx"
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/documents/",
|
||||
{
|
||||
"file": file,
|
||||
},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"file": ["The submitted file is empty."]}
|
||||
assert not Document.objects.exists()
|
||||
|
||||
|
||||
@patch("core.services.converter_services.Converter.convert")
|
||||
def test_api_documents_create_with_file_conversion_error(mock_convert):
|
||||
"""
|
||||
When conversion fails, the API should return a 400 error with appropriate message.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Mock the conversion to raise an error
|
||||
mock_convert.side_effect = ConversionError("Failed to convert document")
|
||||
|
||||
# Create a fake DOCX file
|
||||
file_content = b"fake invalid docx content"
|
||||
file = BytesIO(file_content)
|
||||
file.name = "corrupted.docx"
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/documents/",
|
||||
{
|
||||
"file": file,
|
||||
},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"file": ["Could not convert file content"]}
|
||||
assert not Document.objects.exists()
|
||||
|
||||
|
||||
@patch("core.services.converter_services.Converter.convert")
|
||||
def test_api_documents_create_with_file_service_unavailable(mock_convert):
|
||||
"""
|
||||
When the conversion service is unavailable, appropriate error should be returned.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Mock the conversion to raise ServiceUnavailableError
|
||||
mock_convert.side_effect = ServiceUnavailableError(
|
||||
"Failed to connect to conversion service"
|
||||
)
|
||||
|
||||
# Create a fake DOCX file
|
||||
file_content = b"fake docx content"
|
||||
file = BytesIO(file_content)
|
||||
file.name = "document.docx"
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/documents/",
|
||||
{
|
||||
"file": file,
|
||||
},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"file": ["Could not convert file content"]}
|
||||
assert not Document.objects.exists()
|
||||
|
||||
|
||||
def test_api_documents_create_without_file_still_works():
|
||||
"""
|
||||
Creating a document without a file should still work as before (backward compatibility).
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/documents/",
|
||||
{
|
||||
"title": "Regular document without file",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
document = Document.objects.get()
|
||||
assert document.title == "Regular document without file"
|
||||
assert document.content is None
|
||||
assert document.accesses.filter(role="owner", user=user).exists()
|
||||
|
||||
|
||||
@patch("core.services.converter_services.Converter.convert")
|
||||
def test_api_documents_create_with_file_null_value(mock_convert):
|
||||
"""
|
||||
Passing file=null should be treated as no file upload.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/documents/",
|
||||
{
|
||||
"title": "Document with null file",
|
||||
"file": None,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
document = Document.objects.get()
|
||||
assert document.title == "Document with null file"
|
||||
# Converter should not have been called
|
||||
mock_convert.assert_not_called()
|
||||
|
||||
|
||||
@patch("core.services.converter_services.Converter.convert")
|
||||
def test_api_documents_create_with_file_preserves_content_format(mock_convert):
|
||||
"""
|
||||
Verify that the converted content is stored correctly in the document.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Mock the conversion with realistic base64-encoded YJS data
|
||||
converted_yjs = "AQMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICA="
|
||||
mock_convert.return_value = converted_yjs
|
||||
|
||||
# Create a fake DOCX file
|
||||
file_content = b"fake docx with complex formatting"
|
||||
file = BytesIO(file_content)
|
||||
file.name = "complex_document.docx"
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/documents/",
|
||||
{
|
||||
"file": file,
|
||||
},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
document = Document.objects.get()
|
||||
|
||||
# Verify the content is stored as returned by the converter
|
||||
assert document.content == converted_yjs
|
||||
|
||||
# Verify it's valid base64 (can be decoded)
|
||||
try:
|
||||
b64decode(converted_yjs)
|
||||
except binascii.Error:
|
||||
pytest.fail("Content should be valid base64-encoded data")
|
||||
|
||||
|
||||
@patch("core.services.converter_services.Converter.convert")
|
||||
def test_api_documents_create_with_file_unicode_filename(mock_convert):
|
||||
"""
|
||||
Test that Unicode characters in filenames are handled correctly.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Mock the conversion
|
||||
converted_yjs = "base64encodedyjscontent"
|
||||
mock_convert.return_value = converted_yjs
|
||||
|
||||
# Create a file with Unicode characters in the name
|
||||
file_content = b"fake docx content"
|
||||
file = BytesIO(file_content)
|
||||
file.name = "文档-télécharger-документ.docx"
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/documents/",
|
||||
{
|
||||
"file": file,
|
||||
},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
document = Document.objects.get()
|
||||
assert document.title == "文档-télécharger-документ.docx"
|
||||
|
||||
|
||||
def test_api_documents_create_with_file_max_size_exceeded(settings):
|
||||
"""
|
||||
The uploaded file should not exceed the maximum size in settings.
|
||||
"""
|
||||
settings.CONVERSION_FILE_MAX_SIZE = 1 # 1 byte for test
|
||||
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
file = BytesIO(b"a" * (10))
|
||||
file.name = "test.docx"
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/documents/",
|
||||
{
|
||||
"file": file,
|
||||
},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
assert response.json() == {"file": ["File size exceeds the maximum limit of 0 MB."]}
|
||||
|
||||
|
||||
def test_api_documents_create_with_file_extension_not_allowed(settings):
|
||||
"""
|
||||
The uploaded file should not have an allowed extension.
|
||||
"""
|
||||
settings.CONVERSION_FILE_EXTENSIONS_ALLOWED = [".docx"]
|
||||
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
file = BytesIO(b"fake docx content")
|
||||
file.name = "test.md"
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/documents/",
|
||||
{
|
||||
"file": file,
|
||||
},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {
|
||||
"file": [
|
||||
"File extension .md is not allowed. Allowed extensions are: ['.docx']."
|
||||
]
|
||||
}
|
||||
@@ -43,6 +43,7 @@ def test_api_documents_descendants_list_anonymous_public_standalone():
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child1.is_encrypted,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 1,
|
||||
@@ -68,6 +69,7 @@ def test_api_documents_descendants_list_anonymous_public_standalone():
|
||||
"excerpt": grand_child.excerpt,
|
||||
"id": str(grand_child.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": grand_child.is_encrypted,
|
||||
"link_reach": grand_child.link_reach,
|
||||
"link_role": grand_child.link_role,
|
||||
"numchild": 0,
|
||||
@@ -91,6 +93,7 @@ def test_api_documents_descendants_list_anonymous_public_standalone():
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child2.is_encrypted,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
@@ -143,6 +146,7 @@ def test_api_documents_descendants_list_anonymous_public_parent():
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child1.is_encrypted,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 1,
|
||||
@@ -166,6 +170,7 @@ def test_api_documents_descendants_list_anonymous_public_parent():
|
||||
"excerpt": grand_child.excerpt,
|
||||
"id": str(grand_child.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": grand_child.is_encrypted,
|
||||
"link_reach": grand_child.link_reach,
|
||||
"link_role": grand_child.link_role,
|
||||
"numchild": 0,
|
||||
@@ -189,6 +194,7 @@ def test_api_documents_descendants_list_anonymous_public_parent():
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child2.is_encrypted,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
@@ -262,6 +268,7 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child1.is_encrypted,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 1,
|
||||
@@ -285,6 +292,7 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen
|
||||
"excerpt": grand_child.excerpt,
|
||||
"id": str(grand_child.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": grand_child.is_encrypted,
|
||||
"link_reach": grand_child.link_reach,
|
||||
"link_role": grand_child.link_role,
|
||||
"numchild": 0,
|
||||
@@ -308,6 +316,7 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child2.is_encrypted,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
@@ -366,6 +375,7 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child1.is_encrypted,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 1,
|
||||
@@ -389,6 +399,7 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa
|
||||
"excerpt": grand_child.excerpt,
|
||||
"id": str(grand_child.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": grand_child.is_encrypted,
|
||||
"link_reach": grand_child.link_reach,
|
||||
"link_role": grand_child.link_role,
|
||||
"numchild": 0,
|
||||
@@ -412,6 +423,7 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child2.is_encrypted,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
@@ -491,6 +503,7 @@ def test_api_documents_descendants_list_authenticated_related_direct():
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child1.is_encrypted,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 1,
|
||||
@@ -514,6 +527,7 @@ def test_api_documents_descendants_list_authenticated_related_direct():
|
||||
"excerpt": grand_child.excerpt,
|
||||
"id": str(grand_child.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": grand_child.is_encrypted,
|
||||
"link_reach": grand_child.link_reach,
|
||||
"link_role": grand_child.link_role,
|
||||
"numchild": 0,
|
||||
@@ -537,6 +551,7 @@ def test_api_documents_descendants_list_authenticated_related_direct():
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child2.is_encrypted,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
@@ -596,6 +611,7 @@ def test_api_documents_descendants_list_authenticated_related_parent():
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child1.is_encrypted,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 1,
|
||||
@@ -619,6 +635,7 @@ def test_api_documents_descendants_list_authenticated_related_parent():
|
||||
"excerpt": grand_child.excerpt,
|
||||
"id": str(grand_child.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": grand_child.is_encrypted,
|
||||
"link_reach": grand_child.link_reach,
|
||||
"link_role": grand_child.link_role,
|
||||
"numchild": 0,
|
||||
@@ -642,6 +659,7 @@ def test_api_documents_descendants_list_authenticated_related_parent():
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child2.is_encrypted,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
@@ -747,6 +765,7 @@ def test_api_documents_descendants_list_authenticated_related_team_members(
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child1.is_encrypted,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 1,
|
||||
@@ -770,6 +789,7 @@ def test_api_documents_descendants_list_authenticated_related_team_members(
|
||||
"excerpt": grand_child.excerpt,
|
||||
"id": str(grand_child.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": grand_child.is_encrypted,
|
||||
"link_reach": grand_child.link_reach,
|
||||
"link_role": grand_child.link_role,
|
||||
"numchild": 0,
|
||||
@@ -793,6 +813,7 @@ def test_api_documents_descendants_list_authenticated_related_team_members(
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child2.is_encrypted,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
|
||||
@@ -71,6 +71,7 @@ def test_api_document_favorite_list_authenticated_with_favorite():
|
||||
"excerpt": document.excerpt,
|
||||
"id": str(document.id),
|
||||
"is_favorite": True,
|
||||
"is_encrypted": document.is_encrypted,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses_ancestors": 1,
|
||||
@@ -83,3 +84,34 @@ def test_api_document_favorite_list_authenticated_with_favorite():
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_api_document_favorite_list_with_favorite_children():
|
||||
"""Authenticated users should receive their favorite documents, including children."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
root = factories.DocumentFactory(creator=user, users=[user])
|
||||
children = factories.DocumentFactory.create_batch(
|
||||
2, parent=root, favorited_by=[user]
|
||||
)
|
||||
|
||||
access = factories.UserDocumentAccessFactory(
|
||||
user=user, role=models.RoleChoices.READER, document__favorited_by=[user]
|
||||
)
|
||||
|
||||
other_root = factories.DocumentFactory(creator=user, users=[user])
|
||||
factories.DocumentFactory.create_batch(2, parent=other_root)
|
||||
|
||||
response = client.get("/api/v1.0/documents/favorite_list/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["count"] == 3
|
||||
|
||||
content = response.json()["results"]
|
||||
|
||||
assert content[0]["id"] == str(children[0].id)
|
||||
assert content[1]["id"] == str(children[1].id)
|
||||
assert content[2]["id"] == str(access.document.id)
|
||||
|
||||
@@ -73,6 +73,7 @@ def test_api_documents_list_format():
|
||||
"depth": 1,
|
||||
"excerpt": document.excerpt,
|
||||
"is_favorite": True,
|
||||
"is_encrypted": document.is_encrypted,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses_ancestors": 3,
|
||||
|
||||
@@ -312,6 +312,69 @@ def test_api_documents_list_filter_is_favorite_invalid():
|
||||
assert len(results) == 5
|
||||
|
||||
|
||||
# Filters: is_encrypted
|
||||
|
||||
|
||||
def test_api_documents_list_filter_is_encrypted_true():
|
||||
"""
|
||||
Authenticated users should be able to filter encrypted documents.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.DocumentFactory.create_batch(3, users=[user])
|
||||
factories.DocumentFactory.create_batch(2, users=[user])
|
||||
|
||||
response = client.get("/api/v1.0/documents/?is_encrypted=true")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 3
|
||||
|
||||
# Ensure all results are encrypted
|
||||
for result in results:
|
||||
assert result["is_encrypted"] is True
|
||||
|
||||
|
||||
def test_api_documents_list_filter_is_encrypted_false():
|
||||
"""
|
||||
Authenticated users should be able to filter documents not encrypted.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.DocumentFactory.create_batch(3, users=[user])
|
||||
factories.DocumentFactory.create_batch(2, users=[user])
|
||||
|
||||
response = client.get("/api/v1.0/documents/?is_encrypted=false")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 2
|
||||
|
||||
# Ensure all results are not encrypted
|
||||
for result in results:
|
||||
assert result["is_encrypted"] is False
|
||||
|
||||
|
||||
def test_api_documents_list_filter_is_encrypted_invalid():
|
||||
"""Filtering with an invalid `is_encrypted` value should do nothing."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.DocumentFactory.create_batch(3, users=[user])
|
||||
factories.DocumentFactory.create_batch(2, users=[user])
|
||||
|
||||
response = client.get("/api/v1.0/documents/?is_encrypted=invalid")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 5
|
||||
|
||||
|
||||
# Filters: is_masked
|
||||
|
||||
|
||||
|
||||
@@ -75,6 +75,7 @@ def test_api_documents_retrieve_anonymous_public_standalone():
|
||||
"depth": 1,
|
||||
"excerpt": document.excerpt,
|
||||
"is_favorite": False,
|
||||
"is_encrypted": document.is_encrypted,
|
||||
"link_reach": "public",
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses_ancestors": 0,
|
||||
@@ -151,6 +152,7 @@ def test_api_documents_retrieve_anonymous_public_parent():
|
||||
"depth": 3,
|
||||
"excerpt": document.excerpt,
|
||||
"is_favorite": False,
|
||||
"is_encrypted": document.is_encrypted,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses_ancestors": 0,
|
||||
@@ -260,6 +262,7 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
|
||||
"deleted_at": None,
|
||||
"excerpt": document.excerpt,
|
||||
"is_favorite": False,
|
||||
"is_encrypted": document.is_encrypted,
|
||||
"link_reach": reach,
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses_ancestors": 0,
|
||||
@@ -343,6 +346,7 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
|
||||
"deleted_at": None,
|
||||
"excerpt": document.excerpt,
|
||||
"is_favorite": False,
|
||||
"is_encrypted": document.is_encrypted,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses_ancestors": 0,
|
||||
@@ -458,6 +462,7 @@ def test_api_documents_retrieve_authenticated_related_direct():
|
||||
"depth": 1,
|
||||
"excerpt": document.excerpt,
|
||||
"is_favorite": False,
|
||||
"is_encrypted": document.is_encrypted,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses_ancestors": 2,
|
||||
@@ -541,6 +546,7 @@ def test_api_documents_retrieve_authenticated_related_parent():
|
||||
"deleted_at": None,
|
||||
"excerpt": document.excerpt,
|
||||
"is_favorite": False,
|
||||
"is_encrypted": document.is_encrypted,
|
||||
"link_reach": "restricted",
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses_ancestors": 2,
|
||||
@@ -698,6 +704,7 @@ def test_api_documents_retrieve_authenticated_related_team_members(
|
||||
"depth": 1,
|
||||
"excerpt": document.excerpt,
|
||||
"is_favorite": False,
|
||||
"is_encrypted": document.is_encrypted,
|
||||
"link_reach": "restricted",
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses_ancestors": 5,
|
||||
@@ -765,6 +772,7 @@ def test_api_documents_retrieve_authenticated_related_team_administrators(
|
||||
"depth": 1,
|
||||
"excerpt": document.excerpt,
|
||||
"is_favorite": False,
|
||||
"is_encrypted": document.is_encrypted,
|
||||
"link_reach": "restricted",
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses_ancestors": 5,
|
||||
@@ -832,6 +840,7 @@ def test_api_documents_retrieve_authenticated_related_team_owners(
|
||||
"depth": 1,
|
||||
"excerpt": document.excerpt,
|
||||
"is_favorite": False,
|
||||
"is_encrypted": document.is_encrypted,
|
||||
"link_reach": "restricted",
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses_ancestors": 5,
|
||||
|
||||
@@ -54,6 +54,7 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q
|
||||
"excerpt": child.excerpt,
|
||||
"id": str(child.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child.is_encrypted,
|
||||
"link_reach": child.link_reach,
|
||||
"link_role": child.link_role,
|
||||
"numchild": 0,
|
||||
@@ -78,6 +79,7 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q
|
||||
"excerpt": document.excerpt,
|
||||
"id": str(document.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": document.is_encrypted,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"numchild": 1,
|
||||
@@ -102,6 +104,7 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q
|
||||
"excerpt": sibling1.excerpt,
|
||||
"id": str(sibling1.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": sibling1.is_encrypted,
|
||||
"link_reach": sibling1.link_reach,
|
||||
"link_role": sibling1.link_role,
|
||||
"numchild": 0,
|
||||
@@ -126,6 +129,7 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q
|
||||
"excerpt": sibling2.excerpt,
|
||||
"id": str(sibling2.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": sibling2.is_encrypted,
|
||||
"link_reach": sibling2.link_reach,
|
||||
"link_role": sibling2.link_role,
|
||||
"numchild": 0,
|
||||
@@ -146,6 +150,7 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q
|
||||
"excerpt": parent.excerpt,
|
||||
"id": str(parent.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": parent.is_encrypted,
|
||||
"link_reach": parent.link_reach,
|
||||
"link_role": parent.link_role,
|
||||
"numchild": 3,
|
||||
@@ -219,6 +224,7 @@ def test_api_documents_tree_list_anonymous_public_parent():
|
||||
"excerpt": child.excerpt,
|
||||
"id": str(child.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child.is_encrypted,
|
||||
"link_reach": child.link_reach,
|
||||
"link_role": child.link_role,
|
||||
"numchild": 0,
|
||||
@@ -243,6 +249,7 @@ def test_api_documents_tree_list_anonymous_public_parent():
|
||||
"excerpt": document.excerpt,
|
||||
"id": str(document.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": document.is_encrypted,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"numchild": 1,
|
||||
@@ -271,6 +278,7 @@ def test_api_documents_tree_list_anonymous_public_parent():
|
||||
"excerpt": document_sibling.excerpt,
|
||||
"id": str(document_sibling.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": document_sibling.is_encrypted,
|
||||
"link_reach": document_sibling.link_reach,
|
||||
"link_role": document_sibling.link_role,
|
||||
"numchild": 0,
|
||||
@@ -293,6 +301,7 @@ def test_api_documents_tree_list_anonymous_public_parent():
|
||||
"excerpt": parent.excerpt,
|
||||
"id": str(parent.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": parent.is_encrypted,
|
||||
"link_reach": parent.link_reach,
|
||||
"link_role": parent.link_role,
|
||||
"numchild": 2,
|
||||
@@ -319,6 +328,7 @@ def test_api_documents_tree_list_anonymous_public_parent():
|
||||
"excerpt": parent_sibling.excerpt,
|
||||
"id": str(parent_sibling.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": parent_sibling.is_encrypted,
|
||||
"link_reach": parent_sibling.link_reach,
|
||||
"link_role": parent_sibling.link_role,
|
||||
"numchild": 0,
|
||||
@@ -341,6 +351,7 @@ def test_api_documents_tree_list_anonymous_public_parent():
|
||||
"excerpt": grand_parent.excerpt,
|
||||
"id": str(grand_parent.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": grand_parent.is_encrypted,
|
||||
"link_reach": grand_parent.link_reach,
|
||||
"link_role": grand_parent.link_role,
|
||||
"numchild": 2,
|
||||
@@ -421,6 +432,7 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated
|
||||
"excerpt": child.excerpt,
|
||||
"id": str(child.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child.is_encrypted,
|
||||
"link_reach": child.link_reach,
|
||||
"link_role": child.link_role,
|
||||
"numchild": 0,
|
||||
@@ -443,6 +455,7 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated
|
||||
"excerpt": document.excerpt,
|
||||
"id": str(document.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": document.is_encrypted,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"numchild": 1,
|
||||
@@ -467,6 +480,7 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated
|
||||
"excerpt": sibling.excerpt,
|
||||
"id": str(sibling.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": sibling.is_encrypted,
|
||||
"link_reach": sibling.link_reach,
|
||||
"link_role": sibling.link_role,
|
||||
"numchild": 0,
|
||||
@@ -487,6 +501,7 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated
|
||||
"excerpt": parent.excerpt,
|
||||
"id": str(parent.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": parent.is_encrypted,
|
||||
"link_reach": parent.link_reach,
|
||||
"link_role": parent.link_role,
|
||||
"numchild": 2,
|
||||
@@ -565,6 +580,7 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
|
||||
"excerpt": child.excerpt,
|
||||
"id": str(child.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child.is_encrypted,
|
||||
"link_reach": child.link_reach,
|
||||
"link_role": child.link_role,
|
||||
"numchild": 0,
|
||||
@@ -589,6 +605,7 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
|
||||
"excerpt": document.excerpt,
|
||||
"id": str(document.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": document.is_encrypted,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"numchild": 1,
|
||||
@@ -617,6 +634,7 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
|
||||
"excerpt": document_sibling.excerpt,
|
||||
"id": str(document_sibling.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": document_sibling.is_encrypted,
|
||||
"link_reach": document_sibling.link_reach,
|
||||
"link_role": document_sibling.link_role,
|
||||
"numchild": 0,
|
||||
@@ -639,6 +657,7 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
|
||||
"excerpt": parent.excerpt,
|
||||
"id": str(parent.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": parent.is_encrypted,
|
||||
"link_reach": parent.link_reach,
|
||||
"link_role": parent.link_role,
|
||||
"numchild": 2,
|
||||
@@ -665,6 +684,7 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
|
||||
"excerpt": parent_sibling.excerpt,
|
||||
"id": str(parent_sibling.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": parent_sibling.is_encrypted,
|
||||
"link_reach": parent_sibling.link_reach,
|
||||
"link_role": parent_sibling.link_role,
|
||||
"numchild": 0,
|
||||
@@ -687,6 +707,7 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
|
||||
"excerpt": grand_parent.excerpt,
|
||||
"id": str(grand_parent.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": grand_parent.is_encrypted,
|
||||
"link_reach": grand_parent.link_reach,
|
||||
"link_role": grand_parent.link_role,
|
||||
"numchild": 2,
|
||||
@@ -769,6 +790,7 @@ def test_api_documents_tree_list_authenticated_related_direct():
|
||||
"excerpt": child.excerpt,
|
||||
"id": str(child.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child.is_encrypted,
|
||||
"link_reach": child.link_reach,
|
||||
"link_role": child.link_role,
|
||||
"numchild": 0,
|
||||
@@ -791,6 +813,7 @@ def test_api_documents_tree_list_authenticated_related_direct():
|
||||
"excerpt": document.excerpt,
|
||||
"id": str(document.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": document.is_encrypted,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"numchild": 1,
|
||||
@@ -815,6 +838,7 @@ def test_api_documents_tree_list_authenticated_related_direct():
|
||||
"excerpt": sibling.excerpt,
|
||||
"id": str(sibling.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": sibling.is_encrypted,
|
||||
"link_reach": sibling.link_reach,
|
||||
"link_role": sibling.link_role,
|
||||
"numchild": 0,
|
||||
@@ -835,6 +859,7 @@ def test_api_documents_tree_list_authenticated_related_direct():
|
||||
"excerpt": parent.excerpt,
|
||||
"id": str(parent.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": parent.is_encrypted,
|
||||
"link_reach": parent.link_reach,
|
||||
"link_role": parent.link_role,
|
||||
"numchild": 2,
|
||||
@@ -917,6 +942,7 @@ def test_api_documents_tree_list_authenticated_related_parent():
|
||||
"excerpt": child.excerpt,
|
||||
"id": str(child.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child.is_encrypted,
|
||||
"link_reach": child.link_reach,
|
||||
"link_role": child.link_role,
|
||||
"numchild": 0,
|
||||
@@ -941,6 +967,7 @@ def test_api_documents_tree_list_authenticated_related_parent():
|
||||
"excerpt": document.excerpt,
|
||||
"id": str(document.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": document.is_encrypted,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"numchild": 1,
|
||||
@@ -969,6 +996,7 @@ def test_api_documents_tree_list_authenticated_related_parent():
|
||||
"excerpt": document_sibling.excerpt,
|
||||
"id": str(document_sibling.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": document_sibling.is_encrypted,
|
||||
"link_reach": document_sibling.link_reach,
|
||||
"link_role": document_sibling.link_role,
|
||||
"numchild": 0,
|
||||
@@ -991,6 +1019,7 @@ def test_api_documents_tree_list_authenticated_related_parent():
|
||||
"excerpt": parent.excerpt,
|
||||
"id": str(parent.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": parent.is_encrypted,
|
||||
"link_reach": parent.link_reach,
|
||||
"link_role": parent.link_role,
|
||||
"numchild": 2,
|
||||
@@ -1017,6 +1046,7 @@ def test_api_documents_tree_list_authenticated_related_parent():
|
||||
"excerpt": parent_sibling.excerpt,
|
||||
"id": str(parent_sibling.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": parent_sibling.is_encrypted,
|
||||
"link_reach": parent_sibling.link_reach,
|
||||
"link_role": parent_sibling.link_role,
|
||||
"numchild": 0,
|
||||
@@ -1039,6 +1069,7 @@ def test_api_documents_tree_list_authenticated_related_parent():
|
||||
"excerpt": grand_parent.excerpt,
|
||||
"id": str(grand_parent.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": grand_parent.is_encrypted,
|
||||
"link_reach": grand_parent.link_reach,
|
||||
"link_role": grand_parent.link_role,
|
||||
"numchild": 2,
|
||||
@@ -1129,6 +1160,7 @@ def test_api_documents_tree_list_authenticated_related_team_members(
|
||||
"excerpt": child.excerpt,
|
||||
"id": str(child.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": child.is_encrypted,
|
||||
"link_reach": child.link_reach,
|
||||
"link_role": child.link_role,
|
||||
"numchild": 0,
|
||||
@@ -1151,6 +1183,7 @@ def test_api_documents_tree_list_authenticated_related_team_members(
|
||||
"excerpt": document.excerpt,
|
||||
"id": str(document.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": document.is_encrypted,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"numchild": 1,
|
||||
@@ -1175,6 +1208,7 @@ def test_api_documents_tree_list_authenticated_related_team_members(
|
||||
"excerpt": sibling.excerpt,
|
||||
"id": str(sibling.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": sibling.is_encrypted,
|
||||
"link_reach": sibling.link_reach,
|
||||
"link_role": sibling.link_role,
|
||||
"numchild": 0,
|
||||
@@ -1195,6 +1229,7 @@ def test_api_documents_tree_list_authenticated_related_team_members(
|
||||
"excerpt": parent.excerpt,
|
||||
"id": str(parent.id),
|
||||
"is_favorite": False,
|
||||
"is_encrypted": parent.is_encrypted,
|
||||
"link_reach": parent.link_reach,
|
||||
"link_role": parent.link_role,
|
||||
"numchild": 2,
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
"""
|
||||
Tests for Templates API endpoint in impress's core app: create
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
from core.models import Template
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_templates_create_anonymous():
|
||||
"""Anonymous users should not be allowed to create templates."""
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/templates/",
|
||||
{
|
||||
"title": "my template",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert not Template.objects.exists()
|
||||
|
||||
|
||||
def test_api_templates_create_authenticated():
|
||||
"""
|
||||
Authenticated users should be able to create templates and should automatically be declared
|
||||
as the owner of the newly created template.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/templates/",
|
||||
{
|
||||
"title": "my template",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 405
|
||||
assert not Template.objects.exists()
|
||||
@@ -1,45 +0,0 @@
|
||||
"""
|
||||
Tests for Templates API endpoint in impress's core app: delete
|
||||
"""
|
||||
|
||||
import random
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_templates_delete_anonymous():
|
||||
"""Anonymous users should not be allowed to destroy a template."""
|
||||
template = factories.TemplateFactory()
|
||||
|
||||
response = APIClient().delete(
|
||||
f"/api/v1.0/templates/{template.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert models.Template.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_templates_delete_not_implemented():
|
||||
"""
|
||||
Authenticated users should not be allowed to delete a template to which they are not
|
||||
related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
is_public = random.choice([True, False])
|
||||
template = factories.TemplateFactory(is_public=is_public, users=[(user, "owner")])
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/templates/{template.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 405
|
||||
assert models.Template.objects.count() == 1
|
||||
@@ -1,237 +0,0 @@
|
||||
"""
|
||||
Tests for Templates API endpoint in impress's core app: list
|
||||
"""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_templates_list_anonymous():
|
||||
"""Anonymous users should only be able to list public templates."""
|
||||
factories.TemplateFactory.create_batch(2, is_public=False)
|
||||
public_templates = factories.TemplateFactory.create_batch(2, is_public=True)
|
||||
expected_ids = {str(template.id) for template in public_templates}
|
||||
|
||||
response = APIClient().get("/api/v1.0/templates/")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 2
|
||||
results_id = {result["id"] for result in results}
|
||||
assert expected_ids == results_id
|
||||
|
||||
|
||||
def test_api_templates_list_authenticated_direct():
|
||||
"""
|
||||
Authenticated users should be able to list templates they are a direct
|
||||
owner/administrator/member of or that are public.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
related_templates = [
|
||||
access.template
|
||||
for access in factories.UserTemplateAccessFactory.create_batch(5, user=user)
|
||||
]
|
||||
public_templates = factories.TemplateFactory.create_batch(2, is_public=True)
|
||||
factories.TemplateFactory.create_batch(2, is_public=False)
|
||||
|
||||
expected_ids = {
|
||||
str(template.id) for template in related_templates + public_templates
|
||||
}
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/templates/",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 7
|
||||
results_id = {result["id"] for result in results}
|
||||
assert expected_ids == results_id
|
||||
|
||||
|
||||
def test_api_templates_list_authenticated_via_team(mock_user_teams):
|
||||
"""
|
||||
Authenticated users should be able to list templates they are a
|
||||
owner/administrator/member of via a team or that are public.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
mock_user_teams.return_value = ["team1", "team2", "unknown"]
|
||||
|
||||
templates_team1 = [
|
||||
access.template
|
||||
for access in factories.TeamTemplateAccessFactory.create_batch(2, team="team1")
|
||||
]
|
||||
templates_team2 = [
|
||||
access.template
|
||||
for access in factories.TeamTemplateAccessFactory.create_batch(3, team="team2")
|
||||
]
|
||||
public_templates = factories.TemplateFactory.create_batch(2, is_public=True)
|
||||
factories.TemplateFactory.create_batch(2, is_public=False)
|
||||
|
||||
expected_ids = {
|
||||
str(template.id)
|
||||
for template in templates_team1 + templates_team2 + public_templates
|
||||
}
|
||||
|
||||
response = client.get("/api/v1.0/templates/")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 7
|
||||
results_id = {result["id"] for result in results}
|
||||
assert expected_ids == results_id
|
||||
|
||||
|
||||
@mock.patch.object(PageNumberPagination, "get_page_size", return_value=2)
|
||||
def test_api_templates_list_pagination(
|
||||
_mock_page_size,
|
||||
):
|
||||
"""Pagination should work as expected."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template_ids = [
|
||||
str(access.template_id)
|
||||
for access in factories.UserTemplateAccessFactory.create_batch(3, user=user)
|
||||
]
|
||||
|
||||
# Get page 1
|
||||
response = client.get(
|
||||
"/api/v1.0/templates/",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
|
||||
assert content["count"] == 3
|
||||
assert content["next"] == "http://testserver/api/v1.0/templates/?page=2"
|
||||
assert content["previous"] is None
|
||||
|
||||
assert len(content["results"]) == 2
|
||||
for item in content["results"]:
|
||||
template_ids.remove(item["id"])
|
||||
|
||||
# Get page 2
|
||||
response = client.get(
|
||||
"/api/v1.0/templates/?page=2",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
|
||||
assert content["count"] == 3
|
||||
assert content["next"] is None
|
||||
assert content["previous"] == "http://testserver/api/v1.0/templates/"
|
||||
|
||||
assert len(content["results"]) == 1
|
||||
template_ids.remove(content["results"][0]["id"])
|
||||
assert template_ids == []
|
||||
|
||||
|
||||
def test_api_templates_list_authenticated_distinct():
|
||||
"""A template with several related users should only be listed once."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
template = factories.TemplateFactory(users=[user, other_user], is_public=True)
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/templates/",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert len(content["results"]) == 1
|
||||
assert content["results"][0]["id"] == str(template.id)
|
||||
|
||||
|
||||
def test_api_templates_list_order_default():
|
||||
"""The templates list should be sorted by 'created_at' in descending order by default."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template_ids = [
|
||||
str(access.template.id)
|
||||
for access in factories.UserTemplateAccessFactory.create_batch(5, user=user)
|
||||
]
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/templates/",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
response_data = response.json()
|
||||
response_template_ids = [template["id"] for template in response_data["results"]]
|
||||
|
||||
template_ids.reverse()
|
||||
assert response_template_ids == template_ids, (
|
||||
"created_at values are not sorted from newest to oldest"
|
||||
)
|
||||
|
||||
|
||||
def test_api_templates_list_order_param():
|
||||
"""
|
||||
The templates list is sorted by 'created_at' in ascending order when setting
|
||||
the "ordering" query parameter.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
templates_ids = [
|
||||
str(access.template.id)
|
||||
for access in factories.UserTemplateAccessFactory.create_batch(5, user=user)
|
||||
]
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/templates/?ordering=created_at",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
response_data = response.json()
|
||||
|
||||
response_template_ids = [template["id"] for template in response_data["results"]]
|
||||
|
||||
assert response_template_ids == templates_ids, (
|
||||
"created_at values are not sorted from oldest to newest"
|
||||
)
|
||||
|
||||
|
||||
def test_api_template_throttling(settings):
|
||||
"""Test api template throttling."""
|
||||
current_rate = settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["template"]
|
||||
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["template"] = "2/minute"
|
||||
client = APIClient()
|
||||
for _i in range(2):
|
||||
response = client.get("/api/v1.0/templates/")
|
||||
assert response.status_code == 200
|
||||
with mock.patch("core.api.throttling.capture_message") as mock_capture_message:
|
||||
response = client.get("/api/v1.0/templates/")
|
||||
assert response.status_code == 429
|
||||
mock_capture_message.assert_called_once_with(
|
||||
"Rate limit exceeded for scope template", "warning"
|
||||
)
|
||||
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["template"] = current_rate
|
||||
@@ -1,522 +0,0 @@
|
||||
"""
|
||||
Tests for Templates API endpoint in impress's core app: retrieve
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_templates_retrieve_anonymous_public():
|
||||
"""Anonymous users should be allowed to retrieve public templates."""
|
||||
template = factories.TemplateFactory(is_public=True)
|
||||
|
||||
response = APIClient().get(f"/api/v1.0/templates/{template.id!s}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"id": str(template.id),
|
||||
"abilities": {
|
||||
"destroy": False,
|
||||
"generate_document": True,
|
||||
"accesses_manage": False,
|
||||
"partial_update": False,
|
||||
"retrieve": True,
|
||||
"update": False,
|
||||
},
|
||||
"accesses": [],
|
||||
"title": template.title,
|
||||
"is_public": True,
|
||||
"code": template.code,
|
||||
"css": template.css,
|
||||
}
|
||||
|
||||
|
||||
def test_api_templates_retrieve_anonymous_not_public():
|
||||
"""Anonymous users should not be able to retrieve a template that is not public."""
|
||||
template = factories.TemplateFactory(is_public=False)
|
||||
|
||||
response = APIClient().get(f"/api/v1.0/templates/{template.id!s}/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
def test_api_templates_retrieve_authenticated_unrelated_public():
|
||||
"""
|
||||
Authenticated users should be able to retrieve a public template to which they are
|
||||
not related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory(is_public=True)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/templates/{template.id!s}/",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"id": str(template.id),
|
||||
"abilities": {
|
||||
"destroy": False,
|
||||
"generate_document": True,
|
||||
"accesses_manage": False,
|
||||
"partial_update": False,
|
||||
"retrieve": True,
|
||||
"update": False,
|
||||
},
|
||||
"accesses": [],
|
||||
"title": template.title,
|
||||
"is_public": True,
|
||||
"code": template.code,
|
||||
"css": template.css,
|
||||
}
|
||||
|
||||
|
||||
def test_api_templates_retrieve_authenticated_unrelated_not_public():
|
||||
"""
|
||||
Authenticated users should not be allowed to retrieve a template that is not public and
|
||||
to which they are not related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory(is_public=False)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/templates/{template.id!s}/",
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
|
||||
def test_api_templates_retrieve_authenticated_related_direct():
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve a template to which they
|
||||
are directly related whatever the role.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory()
|
||||
access1 = factories.UserTemplateAccessFactory(template=template, user=user)
|
||||
access2 = factories.UserTemplateAccessFactory(template=template)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/templates/{template.id!s}/",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert sorted(content.pop("accesses"), key=lambda x: x["user"]) == sorted(
|
||||
[
|
||||
{
|
||||
"id": str(access1.id),
|
||||
"user": str(user.id),
|
||||
"team": "",
|
||||
"role": access1.role,
|
||||
"abilities": access1.get_abilities(user),
|
||||
},
|
||||
{
|
||||
"id": str(access2.id),
|
||||
"user": str(access2.user.id),
|
||||
"team": "",
|
||||
"role": access2.role,
|
||||
"abilities": access2.get_abilities(user),
|
||||
},
|
||||
],
|
||||
key=lambda x: x["user"],
|
||||
)
|
||||
assert response.json() == {
|
||||
"id": str(template.id),
|
||||
"title": template.title,
|
||||
"abilities": template.get_abilities(user),
|
||||
"is_public": template.is_public,
|
||||
"code": template.code,
|
||||
"css": template.css,
|
||||
}
|
||||
|
||||
|
||||
def test_api_templates_retrieve_authenticated_related_team_none(mock_user_teams):
|
||||
"""
|
||||
Authenticated users should not be able to retrieve a template related to teams in
|
||||
which the user is not.
|
||||
"""
|
||||
mock_user_teams.return_value = []
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory(is_public=False)
|
||||
|
||||
factories.TeamTemplateAccessFactory(
|
||||
template=template, team="readers", role="reader"
|
||||
)
|
||||
factories.TeamTemplateAccessFactory(
|
||||
template=template, team="editors", role="editor"
|
||||
)
|
||||
factories.TeamTemplateAccessFactory(
|
||||
template=template, team="administrators", role="administrator"
|
||||
)
|
||||
factories.TeamTemplateAccessFactory(template=template, team="owners", role="owner")
|
||||
factories.TeamTemplateAccessFactory(template=template)
|
||||
factories.TeamTemplateAccessFactory()
|
||||
|
||||
response = client.get(f"/api/v1.0/templates/{template.id!s}/")
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"teams",
|
||||
[
|
||||
["readers"],
|
||||
["unknown", "readers"],
|
||||
["editors"],
|
||||
["unknown", "editors"],
|
||||
],
|
||||
)
|
||||
def test_api_templates_retrieve_authenticated_related_team_readers_or_editors(
|
||||
teams, mock_user_teams
|
||||
):
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve a template to which they
|
||||
are related via a team whatever the role and see all its accesses.
|
||||
"""
|
||||
mock_user_teams.return_value = teams
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory(is_public=False)
|
||||
|
||||
access_reader = factories.TeamTemplateAccessFactory(
|
||||
template=template, team="readers", role="reader"
|
||||
)
|
||||
access_editor = factories.TeamTemplateAccessFactory(
|
||||
template=template, team="editors", role="editor"
|
||||
)
|
||||
access_administrator = factories.TeamTemplateAccessFactory(
|
||||
template=template, team="administrators", role="administrator"
|
||||
)
|
||||
access_owner = factories.TeamTemplateAccessFactory(
|
||||
template=template, team="owners", role="owner"
|
||||
)
|
||||
other_access = factories.TeamTemplateAccessFactory(template=template)
|
||||
factories.TeamTemplateAccessFactory()
|
||||
|
||||
response = client.get(f"/api/v1.0/templates/{template.id!s}/")
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
expected_abilities = {
|
||||
"destroy": False,
|
||||
"retrieve": True,
|
||||
"set_role_to": [],
|
||||
"update": False,
|
||||
"partial_update": False,
|
||||
}
|
||||
assert sorted(content.pop("accesses"), key=lambda x: x["id"]) == sorted(
|
||||
[
|
||||
{
|
||||
"id": str(access_reader.id),
|
||||
"user": None,
|
||||
"team": "readers",
|
||||
"role": access_reader.role,
|
||||
"abilities": expected_abilities,
|
||||
},
|
||||
{
|
||||
"id": str(access_editor.id),
|
||||
"user": None,
|
||||
"team": "editors",
|
||||
"role": access_editor.role,
|
||||
"abilities": expected_abilities,
|
||||
},
|
||||
{
|
||||
"id": str(access_administrator.id),
|
||||
"user": None,
|
||||
"team": "administrators",
|
||||
"role": access_administrator.role,
|
||||
"abilities": expected_abilities,
|
||||
},
|
||||
{
|
||||
"id": str(access_owner.id),
|
||||
"user": None,
|
||||
"team": "owners",
|
||||
"role": access_owner.role,
|
||||
"abilities": expected_abilities,
|
||||
},
|
||||
{
|
||||
"id": str(other_access.id),
|
||||
"user": None,
|
||||
"team": other_access.team,
|
||||
"role": other_access.role,
|
||||
"abilities": expected_abilities,
|
||||
},
|
||||
],
|
||||
key=lambda x: x["id"],
|
||||
)
|
||||
assert response.json() == {
|
||||
"id": str(template.id),
|
||||
"title": template.title,
|
||||
"abilities": template.get_abilities(user),
|
||||
"is_public": False,
|
||||
"code": template.code,
|
||||
"css": template.css,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"teams",
|
||||
[
|
||||
["administrators"],
|
||||
["members", "administrators"],
|
||||
["unknown", "administrators"],
|
||||
],
|
||||
)
|
||||
def test_api_templates_retrieve_authenticated_related_team_administrators(
|
||||
teams, mock_user_teams
|
||||
):
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve a template to which they
|
||||
are related via a team whatever the role and see all its accesses.
|
||||
"""
|
||||
mock_user_teams.return_value = teams
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory(is_public=False)
|
||||
|
||||
access_reader = factories.TeamTemplateAccessFactory(
|
||||
template=template, team="readers", role="reader"
|
||||
)
|
||||
access_editor = factories.TeamTemplateAccessFactory(
|
||||
template=template, team="editors", role="editor"
|
||||
)
|
||||
access_administrator = factories.TeamTemplateAccessFactory(
|
||||
template=template, team="administrators", role="administrator"
|
||||
)
|
||||
access_owner = factories.TeamTemplateAccessFactory(
|
||||
template=template, team="owners", role="owner"
|
||||
)
|
||||
other_access = factories.TeamTemplateAccessFactory(template=template)
|
||||
factories.TeamTemplateAccessFactory()
|
||||
|
||||
response = client.get(f"/api/v1.0/templates/{template.id!s}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert sorted(content.pop("accesses"), key=lambda x: x["id"]) == sorted(
|
||||
[
|
||||
{
|
||||
"id": str(access_reader.id),
|
||||
"user": None,
|
||||
"team": "readers",
|
||||
"role": "reader",
|
||||
"abilities": {
|
||||
"destroy": True,
|
||||
"retrieve": True,
|
||||
"set_role_to": ["administrator", "editor"],
|
||||
"update": True,
|
||||
"partial_update": True,
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": str(access_editor.id),
|
||||
"user": None,
|
||||
"team": "editors",
|
||||
"role": "editor",
|
||||
"abilities": {
|
||||
"destroy": True,
|
||||
"retrieve": True,
|
||||
"set_role_to": ["administrator", "reader"],
|
||||
"update": True,
|
||||
"partial_update": True,
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": str(access_administrator.id),
|
||||
"user": None,
|
||||
"team": "administrators",
|
||||
"role": "administrator",
|
||||
"abilities": {
|
||||
"destroy": True,
|
||||
"retrieve": True,
|
||||
"set_role_to": ["editor", "reader"],
|
||||
"update": True,
|
||||
"partial_update": True,
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": str(access_owner.id),
|
||||
"user": None,
|
||||
"team": "owners",
|
||||
"role": "owner",
|
||||
"abilities": {
|
||||
"destroy": False,
|
||||
"retrieve": True,
|
||||
"set_role_to": [],
|
||||
"update": False,
|
||||
"partial_update": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": str(other_access.id),
|
||||
"user": None,
|
||||
"team": other_access.team,
|
||||
"role": other_access.role,
|
||||
"abilities": other_access.get_abilities(user),
|
||||
},
|
||||
],
|
||||
key=lambda x: x["id"],
|
||||
)
|
||||
assert response.json() == {
|
||||
"id": str(template.id),
|
||||
"title": template.title,
|
||||
"abilities": template.get_abilities(user),
|
||||
"is_public": False,
|
||||
"code": template.code,
|
||||
"css": template.css,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"teams",
|
||||
[
|
||||
["owners"],
|
||||
["owners", "administrators"],
|
||||
["members", "administrators", "owners"],
|
||||
["unknown", "owners"],
|
||||
],
|
||||
)
|
||||
def test_api_templates_retrieve_authenticated_related_team_owners(
|
||||
teams, mock_user_teams
|
||||
):
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve a template to which they
|
||||
are related via a team whatever the role and see all its accesses.
|
||||
"""
|
||||
mock_user_teams.return_value = teams
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory(is_public=False)
|
||||
|
||||
access_reader = factories.TeamTemplateAccessFactory(
|
||||
template=template, team="readers", role="reader"
|
||||
)
|
||||
access_editor = factories.TeamTemplateAccessFactory(
|
||||
template=template, team="editors", role="editor"
|
||||
)
|
||||
access_administrator = factories.TeamTemplateAccessFactory(
|
||||
template=template, team="administrators", role="administrator"
|
||||
)
|
||||
access_owner = factories.TeamTemplateAccessFactory(
|
||||
template=template, team="owners", role="owner"
|
||||
)
|
||||
other_access = factories.TeamTemplateAccessFactory(template=template)
|
||||
factories.TeamTemplateAccessFactory()
|
||||
|
||||
response = client.get(f"/api/v1.0/templates/{template.id!s}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert sorted(content.pop("accesses"), key=lambda x: x["id"]) == sorted(
|
||||
[
|
||||
{
|
||||
"id": str(access_reader.id),
|
||||
"user": None,
|
||||
"team": "readers",
|
||||
"role": "reader",
|
||||
"abilities": {
|
||||
"destroy": True,
|
||||
"retrieve": True,
|
||||
"set_role_to": ["owner", "administrator", "editor"],
|
||||
"update": True,
|
||||
"partial_update": True,
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": str(access_editor.id),
|
||||
"user": None,
|
||||
"team": "editors",
|
||||
"role": "editor",
|
||||
"abilities": {
|
||||
"destroy": True,
|
||||
"retrieve": True,
|
||||
"set_role_to": ["owner", "administrator", "reader"],
|
||||
"update": True,
|
||||
"partial_update": True,
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": str(access_administrator.id),
|
||||
"user": None,
|
||||
"team": "administrators",
|
||||
"role": "administrator",
|
||||
"abilities": {
|
||||
"destroy": True,
|
||||
"retrieve": True,
|
||||
"set_role_to": ["owner", "editor", "reader"],
|
||||
"update": True,
|
||||
"partial_update": True,
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": str(access_owner.id),
|
||||
"user": None,
|
||||
"team": "owners",
|
||||
"role": "owner",
|
||||
"abilities": {
|
||||
# editable only if there is another owner role than the user's team...
|
||||
"destroy": other_access.role == "owner",
|
||||
"retrieve": True,
|
||||
"set_role_to": ["administrator", "editor", "reader"]
|
||||
if other_access.role == "owner"
|
||||
else [],
|
||||
"update": other_access.role == "owner",
|
||||
"partial_update": other_access.role == "owner",
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": str(other_access.id),
|
||||
"user": None,
|
||||
"team": other_access.team,
|
||||
"role": other_access.role,
|
||||
"abilities": other_access.get_abilities(user),
|
||||
},
|
||||
],
|
||||
key=lambda x: x["id"],
|
||||
)
|
||||
assert response.json() == {
|
||||
"id": str(template.id),
|
||||
"title": template.title,
|
||||
"abilities": template.get_abilities(user),
|
||||
"is_public": False,
|
||||
"code": template.code,
|
||||
"css": template.css,
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
"""
|
||||
Tests for Templates API endpoint in impress's core app: update
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
from core.api import serializers
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_templates_update_anonymous():
|
||||
"""Anonymous users should not be allowed to update a template."""
|
||||
template = factories.TemplateFactory()
|
||||
|
||||
new_template_values = serializers.TemplateSerializer(
|
||||
instance=factories.TemplateFactory()
|
||||
).data
|
||||
response = APIClient().put(
|
||||
f"/api/v1.0/templates/{template.id!s}/",
|
||||
new_template_values,
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_api_templates_update_not_implemented():
|
||||
"""
|
||||
Authenticated users should not be allowed to update a template.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory(users=[(user, "owner")])
|
||||
|
||||
new_template_values = serializers.TemplateSerializer(
|
||||
instance=factories.TemplateFactory()
|
||||
).data
|
||||
|
||||
response = client.put(
|
||||
f"/api/v1.0/templates/{template.id!s}/", new_template_values, format="json"
|
||||
)
|
||||
|
||||
assert response.status_code == 405
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/templates/{template.id!s}/", new_template_values, format="json"
|
||||
)
|
||||
|
||||
assert response.status_code == 405
|
||||
@@ -46,11 +46,14 @@ def test_api_config(is_authenticated):
|
||||
"AI_FEATURE_ENABLED": False,
|
||||
"COLLABORATION_WS_URL": "http://testcollab/",
|
||||
"COLLABORATION_WS_NOT_CONNECTED_READY_ONLY": True,
|
||||
"CONVERSION_FILE_EXTENSIONS_ALLOWED": [".docx", ".md"],
|
||||
"CONVERSION_FILE_MAX_SIZE": 20971520,
|
||||
"CRISP_WEBSITE_ID": "123",
|
||||
"ENVIRONMENT": "test",
|
||||
"FRONTEND_CSS_URL": "http://testcss/",
|
||||
"FRONTEND_HOMEPAGE_FEATURE_ENABLED": True,
|
||||
"FRONTEND_JS_URL": "http://testjs/",
|
||||
"FRONTEND_SILENT_LOGIN_ENABLED": False,
|
||||
"FRONTEND_THEME": "test-theme",
|
||||
"LANGUAGES": [
|
||||
["en-us", "English"],
|
||||
|
||||
@@ -311,7 +311,7 @@ def test_api_users_list_query_short_queries():
|
||||
"""
|
||||
Queries shorter than 5 characters should return an empty result set.
|
||||
"""
|
||||
user = factories.UserFactory(email="paul@example.com")
|
||||
user = factories.UserFactory(email="paul@example.com", full_name="Paul")
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
|
||||
@@ -1024,6 +1024,39 @@ def test_models_documents__email_invitation__success():
|
||||
assert f"docs/{document.id}/" in email_content
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"email_url_app",
|
||||
[
|
||||
"https://test-example.com", # Test with EMAIL_URL_APP set
|
||||
None, # Test fallback to Site domain
|
||||
],
|
||||
)
|
||||
def test_models_documents__email_invitation__url_app_param(email_url_app):
|
||||
"""
|
||||
Test that email invitation uses EMAIL_URL_APP when set, or falls back to Site domain.
|
||||
"""
|
||||
with override_settings(EMAIL_URL_APP=email_url_app):
|
||||
document = factories.DocumentFactory()
|
||||
|
||||
sender = factories.UserFactory(
|
||||
full_name="Test Sender", email="sender@example.com"
|
||||
)
|
||||
document.send_invitation_email(
|
||||
"guest@example.com", models.RoleChoices.EDITOR, sender, "en"
|
||||
)
|
||||
|
||||
# pylint: disable-next=no-member
|
||||
email = mail.outbox[0]
|
||||
email_content = " ".join(email.body.split())
|
||||
|
||||
# Determine expected domain
|
||||
if email_url_app:
|
||||
assert f"https://test-example.com/docs/{document.id}/" in email_content
|
||||
else:
|
||||
# Default Site domain is example.com
|
||||
assert f"example.com/docs/{document.id}/" in email_content
|
||||
|
||||
|
||||
def test_models_documents__email_invitation__success_empty_title():
|
||||
"""
|
||||
The email invitation is sent successfully.
|
||||
@@ -1393,7 +1426,7 @@ def test_models_documents_restore_complex(django_assert_num_queries):
|
||||
assert child2.ancestors_deleted_at == document.deleted_at
|
||||
|
||||
# Restore the item
|
||||
with django_assert_num_queries(13):
|
||||
with django_assert_num_queries(14):
|
||||
document.restore()
|
||||
document.refresh_from_db()
|
||||
child1.refresh_from_db()
|
||||
|
||||
@@ -1,419 +0,0 @@
|
||||
"""
|
||||
Unit tests for the TemplateAccess model
|
||||
"""
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
import pytest
|
||||
|
||||
from core import factories
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_models_template_accesses_str():
|
||||
"""
|
||||
The str representation should include user email, template title and role.
|
||||
"""
|
||||
user = factories.UserFactory(email="david.bowman@example.com")
|
||||
access = factories.UserTemplateAccessFactory(
|
||||
role="reader",
|
||||
user=user,
|
||||
template__title="admins",
|
||||
)
|
||||
assert str(access) == "david.bowman@example.com is reader in template admins"
|
||||
|
||||
|
||||
def test_models_template_accesses_unique_user():
|
||||
"""Template accesses should be unique for a given couple of user and template."""
|
||||
access = factories.UserTemplateAccessFactory()
|
||||
|
||||
with pytest.raises(
|
||||
ValidationError,
|
||||
match="This user is already in this template.",
|
||||
):
|
||||
factories.UserTemplateAccessFactory(user=access.user, template=access.template)
|
||||
|
||||
|
||||
def test_models_template_accesses_several_empty_teams():
|
||||
"""A template can have several template accesses with an empty team."""
|
||||
access = factories.UserTemplateAccessFactory()
|
||||
factories.UserTemplateAccessFactory(template=access.template)
|
||||
|
||||
|
||||
def test_models_template_accesses_unique_team():
|
||||
"""Template accesses should be unique for a given couple of team and template."""
|
||||
access = factories.TeamTemplateAccessFactory()
|
||||
|
||||
with pytest.raises(
|
||||
ValidationError,
|
||||
match="This team is already in this template.",
|
||||
):
|
||||
factories.TeamTemplateAccessFactory(team=access.team, template=access.template)
|
||||
|
||||
|
||||
def test_models_template_accesses_several_null_users():
|
||||
"""A template can have several template accesses with a null user."""
|
||||
access = factories.TeamTemplateAccessFactory()
|
||||
factories.TeamTemplateAccessFactory(template=access.template)
|
||||
|
||||
|
||||
def test_models_template_accesses_user_and_team_set():
|
||||
"""User and team can't both be set on a template access."""
|
||||
with pytest.raises(
|
||||
ValidationError,
|
||||
match="Either user or team must be set, not both.",
|
||||
):
|
||||
factories.UserTemplateAccessFactory(team="my-team")
|
||||
|
||||
|
||||
def test_models_template_accesses_user_and_team_empty():
|
||||
"""User and team can't both be empty on a template access."""
|
||||
with pytest.raises(
|
||||
ValidationError,
|
||||
match="Either user or team must be set, not both.",
|
||||
):
|
||||
factories.UserTemplateAccessFactory(user=None)
|
||||
|
||||
|
||||
# get_abilities
|
||||
|
||||
|
||||
def test_models_template_access_get_abilities_anonymous():
|
||||
"""Check abilities returned for an anonymous user."""
|
||||
access = factories.UserTemplateAccessFactory()
|
||||
abilities = access.get_abilities(AnonymousUser())
|
||||
assert abilities == {
|
||||
"destroy": False,
|
||||
"retrieve": False,
|
||||
"update": False,
|
||||
"partial_update": False,
|
||||
"set_role_to": [],
|
||||
}
|
||||
|
||||
|
||||
def test_models_template_access_get_abilities_authenticated():
|
||||
"""Check abilities returned for an authenticated user."""
|
||||
access = factories.UserTemplateAccessFactory()
|
||||
user = factories.UserFactory()
|
||||
abilities = access.get_abilities(user)
|
||||
assert abilities == {
|
||||
"destroy": False,
|
||||
"retrieve": False,
|
||||
"update": False,
|
||||
"partial_update": False,
|
||||
"set_role_to": [],
|
||||
}
|
||||
|
||||
|
||||
# - for owner
|
||||
|
||||
|
||||
def test_models_template_access_get_abilities_for_owner_of_self_allowed():
|
||||
"""
|
||||
Check abilities of self access for the owner of a template when
|
||||
there is more than one owner left.
|
||||
"""
|
||||
access = factories.UserTemplateAccessFactory(role="owner")
|
||||
factories.UserTemplateAccessFactory(template=access.template, role="owner")
|
||||
abilities = access.get_abilities(access.user)
|
||||
assert abilities == {
|
||||
"destroy": True,
|
||||
"retrieve": True,
|
||||
"update": True,
|
||||
"partial_update": True,
|
||||
"set_role_to": ["administrator", "editor", "reader"],
|
||||
}
|
||||
|
||||
|
||||
def test_models_template_access_get_abilities_for_owner_of_self_last():
|
||||
"""
|
||||
Check abilities of self access for the owner of a template when there is only one owner left.
|
||||
"""
|
||||
access = factories.UserTemplateAccessFactory(role="owner")
|
||||
abilities = access.get_abilities(access.user)
|
||||
assert abilities == {
|
||||
"destroy": False,
|
||||
"retrieve": True,
|
||||
"update": False,
|
||||
"partial_update": False,
|
||||
"set_role_to": [],
|
||||
}
|
||||
|
||||
|
||||
def test_models_template_access_get_abilities_for_owner_of_owner():
|
||||
"""Check abilities of owner access for the owner of a template."""
|
||||
access = factories.UserTemplateAccessFactory(role="owner")
|
||||
factories.UserTemplateAccessFactory(template=access.template) # another one
|
||||
user = factories.UserTemplateAccessFactory(
|
||||
template=access.template, role="owner"
|
||||
).user
|
||||
abilities = access.get_abilities(user)
|
||||
assert abilities == {
|
||||
"destroy": True,
|
||||
"retrieve": True,
|
||||
"update": True,
|
||||
"partial_update": True,
|
||||
"set_role_to": ["administrator", "editor", "reader"],
|
||||
}
|
||||
|
||||
|
||||
def test_models_template_access_get_abilities_for_owner_of_administrator():
|
||||
"""Check abilities of administrator access for the owner of a template."""
|
||||
access = factories.UserTemplateAccessFactory(role="administrator")
|
||||
factories.UserTemplateAccessFactory(template=access.template) # another one
|
||||
user = factories.UserTemplateAccessFactory(
|
||||
template=access.template, role="owner"
|
||||
).user
|
||||
abilities = access.get_abilities(user)
|
||||
assert abilities == {
|
||||
"destroy": True,
|
||||
"retrieve": True,
|
||||
"update": True,
|
||||
"partial_update": True,
|
||||
"set_role_to": ["owner", "editor", "reader"],
|
||||
}
|
||||
|
||||
|
||||
def test_models_template_access_get_abilities_for_owner_of_editor():
|
||||
"""Check abilities of editor access for the owner of a template."""
|
||||
access = factories.UserTemplateAccessFactory(role="editor")
|
||||
factories.UserTemplateAccessFactory(template=access.template) # another one
|
||||
user = factories.UserTemplateAccessFactory(
|
||||
template=access.template, role="owner"
|
||||
).user
|
||||
abilities = access.get_abilities(user)
|
||||
assert abilities == {
|
||||
"destroy": True,
|
||||
"retrieve": True,
|
||||
"update": True,
|
||||
"partial_update": True,
|
||||
"set_role_to": ["owner", "administrator", "reader"],
|
||||
}
|
||||
|
||||
|
||||
def test_models_template_access_get_abilities_for_owner_of_reader():
|
||||
"""Check abilities of reader access for the owner of a template."""
|
||||
access = factories.UserTemplateAccessFactory(role="reader")
|
||||
factories.UserTemplateAccessFactory(template=access.template) # another one
|
||||
user = factories.UserTemplateAccessFactory(
|
||||
template=access.template, role="owner"
|
||||
).user
|
||||
abilities = access.get_abilities(user)
|
||||
assert abilities == {
|
||||
"destroy": True,
|
||||
"retrieve": True,
|
||||
"update": True,
|
||||
"partial_update": True,
|
||||
"set_role_to": ["owner", "administrator", "editor"],
|
||||
}
|
||||
|
||||
|
||||
# - for administrator
|
||||
|
||||
|
||||
def test_models_template_access_get_abilities_for_administrator_of_owner():
|
||||
"""Check abilities of owner access for the administrator of a template."""
|
||||
access = factories.UserTemplateAccessFactory(role="owner")
|
||||
factories.UserTemplateAccessFactory(template=access.template) # another one
|
||||
user = factories.UserTemplateAccessFactory(
|
||||
template=access.template, role="administrator"
|
||||
).user
|
||||
abilities = access.get_abilities(user)
|
||||
assert abilities == {
|
||||
"destroy": False,
|
||||
"retrieve": True,
|
||||
"update": False,
|
||||
"partial_update": False,
|
||||
"set_role_to": [],
|
||||
}
|
||||
|
||||
|
||||
def test_models_template_access_get_abilities_for_administrator_of_administrator():
|
||||
"""Check abilities of administrator access for the administrator of a template."""
|
||||
access = factories.UserTemplateAccessFactory(role="administrator")
|
||||
factories.UserTemplateAccessFactory(template=access.template) # another one
|
||||
user = factories.UserTemplateAccessFactory(
|
||||
template=access.template, role="administrator"
|
||||
).user
|
||||
abilities = access.get_abilities(user)
|
||||
assert abilities == {
|
||||
"destroy": True,
|
||||
"retrieve": True,
|
||||
"update": True,
|
||||
"partial_update": True,
|
||||
"set_role_to": ["editor", "reader"],
|
||||
}
|
||||
|
||||
|
||||
def test_models_template_access_get_abilities_for_administrator_of_editor():
|
||||
"""Check abilities of editor access for the administrator of a template."""
|
||||
access = factories.UserTemplateAccessFactory(role="editor")
|
||||
factories.UserTemplateAccessFactory(template=access.template) # another one
|
||||
user = factories.UserTemplateAccessFactory(
|
||||
template=access.template, role="administrator"
|
||||
).user
|
||||
abilities = access.get_abilities(user)
|
||||
assert abilities == {
|
||||
"destroy": True,
|
||||
"retrieve": True,
|
||||
"update": True,
|
||||
"partial_update": True,
|
||||
"set_role_to": ["administrator", "reader"],
|
||||
}
|
||||
|
||||
|
||||
def test_models_template_access_get_abilities_for_administrator_of_reader():
|
||||
"""Check abilities of reader access for the administrator of a template."""
|
||||
access = factories.UserTemplateAccessFactory(role="reader")
|
||||
factories.UserTemplateAccessFactory(template=access.template) # another one
|
||||
user = factories.UserTemplateAccessFactory(
|
||||
template=access.template, role="administrator"
|
||||
).user
|
||||
abilities = access.get_abilities(user)
|
||||
assert abilities == {
|
||||
"destroy": True,
|
||||
"retrieve": True,
|
||||
"update": True,
|
||||
"partial_update": True,
|
||||
"set_role_to": ["administrator", "editor"],
|
||||
}
|
||||
|
||||
|
||||
# - For editor
|
||||
|
||||
|
||||
def test_models_template_access_get_abilities_for_editor_of_owner():
|
||||
"""Check abilities of owner access for the editor of a template."""
|
||||
access = factories.UserTemplateAccessFactory(role="owner")
|
||||
factories.UserTemplateAccessFactory(template=access.template) # another one
|
||||
user = factories.UserTemplateAccessFactory(
|
||||
template=access.template, role="editor"
|
||||
).user
|
||||
abilities = access.get_abilities(user)
|
||||
assert abilities == {
|
||||
"destroy": False,
|
||||
"retrieve": True,
|
||||
"update": False,
|
||||
"partial_update": False,
|
||||
"set_role_to": [],
|
||||
}
|
||||
|
||||
|
||||
def test_models_template_access_get_abilities_for_editor_of_administrator():
|
||||
"""Check abilities of administrator access for the editor of a template."""
|
||||
access = factories.UserTemplateAccessFactory(role="administrator")
|
||||
factories.UserTemplateAccessFactory(template=access.template) # another one
|
||||
user = factories.UserTemplateAccessFactory(
|
||||
template=access.template, role="editor"
|
||||
).user
|
||||
abilities = access.get_abilities(user)
|
||||
assert abilities == {
|
||||
"destroy": False,
|
||||
"retrieve": True,
|
||||
"update": False,
|
||||
"partial_update": False,
|
||||
"set_role_to": [],
|
||||
}
|
||||
|
||||
|
||||
def test_models_template_access_get_abilities_for_editor_of_editor_user(
|
||||
django_assert_num_queries,
|
||||
):
|
||||
"""Check abilities of editor access for the editor of a template."""
|
||||
access = factories.UserTemplateAccessFactory(role="editor")
|
||||
factories.UserTemplateAccessFactory(template=access.template) # another one
|
||||
user = factories.UserTemplateAccessFactory(
|
||||
template=access.template, role="editor"
|
||||
).user
|
||||
|
||||
with django_assert_num_queries(1):
|
||||
abilities = access.get_abilities(user)
|
||||
|
||||
assert abilities == {
|
||||
"destroy": False,
|
||||
"retrieve": True,
|
||||
"update": False,
|
||||
"partial_update": False,
|
||||
"set_role_to": [],
|
||||
}
|
||||
|
||||
|
||||
# - For reader
|
||||
|
||||
|
||||
def test_models_template_access_get_abilities_for_reader_of_owner():
|
||||
"""Check abilities of owner access for the reader of a template."""
|
||||
access = factories.UserTemplateAccessFactory(role="owner")
|
||||
factories.UserTemplateAccessFactory(template=access.template) # another one
|
||||
user = factories.UserTemplateAccessFactory(
|
||||
template=access.template, role="reader"
|
||||
).user
|
||||
abilities = access.get_abilities(user)
|
||||
assert abilities == {
|
||||
"destroy": False,
|
||||
"retrieve": True,
|
||||
"update": False,
|
||||
"partial_update": False,
|
||||
"set_role_to": [],
|
||||
}
|
||||
|
||||
|
||||
def test_models_template_access_get_abilities_for_reader_of_administrator():
|
||||
"""Check abilities of administrator access for the reader of a template."""
|
||||
access = factories.UserTemplateAccessFactory(role="administrator")
|
||||
factories.UserTemplateAccessFactory(template=access.template) # another one
|
||||
user = factories.UserTemplateAccessFactory(
|
||||
template=access.template, role="reader"
|
||||
).user
|
||||
abilities = access.get_abilities(user)
|
||||
assert abilities == {
|
||||
"destroy": False,
|
||||
"retrieve": True,
|
||||
"update": False,
|
||||
"partial_update": False,
|
||||
"set_role_to": [],
|
||||
}
|
||||
|
||||
|
||||
def test_models_template_access_get_abilities_for_reader_of_reader_user(
|
||||
django_assert_num_queries,
|
||||
):
|
||||
"""Check abilities of reader access for the reader of a template."""
|
||||
access = factories.UserTemplateAccessFactory(role="reader")
|
||||
factories.UserTemplateAccessFactory(template=access.template) # another one
|
||||
user = factories.UserTemplateAccessFactory(
|
||||
template=access.template, role="reader"
|
||||
).user
|
||||
|
||||
with django_assert_num_queries(1):
|
||||
abilities = access.get_abilities(user)
|
||||
|
||||
assert abilities == {
|
||||
"destroy": False,
|
||||
"retrieve": True,
|
||||
"update": False,
|
||||
"partial_update": False,
|
||||
"set_role_to": [],
|
||||
}
|
||||
|
||||
|
||||
def test_models_template_access_get_abilities_preset_role(django_assert_num_queries):
|
||||
"""No query is done if the role is preset, e.g., with a query annotation."""
|
||||
access = factories.UserTemplateAccessFactory(role="reader")
|
||||
user = factories.UserTemplateAccessFactory(
|
||||
template=access.template, role="reader"
|
||||
).user
|
||||
access.user_roles = ["reader"]
|
||||
|
||||
with django_assert_num_queries(0):
|
||||
abilities = access.get_abilities(user)
|
||||
|
||||
assert abilities == {
|
||||
"destroy": False,
|
||||
"retrieve": True,
|
||||
"update": False,
|
||||
"partial_update": False,
|
||||
"set_role_to": [],
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
"""
|
||||
Unit tests for the Template model
|
||||
"""
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
import pytest
|
||||
|
||||
from core import factories, models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_models_templates_str():
|
||||
"""The str representation should be the title of the template."""
|
||||
template = factories.TemplateFactory(title="admins")
|
||||
assert str(template) == "admins"
|
||||
|
||||
|
||||
def test_models_templates_id_unique():
|
||||
"""The "id" field should be unique."""
|
||||
template = factories.TemplateFactory()
|
||||
with pytest.raises(ValidationError, match="Template with this Id already exists."):
|
||||
factories.TemplateFactory(id=template.id)
|
||||
|
||||
|
||||
def test_models_templates_title_null():
|
||||
"""The "title" field should not be null."""
|
||||
with pytest.raises(ValidationError, match="This field cannot be null."):
|
||||
models.Template.objects.create(title=None)
|
||||
|
||||
|
||||
def test_models_templates_title_empty():
|
||||
"""The "title" field should not be empty."""
|
||||
with pytest.raises(ValidationError, match="This field cannot be blank."):
|
||||
models.Template.objects.create(title="")
|
||||
|
||||
|
||||
def test_models_templates_title_max_length():
|
||||
"""The "title" field should be 100 characters maximum."""
|
||||
factories.TemplateFactory(title="a" * 255)
|
||||
with pytest.raises(
|
||||
ValidationError,
|
||||
match=r"Ensure this value has at most 255 characters \(it has 256\)\.",
|
||||
):
|
||||
factories.TemplateFactory(title="a" * 256)
|
||||
|
||||
|
||||
# get_abilities
|
||||
|
||||
|
||||
def test_models_templates_get_abilities_anonymous_public():
|
||||
"""Check abilities returned for an anonymous user if the template is public."""
|
||||
template = factories.TemplateFactory(is_public=True)
|
||||
abilities = template.get_abilities(AnonymousUser())
|
||||
assert abilities == {
|
||||
"destroy": False,
|
||||
"retrieve": True,
|
||||
"update": False,
|
||||
"accesses_manage": False,
|
||||
"partial_update": False,
|
||||
"generate_document": True,
|
||||
}
|
||||
|
||||
|
||||
def test_models_templates_get_abilities_anonymous_not_public():
|
||||
"""Check abilities returned for an anonymous user if the template is private."""
|
||||
template = factories.TemplateFactory(is_public=False)
|
||||
abilities = template.get_abilities(AnonymousUser())
|
||||
assert abilities == {
|
||||
"destroy": False,
|
||||
"retrieve": False,
|
||||
"update": False,
|
||||
"accesses_manage": False,
|
||||
"partial_update": False,
|
||||
"generate_document": False,
|
||||
}
|
||||
|
||||
|
||||
def test_models_templates_get_abilities_authenticated_public():
|
||||
"""Check abilities returned for an authenticated user if the user is public."""
|
||||
template = factories.TemplateFactory(is_public=True)
|
||||
abilities = template.get_abilities(factories.UserFactory())
|
||||
assert abilities == {
|
||||
"destroy": False,
|
||||
"retrieve": True,
|
||||
"update": False,
|
||||
"accesses_manage": False,
|
||||
"partial_update": False,
|
||||
"generate_document": True,
|
||||
}
|
||||
|
||||
|
||||
def test_models_templates_get_abilities_authenticated_not_public():
|
||||
"""Check abilities returned for an authenticated user if the template is private."""
|
||||
template = factories.TemplateFactory(is_public=False)
|
||||
abilities = template.get_abilities(factories.UserFactory())
|
||||
assert abilities == {
|
||||
"destroy": False,
|
||||
"retrieve": False,
|
||||
"update": False,
|
||||
"accesses_manage": False,
|
||||
"partial_update": False,
|
||||
"generate_document": False,
|
||||
}
|
||||
|
||||
|
||||
def test_models_templates_get_abilities_owner():
|
||||
"""Check abilities returned for the owner of a template."""
|
||||
user = factories.UserFactory()
|
||||
access = factories.UserTemplateAccessFactory(role="owner", user=user)
|
||||
abilities = access.template.get_abilities(access.user)
|
||||
assert abilities == {
|
||||
"destroy": True,
|
||||
"retrieve": True,
|
||||
"update": True,
|
||||
"accesses_manage": True,
|
||||
"partial_update": True,
|
||||
"generate_document": True,
|
||||
}
|
||||
|
||||
|
||||
def test_models_templates_get_abilities_administrator():
|
||||
"""Check abilities returned for the administrator of a template."""
|
||||
access = factories.UserTemplateAccessFactory(role="administrator")
|
||||
abilities = access.template.get_abilities(access.user)
|
||||
assert abilities == {
|
||||
"destroy": False,
|
||||
"retrieve": True,
|
||||
"update": True,
|
||||
"accesses_manage": True,
|
||||
"partial_update": True,
|
||||
"generate_document": True,
|
||||
}
|
||||
|
||||
|
||||
def test_models_templates_get_abilities_editor_user(django_assert_num_queries):
|
||||
"""Check abilities returned for the editor of a template."""
|
||||
access = factories.UserTemplateAccessFactory(role="editor")
|
||||
|
||||
with django_assert_num_queries(1):
|
||||
abilities = access.template.get_abilities(access.user)
|
||||
|
||||
assert abilities == {
|
||||
"destroy": False,
|
||||
"retrieve": True,
|
||||
"update": True,
|
||||
"accesses_manage": False,
|
||||
"partial_update": True,
|
||||
"generate_document": True,
|
||||
}
|
||||
|
||||
|
||||
def test_models_templates_get_abilities_reader_user(django_assert_num_queries):
|
||||
"""Check abilities returned for the reader of a template."""
|
||||
access = factories.UserTemplateAccessFactory(role="reader")
|
||||
|
||||
with django_assert_num_queries(1):
|
||||
abilities = access.template.get_abilities(access.user)
|
||||
|
||||
assert abilities == {
|
||||
"destroy": False,
|
||||
"retrieve": True,
|
||||
"update": False,
|
||||
"accesses_manage": False,
|
||||
"partial_update": False,
|
||||
"generate_document": True,
|
||||
}
|
||||
|
||||
|
||||
def test_models_templates_get_abilities_preset_role(django_assert_num_queries):
|
||||
"""No query is done if the role is preset e.g. with query annotation."""
|
||||
access = factories.UserTemplateAccessFactory(role="reader")
|
||||
access.template.user_roles = ["reader"]
|
||||
|
||||
with django_assert_num_queries(0):
|
||||
abilities = access.template.get_abilities(access.user)
|
||||
|
||||
assert abilities == {
|
||||
"destroy": False,
|
||||
"retrieve": True,
|
||||
"update": False,
|
||||
"accesses_manage": False,
|
||||
"partial_update": False,
|
||||
"generate_document": True,
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Test Converter orchestration services."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from core.services import mime_types
|
||||
from core.services.converter_services import Converter
|
||||
|
||||
|
||||
@patch("core.services.converter_services.DocSpecConverter")
|
||||
@patch("core.services.converter_services.YdocConverter")
|
||||
def test_converter_docx_to_yjs_orchestration(mock_ydoc_class, mock_docspec_class):
|
||||
"""Test that DOCX to YJS conversion uses both DocSpec and Ydoc converters."""
|
||||
# Setup mocks
|
||||
mock_docspec = MagicMock()
|
||||
mock_ydoc = MagicMock()
|
||||
mock_docspec_class.return_value = mock_docspec
|
||||
mock_ydoc_class.return_value = mock_ydoc
|
||||
|
||||
# Mock the conversion chain: DOCX -> BlockNote -> YJS
|
||||
blocknote_data = b'[{"type": "paragraph", "content": "test"}]'
|
||||
yjs_data = "base64encodedyjs"
|
||||
|
||||
mock_docspec.convert.return_value = blocknote_data
|
||||
mock_ydoc.convert.return_value = yjs_data
|
||||
|
||||
# Execute conversion
|
||||
converter = Converter()
|
||||
docx_data = b"fake docx data"
|
||||
result = converter.convert(docx_data, mime_types.DOCX, mime_types.YJS)
|
||||
|
||||
# Verify the orchestration
|
||||
mock_docspec.convert.assert_called_once_with(
|
||||
docx_data, mime_types.DOCX, mime_types.BLOCKNOTE
|
||||
)
|
||||
mock_ydoc.convert.assert_called_once_with(
|
||||
blocknote_data, mime_types.BLOCKNOTE, mime_types.YJS
|
||||
)
|
||||
assert result == yjs_data
|
||||
|
||||
|
||||
@patch("core.services.converter_services.YdocConverter")
|
||||
def test_converter_markdown_to_yjs_delegation(mock_ydoc_class):
|
||||
"""Test that Markdown to YJS conversion is delegated to YdocConverter."""
|
||||
mock_ydoc = MagicMock()
|
||||
mock_ydoc_class.return_value = mock_ydoc
|
||||
|
||||
yjs_data = "base64encodedyjs"
|
||||
mock_ydoc.convert.return_value = yjs_data
|
||||
|
||||
converter = Converter()
|
||||
markdown_data = "# Test Document"
|
||||
result = converter.convert(markdown_data, mime_types.MARKDOWN, mime_types.YJS)
|
||||
|
||||
mock_ydoc.convert.assert_called_once_with(
|
||||
markdown_data, mime_types.MARKDOWN, mime_types.YJS
|
||||
)
|
||||
assert result == yjs_data
|
||||
|
||||
|
||||
@patch("core.services.converter_services.YdocConverter")
|
||||
def test_converter_yjs_to_html_delegation(mock_ydoc_class):
|
||||
"""Test that YJS to HTML conversion is delegated to YdocConverter."""
|
||||
mock_ydoc = MagicMock()
|
||||
mock_ydoc_class.return_value = mock_ydoc
|
||||
|
||||
html_data = "<p>Test Document</p>"
|
||||
mock_ydoc.convert.return_value = html_data
|
||||
|
||||
converter = Converter()
|
||||
yjs_data = b"yjs binary data"
|
||||
result = converter.convert(yjs_data, mime_types.YJS, mime_types.HTML)
|
||||
|
||||
mock_ydoc.convert.assert_called_once_with(yjs_data, mime_types.YJS, mime_types.HTML)
|
||||
assert result == html_data
|
||||
|
||||
|
||||
@patch("core.services.converter_services.YdocConverter")
|
||||
def test_converter_blocknote_to_yjs_delegation(mock_ydoc_class):
|
||||
"""Test that BlockNote to YJS conversion is delegated to YdocConverter."""
|
||||
mock_ydoc = MagicMock()
|
||||
mock_ydoc_class.return_value = mock_ydoc
|
||||
|
||||
yjs_data = "base64encodedyjs"
|
||||
mock_ydoc.convert.return_value = yjs_data
|
||||
|
||||
converter = Converter()
|
||||
blocknote_data = b'[{"type": "paragraph"}]'
|
||||
result = converter.convert(blocknote_data, mime_types.BLOCKNOTE, mime_types.YJS)
|
||||
|
||||
mock_ydoc.convert.assert_called_once_with(
|
||||
blocknote_data, mime_types.BLOCKNOTE, mime_types.YJS
|
||||
)
|
||||
assert result == yjs_data
|
||||
@@ -6,6 +6,7 @@ from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from core.services import mime_types
|
||||
from core.services.converter_services import (
|
||||
ServiceUnavailableError,
|
||||
ValidationError,
|
||||
@@ -21,9 +22,9 @@ def test_auth_header(settings):
|
||||
|
||||
|
||||
def test_convert_empty_text():
|
||||
"""Should raise ValidationError when text is empty."""
|
||||
"""Should raise ValidationError when data is empty."""
|
||||
converter = YdocConverter()
|
||||
with pytest.raises(ValidationError, match="Input text cannot be empty"):
|
||||
with pytest.raises(ValidationError, match="Input data cannot be empty"):
|
||||
converter.convert("")
|
||||
|
||||
|
||||
@@ -36,7 +37,7 @@ def test_convert_service_unavailable(mock_post):
|
||||
|
||||
with pytest.raises(
|
||||
ServiceUnavailableError,
|
||||
match="Failed to connect to conversion service",
|
||||
match="Failed to connect to YDoc conversion service",
|
||||
):
|
||||
converter.convert("test text")
|
||||
|
||||
@@ -52,7 +53,7 @@ def test_convert_http_error(mock_post):
|
||||
|
||||
with pytest.raises(
|
||||
ServiceUnavailableError,
|
||||
match="Failed to connect to conversion service",
|
||||
match="Failed to connect to YDoc conversion service",
|
||||
):
|
||||
converter.convert("test text")
|
||||
|
||||
@@ -83,8 +84,8 @@ def test_convert_full_integration(mock_post, settings):
|
||||
data="test markdown",
|
||||
headers={
|
||||
"Authorization": "Bearer test-key",
|
||||
"Content-Type": "text/markdown",
|
||||
"Accept": "application/vnd.yjs.doc",
|
||||
"Content-Type": mime_types.MARKDOWN,
|
||||
"Accept": mime_types.YJS,
|
||||
},
|
||||
timeout=5,
|
||||
verify=False,
|
||||
@@ -108,9 +109,7 @@ def test_convert_full_integration_with_specific_headers(mock_post, settings):
|
||||
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"
|
||||
)
|
||||
result = converter.convert(b"test_content", mime_types.YJS, mime_types.MARKDOWN)
|
||||
|
||||
assert result == expected_response
|
||||
mock_post.assert_called_once_with(
|
||||
@@ -118,8 +117,8 @@ def test_convert_full_integration_with_specific_headers(mock_post, settings):
|
||||
data=b"test_content",
|
||||
headers={
|
||||
"Authorization": "Bearer test-key",
|
||||
"Content-Type": "application/vnd.yjs.doc",
|
||||
"Accept": "text/markdown",
|
||||
"Content-Type": mime_types.YJS,
|
||||
"Accept": mime_types.MARKDOWN,
|
||||
},
|
||||
timeout=5,
|
||||
verify=False,
|
||||
@@ -135,7 +134,7 @@ def test_convert_timeout(mock_post):
|
||||
|
||||
with pytest.raises(
|
||||
ServiceUnavailableError,
|
||||
match="Failed to connect to conversion service",
|
||||
match="Failed to connect to YDoc conversion service",
|
||||
):
|
||||
converter.convert("test text")
|
||||
|
||||
@@ -144,5 +143,5 @@ def test_convert_none_input():
|
||||
"""Should raise ValidationError when input is None."""
|
||||
converter = YdocConverter()
|
||||
|
||||
with pytest.raises(ValidationError, match="Input text cannot be empty"):
|
||||
with pytest.raises(ValidationError, match="Input data cannot be empty"):
|
||||
converter.convert(None)
|
||||
|
||||
117
src/backend/core/tests/test_services_docspec_converter.py
Normal file
117
src/backend/core/tests/test_services_docspec_converter.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""Test DocSpec converter services."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from core.services import mime_types
|
||||
from core.services.converter_services import (
|
||||
DocSpecConverter,
|
||||
ServiceUnavailableError,
|
||||
ValidationError,
|
||||
)
|
||||
|
||||
|
||||
def test_docspec_convert_empty_data():
|
||||
"""Should raise ValidationError when data is empty."""
|
||||
converter = DocSpecConverter()
|
||||
with pytest.raises(ValidationError, match="Input data cannot be empty"):
|
||||
converter.convert("", mime_types.DOCX, mime_types.BLOCKNOTE)
|
||||
|
||||
|
||||
def test_docspec_convert_none_input():
|
||||
"""Should raise ValidationError when input is None."""
|
||||
converter = DocSpecConverter()
|
||||
with pytest.raises(ValidationError, match="Input data cannot be empty"):
|
||||
converter.convert(None, mime_types.DOCX, mime_types.BLOCKNOTE)
|
||||
|
||||
|
||||
def test_docspec_convert_unsupported_content_type():
|
||||
"""Should raise ValidationError when content type is not DOCX."""
|
||||
converter = DocSpecConverter()
|
||||
with pytest.raises(
|
||||
ValidationError, match="Conversion from text/plain to .* is not supported"
|
||||
):
|
||||
converter.convert(b"test data", "text/plain", mime_types.BLOCKNOTE)
|
||||
|
||||
|
||||
def test_docspec_convert_unsupported_accept():
|
||||
"""Should raise ValidationError when accept type is not BLOCKNOTE."""
|
||||
converter = DocSpecConverter()
|
||||
with pytest.raises(
|
||||
ValidationError,
|
||||
match=f"Conversion from {mime_types.DOCX} to {mime_types.YJS} is not supported",
|
||||
):
|
||||
converter.convert(b"test data", mime_types.DOCX, mime_types.YJS)
|
||||
|
||||
|
||||
@patch("requests.post")
|
||||
def test_docspec_convert_service_unavailable(mock_post):
|
||||
"""Should raise ServiceUnavailableError when service is unavailable."""
|
||||
converter = DocSpecConverter()
|
||||
mock_post.side_effect = requests.RequestException("Connection error")
|
||||
|
||||
with pytest.raises(
|
||||
ServiceUnavailableError,
|
||||
match="Failed to connect to DocSpec conversion service",
|
||||
):
|
||||
converter.convert(b"test data", mime_types.DOCX, mime_types.BLOCKNOTE)
|
||||
|
||||
|
||||
@patch("requests.post")
|
||||
def test_docspec_convert_http_error(mock_post):
|
||||
"""Should raise ServiceUnavailableError when HTTP error occurs."""
|
||||
converter = DocSpecConverter()
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status.side_effect = requests.HTTPError("HTTP Error")
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
with pytest.raises(
|
||||
ServiceUnavailableError,
|
||||
match="Failed to connect to DocSpec conversion service",
|
||||
):
|
||||
converter.convert(b"test data", mime_types.DOCX, mime_types.BLOCKNOTE)
|
||||
|
||||
|
||||
@patch("requests.post")
|
||||
def test_docspec_convert_timeout(mock_post):
|
||||
"""Should raise ServiceUnavailableError when request times out."""
|
||||
converter = DocSpecConverter()
|
||||
mock_post.side_effect = requests.Timeout("Request timed out")
|
||||
|
||||
with pytest.raises(
|
||||
ServiceUnavailableError,
|
||||
match="Failed to connect to DocSpec conversion service",
|
||||
):
|
||||
converter.convert(b"test data", mime_types.DOCX, mime_types.BLOCKNOTE)
|
||||
|
||||
|
||||
@patch("requests.post")
|
||||
def test_docspec_convert_success(mock_post, settings):
|
||||
"""Test successful DOCX to BlockNote conversion."""
|
||||
settings.DOCSPEC_API_URL = "http://docspec.test/convert"
|
||||
settings.CONVERSION_API_TIMEOUT = 5
|
||||
settings.CONVERSION_API_SECURE = False
|
||||
|
||||
converter = DocSpecConverter()
|
||||
|
||||
expected_content = b'[{"type": "paragraph", "content": "test"}]'
|
||||
mock_response = MagicMock()
|
||||
mock_response.content = expected_content
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
docx_data = b"fake docx binary data"
|
||||
result = converter.convert(docx_data, mime_types.DOCX, mime_types.BLOCKNOTE)
|
||||
|
||||
assert result == expected_content
|
||||
|
||||
# Verify the request was made correctly
|
||||
mock_post.assert_called_once_with(
|
||||
"http://docspec.test/convert",
|
||||
headers={"Accept": mime_types.BLOCKNOTE},
|
||||
files={"file": ("document.docx", docx_data, mime_types.DOCX)},
|
||||
timeout=5,
|
||||
verify=False,
|
||||
)
|
||||
@@ -239,6 +239,18 @@ def test_services_search_indexers_serialize_document_empty():
|
||||
assert result["title"] == ""
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_services_search_indexers_serialize_document_encrypted():
|
||||
"""Encrypted documents should have empty content to avoid indexing ciphertext."""
|
||||
document = factories.DocumentFactory(is_encrypted=True)
|
||||
|
||||
indexer = SearchIndexer()
|
||||
result = indexer.serialize_document(document, {})
|
||||
|
||||
assert result["content"] == ""
|
||||
assert result["size"] == 0
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_services_search_indexers_index_errors(indexer_settings):
|
||||
"""
|
||||
|
||||
@@ -10,7 +10,6 @@ from core.api import viewsets
|
||||
|
||||
# - Main endpoints
|
||||
router = DefaultRouter()
|
||||
router.register("templates", viewsets.TemplateViewSet, basename="templates")
|
||||
router.register("documents", viewsets.DocumentViewSet, basename="documents")
|
||||
router.register("users", viewsets.UserViewSet, basename="users")
|
||||
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
<img width="200" src="http://localhost:3000/assets/logo-gouv.png" />
|
||||
<br/>
|
||||
@@ -216,29 +216,6 @@ def create_demo(stdout):
|
||||
|
||||
queue.flush()
|
||||
|
||||
with Timeit(stdout, "Creating Template"):
|
||||
with open(
|
||||
file="demo/data/template/code.txt", mode="r", encoding="utf-8"
|
||||
) as text_file:
|
||||
code_data = text_file.read()
|
||||
|
||||
with open(
|
||||
file="demo/data/template/css.txt", mode="r", encoding="utf-8"
|
||||
) as text_file:
|
||||
css_data = text_file.read()
|
||||
|
||||
queue.push(
|
||||
models.Template(
|
||||
id="baca9e2a-59fb-42ef-b5c6-6f6b05637111",
|
||||
title="Demo Template",
|
||||
description="This is the demo template",
|
||||
code=code_data,
|
||||
css=css_data,
|
||||
is_public=True,
|
||||
)
|
||||
)
|
||||
queue.flush()
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""A management command to create a demo database."""
|
||||
|
||||
@@ -25,7 +25,6 @@ def test_commands_create_demo():
|
||||
"""The create_demo management command should create objects as expected."""
|
||||
call_command("create_demo")
|
||||
|
||||
assert models.Template.objects.count() == 1
|
||||
assert models.User.objects.count() >= 10
|
||||
assert models.Document.objects.count() >= 10
|
||||
assert models.DocumentAccess.objects.count() > 10
|
||||
|
||||
@@ -29,6 +29,10 @@ from sentry_sdk.integrations.logging import ignore_logger
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
DATA_DIR = os.getenv("DATA_DIR", os.path.join("/", "data"))
|
||||
|
||||
KB = 1024
|
||||
MB = KB * KB
|
||||
GB = MB * KB
|
||||
|
||||
|
||||
def get_release():
|
||||
"""
|
||||
@@ -165,10 +169,15 @@ class Base(Configuration):
|
||||
environ_name="AWS_STORAGE_BUCKET_NAME",
|
||||
environ_prefix=None,
|
||||
)
|
||||
AWS_S3_SIGNATURE_VERSION = values.Value(
|
||||
"s3v4",
|
||||
environ_name="AWS_S3_SIGNATURE_VERSION",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# Document images
|
||||
DOCUMENT_IMAGE_MAX_SIZE = values.IntegerValue(
|
||||
10 * (2**20), # 10MB
|
||||
10 * MB, # 10MB
|
||||
environ_name="DOCUMENT_IMAGE_MAX_SIZE",
|
||||
environ_prefix=None,
|
||||
)
|
||||
@@ -406,16 +415,6 @@ class Base(Configuration):
|
||||
environ_name="API_DOCUMENT_ACCESS_THROTTLE_RATE",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"template": values.Value(
|
||||
default="30/minute",
|
||||
environ_name="API_TEMPLATE_THROTTLE_RATE",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"template_access": values.Value(
|
||||
default="30/minute",
|
||||
environ_name="API_TEMPLATE_ACCESS_THROTTLE_RATE",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"invitation": values.Value(
|
||||
default="60/minute",
|
||||
environ_name="API_INVITATION_THROTTLE_RATE",
|
||||
@@ -453,7 +452,7 @@ class Base(Configuration):
|
||||
"REDOC_DIST": "SIDECAR",
|
||||
}
|
||||
|
||||
TRASHBIN_CUTOFF_DAYS = values.Value(
|
||||
TRASHBIN_CUTOFF_DAYS = values.IntegerValue(
|
||||
30, environ_name="TRASHBIN_CUTOFF_DAYS", environ_prefix=None
|
||||
)
|
||||
|
||||
@@ -465,6 +464,7 @@ class Base(Configuration):
|
||||
EMAIL_HOST_PASSWORD = SecretFileValue(None)
|
||||
EMAIL_LOGO_IMG = values.Value(None)
|
||||
EMAIL_PORT = values.PositiveIntegerValue(None)
|
||||
EMAIL_URL_APP = values.Value(None)
|
||||
EMAIL_USE_TLS = values.BooleanValue(False)
|
||||
EMAIL_USE_SSL = values.BooleanValue(False)
|
||||
EMAIL_FROM = values.Value("from@example.com")
|
||||
@@ -512,7 +512,9 @@ class Base(Configuration):
|
||||
FRONTEND_JS_URL = values.Value(
|
||||
None, environ_name="FRONTEND_JS_URL", environ_prefix=None
|
||||
)
|
||||
|
||||
FRONTEND_SILENT_LOGIN_ENABLED = values.BooleanValue(
|
||||
default=False, environ_name="FRONTEND_SILENT_LOGIN_ENABLED", environ_prefix=None
|
||||
)
|
||||
THEME_CUSTOMIZATION_FILE_PATH = values.Value(
|
||||
os.path.join(BASE_DIR, "impress/configuration/theme/default.json"),
|
||||
environ_name="THEME_CUSTOMIZATION_FILE_PATH",
|
||||
@@ -554,6 +556,16 @@ class Base(Configuration):
|
||||
SESSION_COOKIE_NAME = "docs_sessionid"
|
||||
|
||||
# OIDC - Authorization Code Flow
|
||||
OIDC_AUTHENTICATE_CLASS = values.Value(
|
||||
"lasuite.oidc_login.views.OIDCAuthenticationRequestView",
|
||||
environ_name="OIDC_AUTHENTICATE_CLASS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
OIDC_CALLBACK_CLASS = values.Value(
|
||||
"lasuite.oidc_login.views.OIDCAuthenticationCallbackView",
|
||||
environ_name="OIDC_CALLBACK_CLASS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
OIDC_CREATE_USER = values.BooleanValue(
|
||||
default=True,
|
||||
environ_name="OIDC_CREATE_USER",
|
||||
@@ -699,6 +711,16 @@ class Base(Configuration):
|
||||
"day": 200,
|
||||
}
|
||||
|
||||
LANGFUSE_SECRET_KEY = SecretFileValue(
|
||||
None, environ_name="LANGFUSE_SECRET_KEY", environ_prefix=None
|
||||
)
|
||||
LANGFUSE_PUBLIC_KEY = values.Value(
|
||||
None, environ_name="LANGFUSE_PUBLIC_KEY", environ_prefix=None
|
||||
)
|
||||
LANGFUSE_BASE_URL = values.Value(
|
||||
None, environ_name="LANGFUSE_BASE_URL", environ_prefix=None
|
||||
)
|
||||
|
||||
# Y provider microservice
|
||||
Y_PROVIDER_API_KEY = SecretFileValue(
|
||||
environ_name="Y_PROVIDER_API_KEY",
|
||||
@@ -709,6 +731,22 @@ class Base(Configuration):
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# DocSpec API microservice
|
||||
DOCSPEC_API_URL = values.Value(environ_name="DOCSPEC_API_URL", environ_prefix=None)
|
||||
|
||||
# Imported file settings
|
||||
CONVERSION_FILE_MAX_SIZE = values.IntegerValue(
|
||||
20 * MB, # 10MB
|
||||
environ_name="CONVERSION_FILE_MAX_SIZE",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
CONVERSION_FILE_EXTENSIONS_ALLOWED = values.ListValue(
|
||||
default=[".docx", ".md"],
|
||||
environ_name="CONVERSION_FILE_EXTENSIONS_ALLOWED",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# Conversion endpoint
|
||||
CONVERSION_API_ENDPOINT = values.Value(
|
||||
default="convert",
|
||||
@@ -1050,15 +1088,20 @@ class Production(Base):
|
||||
# Modern browsers require to have the `secure` attribute on cookies with `Samesite=none`
|
||||
CSRF_COOKIE_SECURE = True
|
||||
SESSION_COOKIE_SECURE = True
|
||||
SESSION_CACHE_ALIAS = "session"
|
||||
|
||||
# Privacy
|
||||
SECURE_REFERRER_POLICY = "same-origin"
|
||||
|
||||
# Conversion API: Always verify SSL in production
|
||||
CONVERSION_API_SECURE = True
|
||||
|
||||
# Cache
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django_redis.cache.RedisCache",
|
||||
"LOCATION": values.Value(
|
||||
"redis://redis:6379/1",
|
||||
"redis://redis:6379/0",
|
||||
environ_name="REDIS_URL",
|
||||
environ_prefix=None,
|
||||
),
|
||||
@@ -1072,10 +1115,26 @@ class Production(Base):
|
||||
},
|
||||
"KEY_PREFIX": values.Value(
|
||||
"docs",
|
||||
environ_name="CACHES_KEY_PREFIX",
|
||||
environ_name="CACHES_DEFAULT_KEY_PREFIX",
|
||||
environ_prefix=None,
|
||||
),
|
||||
},
|
||||
"session": {
|
||||
"BACKEND": "django_redis.cache.RedisCache",
|
||||
"LOCATION": values.Value(
|
||||
"redis://redis:6379/0",
|
||||
environ_name="REDIS_URL",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"TIMEOUT": values.IntegerValue(
|
||||
30, # timeout in seconds
|
||||
environ_name="CACHES_SESSION_TIMEOUT",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"OPTIONS": {
|
||||
"CLIENT_CLASS": "django_redis.client.DefaultClient",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-12-09 11:12\n"
|
||||
"POT-Creation-Date: 2026-01-21 09:53+0000\n"
|
||||
"PO-Revision-Date: 2026-01-28 20:12\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Breton\n"
|
||||
"Language: br_FR\n"
|
||||
@@ -17,20 +17,20 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 18\n"
|
||||
|
||||
#: build/lib/core/admin.py:36 core/admin.py:36
|
||||
#: build/lib/core/admin.py:28 core/admin.py:28
|
||||
msgid "Personal info"
|
||||
msgstr "Titouroù personel"
|
||||
|
||||
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
|
||||
#: core/admin.py:137
|
||||
#: build/lib/core/admin.py:41 build/lib/core/admin.py:121 core/admin.py:41
|
||||
#: core/admin.py:121
|
||||
msgid "Permissions"
|
||||
msgstr "Aotreoù"
|
||||
|
||||
#: build/lib/core/admin.py:61 core/admin.py:61
|
||||
#: build/lib/core/admin.py:53 core/admin.py:53
|
||||
msgid "Important dates"
|
||||
msgstr "Deiziadoù a-bouez"
|
||||
|
||||
#: build/lib/core/admin.py:147 core/admin.py:147
|
||||
#: build/lib/core/admin.py:131 core/admin.py:131
|
||||
msgid "Tree structure"
|
||||
msgstr "Gwezennadur"
|
||||
|
||||
@@ -50,40 +50,32 @@ msgstr "Kuzhet"
|
||||
msgid "Favorite"
|
||||
msgstr "Sinedoù"
|
||||
|
||||
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
|
||||
#: build/lib/core/api/serializers.py:505 core/api/serializers.py:505
|
||||
msgid "A new document was created on your behalf!"
|
||||
msgstr "Ur restr nevez a zo bet krouet ganeoc'h!"
|
||||
|
||||
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
|
||||
#: build/lib/core/api/serializers.py:509 core/api/serializers.py:509
|
||||
msgid "You have been granted ownership of a new document:"
|
||||
msgstr "C'hwi zo bet disklaeriet perc'henn ur restr nevez:"
|
||||
|
||||
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
|
||||
#: build/lib/core/api/serializers.py:545 core/api/serializers.py:545
|
||||
msgid "This field is required."
|
||||
msgstr "Ar vaezienn-mañ a zo rekis."
|
||||
|
||||
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
|
||||
#: build/lib/core/api/serializers.py:556 core/api/serializers.py:556
|
||||
#, python-format
|
||||
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:694 core/api/serializers.py:694
|
||||
msgid "Body"
|
||||
msgstr "Korf"
|
||||
|
||||
#: build/lib/core/api/serializers.py:697 core/api/serializers.py:697
|
||||
msgid "Body type"
|
||||
msgstr "Doare korf"
|
||||
|
||||
#: build/lib/core/api/serializers.py:703 core/api/serializers.py:703
|
||||
msgid "Format"
|
||||
msgstr "Stumm"
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
|
||||
#: build/lib/core/api/viewsets.py:1122 core/api/viewsets.py:1122
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "eilenn {title}"
|
||||
|
||||
#: build/lib/core/apps.py:12 core/apps.py:12
|
||||
msgid "Impress core application"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
|
||||
#: core/choices.py:43
|
||||
msgid "Reader"
|
||||
@@ -143,301 +135,259 @@ msgstr "Kleiz"
|
||||
msgid "Right"
|
||||
msgstr "Dehoù"
|
||||
|
||||
#: build/lib/core/models.py:80 core/models.py:80
|
||||
#: build/lib/core/models.py:81 core/models.py:81
|
||||
msgid "id"
|
||||
msgstr "id"
|
||||
|
||||
#: build/lib/core/models.py:81 core/models.py:81
|
||||
#: build/lib/core/models.py:82 core/models.py:82
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr "alc'hwez kentañ evit an enrollañ evel UIID"
|
||||
|
||||
#: build/lib/core/models.py:87 core/models.py:87
|
||||
#: build/lib/core/models.py:88 core/models.py:88
|
||||
msgid "created on"
|
||||
msgstr "krouet d'ar/al"
|
||||
|
||||
#: build/lib/core/models.py:88 core/models.py:88
|
||||
#: build/lib/core/models.py:89 core/models.py:89
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr "deiziad hag eurvezh krouidigezh an enrolladenn"
|
||||
|
||||
#: build/lib/core/models.py:93 core/models.py:93
|
||||
#: build/lib/core/models.py:94 core/models.py:94
|
||||
msgid "updated on"
|
||||
msgstr "hizivaet d'ar/al"
|
||||
|
||||
#: build/lib/core/models.py:94 core/models.py:94
|
||||
#: build/lib/core/models.py:95 core/models.py:95
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr "deiziad hag eurvezh m'eo bet hizivaet an enrolladenn"
|
||||
|
||||
#: build/lib/core/models.py:130 core/models.py:130
|
||||
#: build/lib/core/models.py:131 core/models.py:131
|
||||
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
|
||||
msgstr "N'hon eus kavet implijer ebet gant an isstrollad-mañ met ar postel a zo liammet ouzh un implijer enrollet."
|
||||
|
||||
#: build/lib/core/models.py:141 core/models.py:141
|
||||
#: build/lib/core/models.py:142 core/models.py:142
|
||||
msgid "sub"
|
||||
msgstr "isstrollad"
|
||||
|
||||
#: build/lib/core/models.py:142 core/models.py:142
|
||||
#: build/lib/core/models.py:143 core/models.py:143
|
||||
msgid "Required. 255 characters or fewer. ASCII characters only."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:150 core/models.py:150
|
||||
#: build/lib/core/models.py:151 core/models.py:151
|
||||
msgid "full name"
|
||||
msgstr "anv klok"
|
||||
|
||||
#: build/lib/core/models.py:152 core/models.py:152
|
||||
#: build/lib/core/models.py:153 core/models.py:153
|
||||
msgid "short name"
|
||||
msgstr "anv berr"
|
||||
|
||||
#: build/lib/core/models.py:155 core/models.py:155
|
||||
#: build/lib/core/models.py:156 core/models.py:156
|
||||
msgid "identity email address"
|
||||
msgstr "postel identelezh"
|
||||
|
||||
#: build/lib/core/models.py:160 core/models.py:160
|
||||
#: build/lib/core/models.py:161 core/models.py:161
|
||||
msgid "admin email address"
|
||||
msgstr "postel ar merour"
|
||||
|
||||
#: build/lib/core/models.py:167 core/models.py:167
|
||||
#: build/lib/core/models.py:168 core/models.py:168
|
||||
msgid "language"
|
||||
msgstr "yezh"
|
||||
|
||||
#: build/lib/core/models.py:168 core/models.py:168
|
||||
#: build/lib/core/models.py:169 core/models.py:169
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr "Ar yezh a vo implijet evit etrefas an implijer."
|
||||
|
||||
#: build/lib/core/models.py:176 core/models.py:176
|
||||
#: build/lib/core/models.py:177 core/models.py:177
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr "Ar gwerzhid-eur a vo implijet evit etrefas an implijer."
|
||||
|
||||
#: build/lib/core/models.py:179 core/models.py:179
|
||||
#: build/lib/core/models.py:180 core/models.py:180
|
||||
msgid "device"
|
||||
msgstr "trevnad"
|
||||
|
||||
#: build/lib/core/models.py:181 core/models.py:181
|
||||
#: build/lib/core/models.py:182 core/models.py:182
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr "Pe vefe an implijer un aparailh pe un implijer gwirion."
|
||||
|
||||
#: build/lib/core/models.py:184 core/models.py:184
|
||||
#: build/lib/core/models.py:185 core/models.py:185
|
||||
msgid "staff status"
|
||||
msgstr "statud ar skipailh"
|
||||
|
||||
#: build/lib/core/models.py:186 core/models.py:186
|
||||
#: build/lib/core/models.py:187 core/models.py:187
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Ma c'hall an implijer kevreañ ouzh al lec'hienn verañ-mañ."
|
||||
|
||||
#: build/lib/core/models.py:189 core/models.py:189
|
||||
#: build/lib/core/models.py:190 core/models.py:190
|
||||
msgid "active"
|
||||
msgstr "oberiant"
|
||||
|
||||
#: build/lib/core/models.py:192 core/models.py:192
|
||||
#: build/lib/core/models.py:193 core/models.py:193
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr "Ma rank bezañ tretet an implijer-mañ evel oberiant. Diziuzit an dra-mañ e-plas dilemel kontoù."
|
||||
|
||||
#: build/lib/core/models.py:204 core/models.py:204
|
||||
#: build/lib/core/models.py:205 core/models.py:205
|
||||
msgid "user"
|
||||
msgstr "implijer"
|
||||
|
||||
#: build/lib/core/models.py:205 core/models.py:205
|
||||
#: build/lib/core/models.py:206 core/models.py:206
|
||||
msgid "users"
|
||||
msgstr "implijerien"
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
|
||||
#: core/models.py:361 core/models.py:1430
|
||||
#: build/lib/core/models.py:362 core/models.py:362
|
||||
msgid "title"
|
||||
msgstr "titl"
|
||||
|
||||
#: build/lib/core/models.py:362 core/models.py:362
|
||||
#: build/lib/core/models.py:363 core/models.py:363
|
||||
msgid "excerpt"
|
||||
msgstr "bomm"
|
||||
|
||||
#: build/lib/core/models.py:411 core/models.py:411
|
||||
#: build/lib/core/models.py:412 core/models.py:412
|
||||
msgid "Document"
|
||||
msgstr "Restr"
|
||||
|
||||
#: build/lib/core/models.py:412 core/models.py:412
|
||||
#: build/lib/core/models.py:413 core/models.py:413
|
||||
msgid "Documents"
|
||||
msgstr "Restroù"
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
|
||||
#: core/models.py:824
|
||||
#: build/lib/core/models.py:425 build/lib/core/models.py:828 core/models.py:425
|
||||
#: core/models.py:828
|
||||
msgid "Untitled Document"
|
||||
msgstr "Restr hep titl"
|
||||
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#: build/lib/core/models.py:829 core/models.py:829
|
||||
msgid "Open"
|
||||
msgstr "Digeriñ"
|
||||
|
||||
#: build/lib/core/models.py:864 core/models.py:864
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "{name} en deus rannet ur restr ganeoc'h!"
|
||||
|
||||
#: build/lib/core/models.py:863 core/models.py:863
|
||||
#: build/lib/core/models.py:868 core/models.py:868
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr "{name} en deus pedet ac'hanoc'h gant ar rol \"{role}\" war ar restr da-heul:"
|
||||
|
||||
#: build/lib/core/models.py:869 core/models.py:869
|
||||
#: build/lib/core/models.py:874 core/models.py:874
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr "{name} en deus rannet ur restr ganeoc'h: {title}"
|
||||
|
||||
#: build/lib/core/models.py:969 core/models.py:969
|
||||
#: build/lib/core/models.py:975 core/models.py:975
|
||||
msgid "Document/user link trace"
|
||||
msgstr "Roud liamm ar restr/an implijer"
|
||||
|
||||
#: build/lib/core/models.py:970 core/models.py:970
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
msgid "Document/user link traces"
|
||||
msgstr "Roudoù liamm ar restr/an implijer"
|
||||
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
#: build/lib/core/models.py:982 core/models.py:982
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr "Ur roud liamm a zo dija evit an restr/an implijer."
|
||||
|
||||
#: build/lib/core/models.py:999 core/models.py:999
|
||||
#: build/lib/core/models.py:1005 core/models.py:1005
|
||||
msgid "Document favorite"
|
||||
msgstr "Restr muiañ-karet"
|
||||
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
msgid "Document favorites"
|
||||
msgstr "Restroù muiañ-karet"
|
||||
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
#: build/lib/core/models.py:1012 core/models.py:1012
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr "Ar restr-mañ a zo ur restr muiañ karet gant an implijer-mañ."
|
||||
|
||||
#: build/lib/core/models.py:1028 core/models.py:1028
|
||||
#: build/lib/core/models.py:1034 core/models.py:1034
|
||||
msgid "Document/user relation"
|
||||
msgstr "Liamm restr/implijer"
|
||||
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
msgid "Document/user relations"
|
||||
msgstr "Liammoù restr/implijer"
|
||||
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
msgid "This user is already in this document."
|
||||
msgstr "An implijer-mañ a zo dija er restr-mañ."
|
||||
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
#: build/lib/core/models.py:1047 core/models.py:1047
|
||||
msgid "This team is already in this document."
|
||||
msgstr "Ar skipailh-mañ a zo dija en restr-mañ."
|
||||
|
||||
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
|
||||
#: core/models.py:1047 core/models.py:1516
|
||||
#: build/lib/core/models.py:1053 core/models.py:1053
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "An implijer pe ar skipailh a rank bezañ termenet, ket an daou avat."
|
||||
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1204 core/models.py:1204
|
||||
msgid "Document ask for access"
|
||||
msgstr "Goulenn tizhout ar restr"
|
||||
|
||||
#: build/lib/core/models.py:1199 core/models.py:1199
|
||||
#: build/lib/core/models.py:1205 core/models.py:1205
|
||||
msgid "Document ask for accesses"
|
||||
msgstr "Goulennoù tizhout ar restr"
|
||||
|
||||
#: build/lib/core/models.py:1205 core/models.py:1205
|
||||
#: build/lib/core/models.py:1211 core/models.py:1211
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr "An implijer en deus goulennet tizhout ar restr-mañ."
|
||||
|
||||
#: build/lib/core/models.py:1262 core/models.py:1262
|
||||
#: build/lib/core/models.py:1268 core/models.py:1268
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr "{name} en defe c'hoant da dizhout ar restr-mañ!"
|
||||
|
||||
#: build/lib/core/models.py:1266 core/models.py:1266
|
||||
#: build/lib/core/models.py:1272 core/models.py:1272
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr "{name} en defe c'hoant da dizhout ar restr da-heul:"
|
||||
|
||||
#: build/lib/core/models.py:1272 core/models.py:1272
|
||||
#: build/lib/core/models.py:1278 core/models.py:1278
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr "{name} en defe c'hoant da dizhout ar restr: {title}"
|
||||
|
||||
#: build/lib/core/models.py:1314 core/models.py:1314
|
||||
#: build/lib/core/models.py:1320 core/models.py:1320
|
||||
msgid "Thread"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1315 core/models.py:1315
|
||||
#: build/lib/core/models.py:1321 core/models.py:1321
|
||||
msgid "Threads"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
|
||||
#: core/models.py:1318 core/models.py:1370
|
||||
#: build/lib/core/models.py:1324 build/lib/core/models.py:1376
|
||||
#: core/models.py:1324 core/models.py:1376
|
||||
msgid "Anonymous"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1365 core/models.py:1365
|
||||
#: build/lib/core/models.py:1371 core/models.py:1371
|
||||
msgid "Comment"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1366 core/models.py:1366
|
||||
#: build/lib/core/models.py:1372 core/models.py:1372
|
||||
msgid "Comments"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1415 core/models.py:1415
|
||||
#: build/lib/core/models.py:1421 core/models.py:1421
|
||||
msgid "This emoji has already been reacted to this comment."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1419 core/models.py:1419
|
||||
#: build/lib/core/models.py:1425 core/models.py:1425
|
||||
msgid "Reaction"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1420 core/models.py:1420
|
||||
#: build/lib/core/models.py:1426 core/models.py:1426
|
||||
msgid "Reactions"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1431 core/models.py:1431
|
||||
msgid "description"
|
||||
msgstr "deskrivadur"
|
||||
|
||||
#: build/lib/core/models.py:1432 core/models.py:1432
|
||||
msgid "code"
|
||||
msgstr "kod"
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
msgid "css"
|
||||
msgstr "css"
|
||||
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
msgid "public"
|
||||
msgstr "publik"
|
||||
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr "M'eo foran ar patrom-mañ hag implijus gant n'eus forzh piv."
|
||||
|
||||
#: build/lib/core/models.py:1443 core/models.py:1443
|
||||
msgid "Template"
|
||||
msgstr "Patrom"
|
||||
|
||||
#: build/lib/core/models.py:1444 core/models.py:1444
|
||||
msgid "Templates"
|
||||
msgstr "Patromoù"
|
||||
|
||||
#: build/lib/core/models.py:1497 core/models.py:1497
|
||||
msgid "Template/user relation"
|
||||
msgstr "Liamm patrom/implijer"
|
||||
|
||||
#: build/lib/core/models.py:1498 core/models.py:1498
|
||||
msgid "Template/user relations"
|
||||
msgstr "Liammoù patrom/implijer"
|
||||
|
||||
#: build/lib/core/models.py:1504 core/models.py:1504
|
||||
msgid "This user is already in this template."
|
||||
msgstr "An implijer-mañ a zo dija er patrom-mañ."
|
||||
|
||||
#: build/lib/core/models.py:1510 core/models.py:1510
|
||||
msgid "This team is already in this template."
|
||||
msgstr "Ar skipailh-mañ a zo dija er patrom-mañ."
|
||||
|
||||
#: build/lib/core/models.py:1587 core/models.py:1587
|
||||
#: build/lib/core/models.py:1436 core/models.py:1436
|
||||
msgid "email address"
|
||||
msgstr "postel"
|
||||
|
||||
#: build/lib/core/models.py:1606 core/models.py:1606
|
||||
#: build/lib/core/models.py:1455 core/models.py:1455
|
||||
msgid "Document invitation"
|
||||
msgstr "Pedadenn d'ur restr"
|
||||
|
||||
#: build/lib/core/models.py:1607 core/models.py:1607
|
||||
#: build/lib/core/models.py:1456 core/models.py:1456
|
||||
msgid "Document invitations"
|
||||
msgstr "Pedadennoù d'ur restr"
|
||||
|
||||
#: build/lib/core/models.py:1627 core/models.py:1627
|
||||
#: build/lib/core/models.py:1476 core/models.py:1476
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr "Ar postel-mañ a zo liammet ouzh un implijer enskrivet."
|
||||
|
||||
@@ -446,17 +396,12 @@ msgstr "Ar postel-mañ a zo liammet ouzh un implijer enskrivet."
|
||||
msgid "Logo email"
|
||||
msgstr "Logo ar postel"
|
||||
|
||||
#: core/templates/mail/html/template.html:200
|
||||
#: core/templates/mail/text/template.txt:10
|
||||
msgid "Open"
|
||||
msgstr "Digeriñ"
|
||||
|
||||
#: core/templates/mail/html/template.html:217
|
||||
#: core/templates/mail/html/template.html:219
|
||||
#: core/templates/mail/text/template.txt:14
|
||||
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
|
||||
msgstr " Docs, hoc'h ostilh nevez ret-holl evit aozañ, rannañ ha kenlabourat war ar restr e skipailh. "
|
||||
|
||||
#: core/templates/mail/html/template.html:224
|
||||
#: core/templates/mail/html/template.html:226
|
||||
#: core/templates/mail/text/template.txt:16
|
||||
#, python-format
|
||||
msgid " Brought to you by %(brandname)s "
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-12-09 11:12\n"
|
||||
"POT-Creation-Date: 2026-01-21 09:53+0000\n"
|
||||
"PO-Revision-Date: 2026-01-28 20:12\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: German\n"
|
||||
"Language: de_DE\n"
|
||||
@@ -17,20 +17,20 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 18\n"
|
||||
|
||||
#: build/lib/core/admin.py:36 core/admin.py:36
|
||||
#: build/lib/core/admin.py:28 core/admin.py:28
|
||||
msgid "Personal info"
|
||||
msgstr "Persönliche Daten"
|
||||
|
||||
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
|
||||
#: core/admin.py:137
|
||||
#: build/lib/core/admin.py:41 build/lib/core/admin.py:121 core/admin.py:41
|
||||
#: core/admin.py:121
|
||||
msgid "Permissions"
|
||||
msgstr "Berechtigungen"
|
||||
|
||||
#: build/lib/core/admin.py:61 core/admin.py:61
|
||||
#: build/lib/core/admin.py:53 core/admin.py:53
|
||||
msgid "Important dates"
|
||||
msgstr "Wichtige Daten"
|
||||
|
||||
#: build/lib/core/admin.py:147 core/admin.py:147
|
||||
#: build/lib/core/admin.py:131 core/admin.py:131
|
||||
msgid "Tree structure"
|
||||
msgstr "Baumstruktur"
|
||||
|
||||
@@ -50,40 +50,32 @@ msgstr ""
|
||||
msgid "Favorite"
|
||||
msgstr "Favorit"
|
||||
|
||||
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
|
||||
#: build/lib/core/api/serializers.py:505 core/api/serializers.py:505
|
||||
msgid "A new document was created on your behalf!"
|
||||
msgstr "Ein neues Dokument wurde in Ihrem Namen erstellt!"
|
||||
|
||||
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
|
||||
#: build/lib/core/api/serializers.py:509 core/api/serializers.py:509
|
||||
msgid "You have been granted ownership of a new document:"
|
||||
msgstr "Sie sind Besitzer eines neuen Dokuments:"
|
||||
|
||||
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
|
||||
#: build/lib/core/api/serializers.py:545 core/api/serializers.py:545
|
||||
msgid "This field is required."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
|
||||
#: build/lib/core/api/serializers.py:556 core/api/serializers.py:556
|
||||
#, python-format
|
||||
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:694 core/api/serializers.py:694
|
||||
msgid "Body"
|
||||
msgstr "Inhalt"
|
||||
|
||||
#: build/lib/core/api/serializers.py:697 core/api/serializers.py:697
|
||||
msgid "Body type"
|
||||
msgstr "Typ"
|
||||
|
||||
#: build/lib/core/api/serializers.py:703 core/api/serializers.py:703
|
||||
msgid "Format"
|
||||
msgstr "Format"
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
|
||||
#: build/lib/core/api/viewsets.py:1122 core/api/viewsets.py:1122
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "Kopie von {title}"
|
||||
|
||||
#: build/lib/core/apps.py:12 core/apps.py:12
|
||||
msgid "Impress core application"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
|
||||
#: core/choices.py:43
|
||||
msgid "Reader"
|
||||
@@ -143,301 +135,259 @@ msgstr "Links"
|
||||
msgid "Right"
|
||||
msgstr "Rechts"
|
||||
|
||||
#: build/lib/core/models.py:80 core/models.py:80
|
||||
#: build/lib/core/models.py:81 core/models.py:81
|
||||
msgid "id"
|
||||
msgstr "id"
|
||||
|
||||
#: build/lib/core/models.py:81 core/models.py:81
|
||||
#: build/lib/core/models.py:82 core/models.py:82
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr "primärer Schlüssel für den Datensatz als UUID"
|
||||
|
||||
#: build/lib/core/models.py:87 core/models.py:87
|
||||
#: build/lib/core/models.py:88 core/models.py:88
|
||||
msgid "created on"
|
||||
msgstr "Erstellt"
|
||||
|
||||
#: build/lib/core/models.py:88 core/models.py:88
|
||||
#: build/lib/core/models.py:89 core/models.py:89
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr "Datum und Uhrzeit, an dem ein Datensatz erstellt wurde"
|
||||
|
||||
#: build/lib/core/models.py:93 core/models.py:93
|
||||
#: build/lib/core/models.py:94 core/models.py:94
|
||||
msgid "updated on"
|
||||
msgstr "Aktualisiert"
|
||||
|
||||
#: build/lib/core/models.py:94 core/models.py:94
|
||||
#: build/lib/core/models.py:95 core/models.py:95
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr "Datum und Uhrzeit, an dem zuletzt aktualisiert wurde"
|
||||
|
||||
#: build/lib/core/models.py:130 core/models.py:130
|
||||
#: build/lib/core/models.py:131 core/models.py:131
|
||||
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
|
||||
msgstr "Wir konnten keinen Benutzer mit diesem Abo finden, aber die E-Mail-Adresse ist bereits einem registrierten Benutzer zugeordnet."
|
||||
|
||||
#: build/lib/core/models.py:141 core/models.py:141
|
||||
#: build/lib/core/models.py:142 core/models.py:142
|
||||
msgid "sub"
|
||||
msgstr "unter"
|
||||
|
||||
#: build/lib/core/models.py:142 core/models.py:142
|
||||
#: build/lib/core/models.py:143 core/models.py:143
|
||||
msgid "Required. 255 characters or fewer. ASCII characters only."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:150 core/models.py:150
|
||||
#: build/lib/core/models.py:151 core/models.py:151
|
||||
msgid "full name"
|
||||
msgstr "Name"
|
||||
|
||||
#: build/lib/core/models.py:152 core/models.py:152
|
||||
#: build/lib/core/models.py:153 core/models.py:153
|
||||
msgid "short name"
|
||||
msgstr "Kurzbezeichnung"
|
||||
|
||||
#: build/lib/core/models.py:155 core/models.py:155
|
||||
#: build/lib/core/models.py:156 core/models.py:156
|
||||
msgid "identity email address"
|
||||
msgstr "Identitäts-E-Mail-Adresse"
|
||||
|
||||
#: build/lib/core/models.py:160 core/models.py:160
|
||||
#: build/lib/core/models.py:161 core/models.py:161
|
||||
msgid "admin email address"
|
||||
msgstr "Admin E-Mail-Adresse"
|
||||
|
||||
#: build/lib/core/models.py:167 core/models.py:167
|
||||
#: build/lib/core/models.py:168 core/models.py:168
|
||||
msgid "language"
|
||||
msgstr "Sprache"
|
||||
|
||||
#: build/lib/core/models.py:168 core/models.py:168
|
||||
#: build/lib/core/models.py:169 core/models.py:169
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr "Die Sprache, in der der Benutzer die Benutzeroberfläche sehen möchte."
|
||||
|
||||
#: build/lib/core/models.py:176 core/models.py:176
|
||||
#: build/lib/core/models.py:177 core/models.py:177
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr "Die Zeitzone, in der der Nutzer Zeiten sehen möchte."
|
||||
|
||||
#: build/lib/core/models.py:179 core/models.py:179
|
||||
#: build/lib/core/models.py:180 core/models.py:180
|
||||
msgid "device"
|
||||
msgstr "Gerät"
|
||||
|
||||
#: build/lib/core/models.py:181 core/models.py:181
|
||||
#: build/lib/core/models.py:182 core/models.py:182
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr "Ob der Benutzer ein Gerät oder ein echter Benutzer ist."
|
||||
|
||||
#: build/lib/core/models.py:184 core/models.py:184
|
||||
#: build/lib/core/models.py:185 core/models.py:185
|
||||
msgid "staff status"
|
||||
msgstr "Status des Teammitgliedes"
|
||||
|
||||
#: build/lib/core/models.py:186 core/models.py:186
|
||||
#: build/lib/core/models.py:187 core/models.py:187
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Gibt an, ob der Benutzer sich in diese Admin-Seite einloggen kann."
|
||||
|
||||
#: build/lib/core/models.py:189 core/models.py:189
|
||||
#: build/lib/core/models.py:190 core/models.py:190
|
||||
msgid "active"
|
||||
msgstr "aktiviert"
|
||||
|
||||
#: build/lib/core/models.py:192 core/models.py:192
|
||||
#: build/lib/core/models.py:193 core/models.py:193
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr "Ob dieser Benutzer als aktiviert behandelt werden soll. Deaktivieren Sie diese Option, anstatt Konten zu löschen."
|
||||
|
||||
#: build/lib/core/models.py:204 core/models.py:204
|
||||
#: build/lib/core/models.py:205 core/models.py:205
|
||||
msgid "user"
|
||||
msgstr "Benutzer"
|
||||
|
||||
#: build/lib/core/models.py:205 core/models.py:205
|
||||
#: build/lib/core/models.py:206 core/models.py:206
|
||||
msgid "users"
|
||||
msgstr "Benutzer"
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
|
||||
#: core/models.py:361 core/models.py:1430
|
||||
#: build/lib/core/models.py:362 core/models.py:362
|
||||
msgid "title"
|
||||
msgstr "Titel"
|
||||
|
||||
#: build/lib/core/models.py:362 core/models.py:362
|
||||
#: build/lib/core/models.py:363 core/models.py:363
|
||||
msgid "excerpt"
|
||||
msgstr "Auszug"
|
||||
|
||||
#: build/lib/core/models.py:411 core/models.py:411
|
||||
#: build/lib/core/models.py:412 core/models.py:412
|
||||
msgid "Document"
|
||||
msgstr "Dokument"
|
||||
|
||||
#: build/lib/core/models.py:412 core/models.py:412
|
||||
#: build/lib/core/models.py:413 core/models.py:413
|
||||
msgid "Documents"
|
||||
msgstr "Dokumente"
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
|
||||
#: core/models.py:824
|
||||
#: build/lib/core/models.py:425 build/lib/core/models.py:828 core/models.py:425
|
||||
#: core/models.py:828
|
||||
msgid "Untitled Document"
|
||||
msgstr "Unbenanntes Dokument"
|
||||
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#: build/lib/core/models.py:829 core/models.py:829
|
||||
msgid "Open"
|
||||
msgstr "Öffnen"
|
||||
|
||||
#: build/lib/core/models.py:864 core/models.py:864
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "{name} hat ein Dokument mit Ihnen geteilt!"
|
||||
|
||||
#: build/lib/core/models.py:863 core/models.py:863
|
||||
#: build/lib/core/models.py:868 core/models.py:868
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr "{name} hat Sie mit der Rolle \"{role}\" zu folgendem Dokument eingeladen:"
|
||||
|
||||
#: build/lib/core/models.py:869 core/models.py:869
|
||||
#: build/lib/core/models.py:874 core/models.py:874
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr "{name} hat ein Dokument mit Ihnen geteilt: {title}"
|
||||
|
||||
#: build/lib/core/models.py:969 core/models.py:969
|
||||
#: build/lib/core/models.py:975 core/models.py:975
|
||||
msgid "Document/user link trace"
|
||||
msgstr "Dokument/Benutzer Linkverfolgung"
|
||||
|
||||
#: build/lib/core/models.py:970 core/models.py:970
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
msgid "Document/user link traces"
|
||||
msgstr "Dokument/Benutzer Linkverfolgung"
|
||||
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
#: build/lib/core/models.py:982 core/models.py:982
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr "Für dieses Dokument/ diesen Benutzer ist bereits eine Linkverfolgung vorhanden."
|
||||
|
||||
#: build/lib/core/models.py:999 core/models.py:999
|
||||
#: build/lib/core/models.py:1005 core/models.py:1005
|
||||
msgid "Document favorite"
|
||||
msgstr "Dokumentenfavorit"
|
||||
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
msgid "Document favorites"
|
||||
msgstr "Dokumentfavoriten"
|
||||
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
#: build/lib/core/models.py:1012 core/models.py:1012
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr "Dieses Dokument ist bereits durch den gleichen Benutzer favorisiert worden."
|
||||
|
||||
#: build/lib/core/models.py:1028 core/models.py:1028
|
||||
#: build/lib/core/models.py:1034 core/models.py:1034
|
||||
msgid "Document/user relation"
|
||||
msgstr "Dokument/Benutzerbeziehung"
|
||||
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
msgid "Document/user relations"
|
||||
msgstr "Dokument/Benutzerbeziehungen"
|
||||
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
msgid "This user is already in this document."
|
||||
msgstr "Dieser Benutzer befindet sich bereits in diesem Dokument."
|
||||
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
#: build/lib/core/models.py:1047 core/models.py:1047
|
||||
msgid "This team is already in this document."
|
||||
msgstr "Dieses Team befindet sich bereits in diesem Dokument."
|
||||
|
||||
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
|
||||
#: core/models.py:1047 core/models.py:1516
|
||||
#: build/lib/core/models.py:1053 core/models.py:1053
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Benutzer oder Team müssen gesetzt werden, nicht beides."
|
||||
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1204 core/models.py:1204
|
||||
msgid "Document ask for access"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1199 core/models.py:1199
|
||||
#: build/lib/core/models.py:1205 core/models.py:1205
|
||||
msgid "Document ask for accesses"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1205 core/models.py:1205
|
||||
#: build/lib/core/models.py:1211 core/models.py:1211
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1262 core/models.py:1262
|
||||
#: build/lib/core/models.py:1268 core/models.py:1268
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1266 core/models.py:1266
|
||||
#: build/lib/core/models.py:1272 core/models.py:1272
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1272 core/models.py:1272
|
||||
#: build/lib/core/models.py:1278 core/models.py:1278
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1314 core/models.py:1314
|
||||
#: build/lib/core/models.py:1320 core/models.py:1320
|
||||
msgid "Thread"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1315 core/models.py:1315
|
||||
#: build/lib/core/models.py:1321 core/models.py:1321
|
||||
msgid "Threads"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
|
||||
#: core/models.py:1318 core/models.py:1370
|
||||
#: build/lib/core/models.py:1324 build/lib/core/models.py:1376
|
||||
#: core/models.py:1324 core/models.py:1376
|
||||
msgid "Anonymous"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1365 core/models.py:1365
|
||||
#: build/lib/core/models.py:1371 core/models.py:1371
|
||||
msgid "Comment"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1366 core/models.py:1366
|
||||
#: build/lib/core/models.py:1372 core/models.py:1372
|
||||
msgid "Comments"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1415 core/models.py:1415
|
||||
#: build/lib/core/models.py:1421 core/models.py:1421
|
||||
msgid "This emoji has already been reacted to this comment."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1419 core/models.py:1419
|
||||
#: build/lib/core/models.py:1425 core/models.py:1425
|
||||
msgid "Reaction"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1420 core/models.py:1420
|
||||
#: build/lib/core/models.py:1426 core/models.py:1426
|
||||
msgid "Reactions"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1431 core/models.py:1431
|
||||
msgid "description"
|
||||
msgstr "Beschreibung"
|
||||
|
||||
#: build/lib/core/models.py:1432 core/models.py:1432
|
||||
msgid "code"
|
||||
msgstr "Code"
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
msgid "css"
|
||||
msgstr "CSS"
|
||||
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
msgid "public"
|
||||
msgstr "öffentlich"
|
||||
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr "Ob diese Vorlage für jedermann öffentlich ist."
|
||||
|
||||
#: build/lib/core/models.py:1443 core/models.py:1443
|
||||
msgid "Template"
|
||||
msgstr "Vorlage"
|
||||
|
||||
#: build/lib/core/models.py:1444 core/models.py:1444
|
||||
msgid "Templates"
|
||||
msgstr "Vorlagen"
|
||||
|
||||
#: build/lib/core/models.py:1497 core/models.py:1497
|
||||
msgid "Template/user relation"
|
||||
msgstr "Vorlage/Benutzer-Beziehung"
|
||||
|
||||
#: build/lib/core/models.py:1498 core/models.py:1498
|
||||
msgid "Template/user relations"
|
||||
msgstr "Vorlage/Benutzerbeziehungen"
|
||||
|
||||
#: build/lib/core/models.py:1504 core/models.py:1504
|
||||
msgid "This user is already in this template."
|
||||
msgstr "Dieser Benutzer ist bereits in dieser Vorlage."
|
||||
|
||||
#: build/lib/core/models.py:1510 core/models.py:1510
|
||||
msgid "This team is already in this template."
|
||||
msgstr "Dieses Team ist bereits in diesem Template."
|
||||
|
||||
#: build/lib/core/models.py:1587 core/models.py:1587
|
||||
#: build/lib/core/models.py:1436 core/models.py:1436
|
||||
msgid "email address"
|
||||
msgstr "E-Mail-Adresse"
|
||||
|
||||
#: build/lib/core/models.py:1606 core/models.py:1606
|
||||
#: build/lib/core/models.py:1455 core/models.py:1455
|
||||
msgid "Document invitation"
|
||||
msgstr "Einladung zum Dokument"
|
||||
|
||||
#: build/lib/core/models.py:1607 core/models.py:1607
|
||||
#: build/lib/core/models.py:1456 core/models.py:1456
|
||||
msgid "Document invitations"
|
||||
msgstr "Dokumenteinladungen"
|
||||
|
||||
#: build/lib/core/models.py:1627 core/models.py:1627
|
||||
#: build/lib/core/models.py:1476 core/models.py:1476
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr "Diese E-Mail ist bereits einem registrierten Benutzer zugeordnet."
|
||||
|
||||
@@ -446,17 +396,12 @@ msgstr "Diese E-Mail ist bereits einem registrierten Benutzer zugeordnet."
|
||||
msgid "Logo email"
|
||||
msgstr "Logo-E-Mail"
|
||||
|
||||
#: core/templates/mail/html/template.html:200
|
||||
#: core/templates/mail/text/template.txt:10
|
||||
msgid "Open"
|
||||
msgstr "Öffnen"
|
||||
|
||||
#: core/templates/mail/html/template.html:217
|
||||
#: core/templates/mail/html/template.html:219
|
||||
#: core/templates/mail/text/template.txt:14
|
||||
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
|
||||
msgstr " Docs, Ihr neues unentbehrliches Werkzeug für die Organisation, den Austausch und die Zusammenarbeit in Ihren Dokumenten als Team. "
|
||||
|
||||
#: core/templates/mail/html/template.html:224
|
||||
#: core/templates/mail/html/template.html:226
|
||||
#: core/templates/mail/text/template.txt:16
|
||||
#, python-format
|
||||
msgid " Brought to you by %(brandname)s "
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-12-09 11:12\n"
|
||||
"POT-Creation-Date: 2026-01-21 09:53+0000\n"
|
||||
"PO-Revision-Date: 2026-01-28 20:12\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: English\n"
|
||||
"Language: en_US\n"
|
||||
@@ -17,20 +17,20 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 18\n"
|
||||
|
||||
#: build/lib/core/admin.py:36 core/admin.py:36
|
||||
#: build/lib/core/admin.py:28 core/admin.py:28
|
||||
msgid "Personal info"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
|
||||
#: core/admin.py:137
|
||||
#: build/lib/core/admin.py:41 build/lib/core/admin.py:121 core/admin.py:41
|
||||
#: core/admin.py:121
|
||||
msgid "Permissions"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/admin.py:61 core/admin.py:61
|
||||
#: build/lib/core/admin.py:53 core/admin.py:53
|
||||
msgid "Important dates"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/admin.py:147 core/admin.py:147
|
||||
#: build/lib/core/admin.py:131 core/admin.py:131
|
||||
msgid "Tree structure"
|
||||
msgstr ""
|
||||
|
||||
@@ -50,40 +50,32 @@ msgstr ""
|
||||
msgid "Favorite"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
|
||||
#: build/lib/core/api/serializers.py:505 core/api/serializers.py:505
|
||||
msgid "A new document was created on your behalf!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
|
||||
#: build/lib/core/api/serializers.py:509 core/api/serializers.py:509
|
||||
msgid "You have been granted ownership of a new document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
|
||||
#: build/lib/core/api/serializers.py:545 core/api/serializers.py:545
|
||||
msgid "This field is required."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
|
||||
#: build/lib/core/api/serializers.py:556 core/api/serializers.py:556
|
||||
#, python-format
|
||||
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:694 core/api/serializers.py:694
|
||||
msgid "Body"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:697 core/api/serializers.py:697
|
||||
msgid "Body type"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:703 core/api/serializers.py:703
|
||||
msgid "Format"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
|
||||
#: build/lib/core/api/viewsets.py:1122 core/api/viewsets.py:1122
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/apps.py:12 core/apps.py:12
|
||||
msgid "Impress core application"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
|
||||
#: core/choices.py:43
|
||||
msgid "Reader"
|
||||
@@ -143,301 +135,259 @@ msgstr ""
|
||||
msgid "Right"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:80 core/models.py:80
|
||||
#: build/lib/core/models.py:81 core/models.py:81
|
||||
msgid "id"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:81 core/models.py:81
|
||||
#: build/lib/core/models.py:82 core/models.py:82
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:87 core/models.py:87
|
||||
#: build/lib/core/models.py:88 core/models.py:88
|
||||
msgid "created on"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:88 core/models.py:88
|
||||
#: build/lib/core/models.py:89 core/models.py:89
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:93 core/models.py:93
|
||||
#: build/lib/core/models.py:94 core/models.py:94
|
||||
msgid "updated on"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:94 core/models.py:94
|
||||
#: build/lib/core/models.py:95 core/models.py:95
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:130 core/models.py:130
|
||||
#: build/lib/core/models.py:131 core/models.py:131
|
||||
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:141 core/models.py:141
|
||||
#: build/lib/core/models.py:142 core/models.py:142
|
||||
msgid "sub"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:142 core/models.py:142
|
||||
#: build/lib/core/models.py:143 core/models.py:143
|
||||
msgid "Required. 255 characters or fewer. ASCII characters only."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:150 core/models.py:150
|
||||
#: build/lib/core/models.py:151 core/models.py:151
|
||||
msgid "full name"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:152 core/models.py:152
|
||||
#: build/lib/core/models.py:153 core/models.py:153
|
||||
msgid "short name"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:155 core/models.py:155
|
||||
#: build/lib/core/models.py:156 core/models.py:156
|
||||
msgid "identity email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:160 core/models.py:160
|
||||
#: build/lib/core/models.py:161 core/models.py:161
|
||||
msgid "admin email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:167 core/models.py:167
|
||||
#: build/lib/core/models.py:168 core/models.py:168
|
||||
msgid "language"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:168 core/models.py:168
|
||||
#: build/lib/core/models.py:169 core/models.py:169
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:176 core/models.py:176
|
||||
#: build/lib/core/models.py:177 core/models.py:177
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:179 core/models.py:179
|
||||
#: build/lib/core/models.py:180 core/models.py:180
|
||||
msgid "device"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:181 core/models.py:181
|
||||
#: build/lib/core/models.py:182 core/models.py:182
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:184 core/models.py:184
|
||||
#: build/lib/core/models.py:185 core/models.py:185
|
||||
msgid "staff status"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:186 core/models.py:186
|
||||
#: build/lib/core/models.py:187 core/models.py:187
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:189 core/models.py:189
|
||||
#: build/lib/core/models.py:190 core/models.py:190
|
||||
msgid "active"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:192 core/models.py:192
|
||||
#: build/lib/core/models.py:193 core/models.py:193
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:204 core/models.py:204
|
||||
#: build/lib/core/models.py:205 core/models.py:205
|
||||
msgid "user"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:205 core/models.py:205
|
||||
#: build/lib/core/models.py:206 core/models.py:206
|
||||
msgid "users"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
|
||||
#: core/models.py:361 core/models.py:1430
|
||||
#: build/lib/core/models.py:362 core/models.py:362
|
||||
msgid "title"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:362 core/models.py:362
|
||||
#: build/lib/core/models.py:363 core/models.py:363
|
||||
msgid "excerpt"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:411 core/models.py:411
|
||||
#: build/lib/core/models.py:412 core/models.py:412
|
||||
msgid "Document"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:412 core/models.py:412
|
||||
#: build/lib/core/models.py:413 core/models.py:413
|
||||
msgid "Documents"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
|
||||
#: core/models.py:824
|
||||
#: build/lib/core/models.py:425 build/lib/core/models.py:828 core/models.py:425
|
||||
#: core/models.py:828
|
||||
msgid "Untitled Document"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#: build/lib/core/models.py:829 core/models.py:829
|
||||
msgid "Open"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:864 core/models.py:864
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:863 core/models.py:863
|
||||
#: build/lib/core/models.py:868 core/models.py:868
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:869 core/models.py:869
|
||||
#: build/lib/core/models.py:874 core/models.py:874
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:969 core/models.py:969
|
||||
#: build/lib/core/models.py:975 core/models.py:975
|
||||
msgid "Document/user link trace"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:970 core/models.py:970
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
msgid "Document/user link traces"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
#: build/lib/core/models.py:982 core/models.py:982
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:999 core/models.py:999
|
||||
#: build/lib/core/models.py:1005 core/models.py:1005
|
||||
msgid "Document favorite"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
msgid "Document favorites"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
#: build/lib/core/models.py:1012 core/models.py:1012
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1028 core/models.py:1028
|
||||
#: build/lib/core/models.py:1034 core/models.py:1034
|
||||
msgid "Document/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
msgid "Document/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
msgid "This user is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
#: build/lib/core/models.py:1047 core/models.py:1047
|
||||
msgid "This team is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
|
||||
#: core/models.py:1047 core/models.py:1516
|
||||
#: build/lib/core/models.py:1053 core/models.py:1053
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1204 core/models.py:1204
|
||||
msgid "Document ask for access"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1199 core/models.py:1199
|
||||
#: build/lib/core/models.py:1205 core/models.py:1205
|
||||
msgid "Document ask for accesses"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1205 core/models.py:1205
|
||||
#: build/lib/core/models.py:1211 core/models.py:1211
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1262 core/models.py:1262
|
||||
#: build/lib/core/models.py:1268 core/models.py:1268
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1266 core/models.py:1266
|
||||
#: build/lib/core/models.py:1272 core/models.py:1272
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1272 core/models.py:1272
|
||||
#: build/lib/core/models.py:1278 core/models.py:1278
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1314 core/models.py:1314
|
||||
#: build/lib/core/models.py:1320 core/models.py:1320
|
||||
msgid "Thread"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1315 core/models.py:1315
|
||||
#: build/lib/core/models.py:1321 core/models.py:1321
|
||||
msgid "Threads"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
|
||||
#: core/models.py:1318 core/models.py:1370
|
||||
#: build/lib/core/models.py:1324 build/lib/core/models.py:1376
|
||||
#: core/models.py:1324 core/models.py:1376
|
||||
msgid "Anonymous"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1365 core/models.py:1365
|
||||
#: build/lib/core/models.py:1371 core/models.py:1371
|
||||
msgid "Comment"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1366 core/models.py:1366
|
||||
#: build/lib/core/models.py:1372 core/models.py:1372
|
||||
msgid "Comments"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1415 core/models.py:1415
|
||||
#: build/lib/core/models.py:1421 core/models.py:1421
|
||||
msgid "This emoji has already been reacted to this comment."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1419 core/models.py:1419
|
||||
#: build/lib/core/models.py:1425 core/models.py:1425
|
||||
msgid "Reaction"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1420 core/models.py:1420
|
||||
#: build/lib/core/models.py:1426 core/models.py:1426
|
||||
msgid "Reactions"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1431 core/models.py:1431
|
||||
msgid "description"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1432 core/models.py:1432
|
||||
msgid "code"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
msgid "css"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
msgid "public"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1443 core/models.py:1443
|
||||
msgid "Template"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1444 core/models.py:1444
|
||||
msgid "Templates"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1497 core/models.py:1497
|
||||
msgid "Template/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1498 core/models.py:1498
|
||||
msgid "Template/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1504 core/models.py:1504
|
||||
msgid "This user is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1510 core/models.py:1510
|
||||
msgid "This team is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1587 core/models.py:1587
|
||||
#: build/lib/core/models.py:1436 core/models.py:1436
|
||||
msgid "email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1606 core/models.py:1606
|
||||
#: build/lib/core/models.py:1455 core/models.py:1455
|
||||
msgid "Document invitation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1607 core/models.py:1607
|
||||
#: build/lib/core/models.py:1456 core/models.py:1456
|
||||
msgid "Document invitations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1627 core/models.py:1627
|
||||
#: build/lib/core/models.py:1476 core/models.py:1476
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr ""
|
||||
|
||||
@@ -446,17 +396,12 @@ msgstr ""
|
||||
msgid "Logo email"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/template.html:200
|
||||
#: core/templates/mail/text/template.txt:10
|
||||
msgid "Open"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/template.html:217
|
||||
#: core/templates/mail/html/template.html:219
|
||||
#: core/templates/mail/text/template.txt:14
|
||||
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/template.html:224
|
||||
#: core/templates/mail/html/template.html:226
|
||||
#: core/templates/mail/text/template.txt:16
|
||||
#, python-format
|
||||
msgid " Brought to you by %(brandname)s "
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-12-09 11:12\n"
|
||||
"POT-Creation-Date: 2026-01-21 09:53+0000\n"
|
||||
"PO-Revision-Date: 2026-01-28 20:12\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Spanish\n"
|
||||
"Language: es_ES\n"
|
||||
@@ -17,20 +17,20 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 18\n"
|
||||
|
||||
#: build/lib/core/admin.py:36 core/admin.py:36
|
||||
#: build/lib/core/admin.py:28 core/admin.py:28
|
||||
msgid "Personal info"
|
||||
msgstr "Información Personal"
|
||||
|
||||
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
|
||||
#: core/admin.py:137
|
||||
#: build/lib/core/admin.py:41 build/lib/core/admin.py:121 core/admin.py:41
|
||||
#: core/admin.py:121
|
||||
msgid "Permissions"
|
||||
msgstr "Permisos"
|
||||
|
||||
#: build/lib/core/admin.py:61 core/admin.py:61
|
||||
#: build/lib/core/admin.py:53 core/admin.py:53
|
||||
msgid "Important dates"
|
||||
msgstr "Fechas importantes"
|
||||
|
||||
#: build/lib/core/admin.py:147 core/admin.py:147
|
||||
#: build/lib/core/admin.py:131 core/admin.py:131
|
||||
msgid "Tree structure"
|
||||
msgstr "Estructura en árbol"
|
||||
|
||||
@@ -50,40 +50,32 @@ msgstr ""
|
||||
msgid "Favorite"
|
||||
msgstr "Favorito"
|
||||
|
||||
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
|
||||
#: build/lib/core/api/serializers.py:505 core/api/serializers.py:505
|
||||
msgid "A new document was created on your behalf!"
|
||||
msgstr "¡Un nuevo documento se ha creado por ti!"
|
||||
|
||||
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
|
||||
#: build/lib/core/api/serializers.py:509 core/api/serializers.py:509
|
||||
msgid "You have been granted ownership of a new document:"
|
||||
msgstr "Se le ha concedido la propiedad de un nuevo documento :"
|
||||
|
||||
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
|
||||
#: build/lib/core/api/serializers.py:545 core/api/serializers.py:545
|
||||
msgid "This field is required."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
|
||||
#: build/lib/core/api/serializers.py:556 core/api/serializers.py:556
|
||||
#, python-format
|
||||
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:694 core/api/serializers.py:694
|
||||
msgid "Body"
|
||||
msgstr "Cuerpo"
|
||||
|
||||
#: build/lib/core/api/serializers.py:697 core/api/serializers.py:697
|
||||
msgid "Body type"
|
||||
msgstr "Tipo de Cuerpo"
|
||||
|
||||
#: build/lib/core/api/serializers.py:703 core/api/serializers.py:703
|
||||
msgid "Format"
|
||||
msgstr "Formato"
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
|
||||
#: build/lib/core/api/viewsets.py:1122 core/api/viewsets.py:1122
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "copia de {title}"
|
||||
|
||||
#: build/lib/core/apps.py:12 core/apps.py:12
|
||||
msgid "Impress core application"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
|
||||
#: core/choices.py:43
|
||||
msgid "Reader"
|
||||
@@ -143,301 +135,259 @@ msgstr "Izquierda"
|
||||
msgid "Right"
|
||||
msgstr "Derecha"
|
||||
|
||||
#: build/lib/core/models.py:80 core/models.py:80
|
||||
#: build/lib/core/models.py:81 core/models.py:81
|
||||
msgid "id"
|
||||
msgstr "id"
|
||||
|
||||
#: build/lib/core/models.py:81 core/models.py:81
|
||||
#: build/lib/core/models.py:82 core/models.py:82
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr "clave primaria para el registro como UUID"
|
||||
|
||||
#: build/lib/core/models.py:87 core/models.py:87
|
||||
#: build/lib/core/models.py:88 core/models.py:88
|
||||
msgid "created on"
|
||||
msgstr "creado el"
|
||||
|
||||
#: build/lib/core/models.py:88 core/models.py:88
|
||||
#: build/lib/core/models.py:89 core/models.py:89
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr "fecha y hora en la que se creó un registro"
|
||||
|
||||
#: build/lib/core/models.py:93 core/models.py:93
|
||||
#: build/lib/core/models.py:94 core/models.py:94
|
||||
msgid "updated on"
|
||||
msgstr "actualizado el"
|
||||
|
||||
#: build/lib/core/models.py:94 core/models.py:94
|
||||
#: build/lib/core/models.py:95 core/models.py:95
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr "fecha y hora en la que un registro fue actualizado por última vez"
|
||||
|
||||
#: build/lib/core/models.py:130 core/models.py:130
|
||||
#: build/lib/core/models.py:131 core/models.py:131
|
||||
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
|
||||
msgstr "No se ha podido encontrar un usuario con este sub (UUID), pero el correo electrónico ya está asociado con un usuario."
|
||||
|
||||
#: build/lib/core/models.py:141 core/models.py:141
|
||||
#: build/lib/core/models.py:142 core/models.py:142
|
||||
msgid "sub"
|
||||
msgstr "sub (UUID)"
|
||||
|
||||
#: build/lib/core/models.py:142 core/models.py:142
|
||||
#: build/lib/core/models.py:143 core/models.py:143
|
||||
msgid "Required. 255 characters or fewer. ASCII characters only."
|
||||
msgstr "Obligatorio. 255 caracteres o menos. Solo caracteres ASCII."
|
||||
|
||||
#: build/lib/core/models.py:150 core/models.py:150
|
||||
#: build/lib/core/models.py:151 core/models.py:151
|
||||
msgid "full name"
|
||||
msgstr "nombre completo"
|
||||
|
||||
#: build/lib/core/models.py:152 core/models.py:152
|
||||
#: build/lib/core/models.py:153 core/models.py:153
|
||||
msgid "short name"
|
||||
msgstr "nombre abreviado"
|
||||
|
||||
#: build/lib/core/models.py:155 core/models.py:155
|
||||
#: build/lib/core/models.py:156 core/models.py:156
|
||||
msgid "identity email address"
|
||||
msgstr "correo electrónico de identidad"
|
||||
|
||||
#: build/lib/core/models.py:160 core/models.py:160
|
||||
#: build/lib/core/models.py:161 core/models.py:161
|
||||
msgid "admin email address"
|
||||
msgstr "correo electrónico del administrador"
|
||||
|
||||
#: build/lib/core/models.py:167 core/models.py:167
|
||||
#: build/lib/core/models.py:168 core/models.py:168
|
||||
msgid "language"
|
||||
msgstr "idioma"
|
||||
|
||||
#: build/lib/core/models.py:168 core/models.py:168
|
||||
#: build/lib/core/models.py:169 core/models.py:169
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr "El idioma en el que el usuario desea ver la interfaz."
|
||||
|
||||
#: build/lib/core/models.py:176 core/models.py:176
|
||||
#: build/lib/core/models.py:177 core/models.py:177
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr "La zona horaria en la que el usuario quiere ver los tiempos."
|
||||
|
||||
#: build/lib/core/models.py:179 core/models.py:179
|
||||
#: build/lib/core/models.py:180 core/models.py:180
|
||||
msgid "device"
|
||||
msgstr "dispositivo"
|
||||
|
||||
#: build/lib/core/models.py:181 core/models.py:181
|
||||
#: build/lib/core/models.py:182 core/models.py:182
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr "Si el usuario es un dispositivo o un usuario real."
|
||||
|
||||
#: build/lib/core/models.py:184 core/models.py:184
|
||||
#: build/lib/core/models.py:185 core/models.py:185
|
||||
msgid "staff status"
|
||||
msgstr "rol en el equipo"
|
||||
|
||||
#: build/lib/core/models.py:186 core/models.py:186
|
||||
#: build/lib/core/models.py:187 core/models.py:187
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Si el usuario puede iniciar sesión en esta página web de administración."
|
||||
|
||||
#: build/lib/core/models.py:189 core/models.py:189
|
||||
#: build/lib/core/models.py:190 core/models.py:190
|
||||
msgid "active"
|
||||
msgstr "activo"
|
||||
|
||||
#: build/lib/core/models.py:192 core/models.py:192
|
||||
#: build/lib/core/models.py:193 core/models.py:193
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr "Si este usuario debe ser considerado como activo. Deseleccionar en lugar de eliminar cuentas."
|
||||
|
||||
#: build/lib/core/models.py:204 core/models.py:204
|
||||
#: build/lib/core/models.py:205 core/models.py:205
|
||||
msgid "user"
|
||||
msgstr "usuario"
|
||||
|
||||
#: build/lib/core/models.py:205 core/models.py:205
|
||||
#: build/lib/core/models.py:206 core/models.py:206
|
||||
msgid "users"
|
||||
msgstr "usuarios"
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
|
||||
#: core/models.py:361 core/models.py:1430
|
||||
#: build/lib/core/models.py:362 core/models.py:362
|
||||
msgid "title"
|
||||
msgstr "título"
|
||||
|
||||
#: build/lib/core/models.py:362 core/models.py:362
|
||||
#: build/lib/core/models.py:363 core/models.py:363
|
||||
msgid "excerpt"
|
||||
msgstr "resumen"
|
||||
|
||||
#: build/lib/core/models.py:411 core/models.py:411
|
||||
#: build/lib/core/models.py:412 core/models.py:412
|
||||
msgid "Document"
|
||||
msgstr "Documento"
|
||||
|
||||
#: build/lib/core/models.py:412 core/models.py:412
|
||||
#: build/lib/core/models.py:413 core/models.py:413
|
||||
msgid "Documents"
|
||||
msgstr "Documentos"
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
|
||||
#: core/models.py:824
|
||||
#: build/lib/core/models.py:425 build/lib/core/models.py:828 core/models.py:425
|
||||
#: core/models.py:828
|
||||
msgid "Untitled Document"
|
||||
msgstr "Documento sin título"
|
||||
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#: build/lib/core/models.py:829 core/models.py:829
|
||||
msgid "Open"
|
||||
msgstr "Abrir"
|
||||
|
||||
#: build/lib/core/models.py:864 core/models.py:864
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "¡{name} ha compartido un documento contigo!"
|
||||
|
||||
#: build/lib/core/models.py:863 core/models.py:863
|
||||
#: build/lib/core/models.py:868 core/models.py:868
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr "Te ha invitado {name} al siguiente documento con el rol \"{role}\" :"
|
||||
|
||||
#: build/lib/core/models.py:869 core/models.py:869
|
||||
#: build/lib/core/models.py:874 core/models.py:874
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr "{name} ha compartido un documento contigo: {title}"
|
||||
|
||||
#: build/lib/core/models.py:969 core/models.py:969
|
||||
#: build/lib/core/models.py:975 core/models.py:975
|
||||
msgid "Document/user link trace"
|
||||
msgstr "Traza del enlace de documento/usuario"
|
||||
|
||||
#: build/lib/core/models.py:970 core/models.py:970
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
msgid "Document/user link traces"
|
||||
msgstr "Trazas del enlace de documento/usuario"
|
||||
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
#: build/lib/core/models.py:982 core/models.py:982
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr "Ya existe una traza de enlace para este documento/usuario."
|
||||
|
||||
#: build/lib/core/models.py:999 core/models.py:999
|
||||
#: build/lib/core/models.py:1005 core/models.py:1005
|
||||
msgid "Document favorite"
|
||||
msgstr "Documento favorito"
|
||||
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
msgid "Document favorites"
|
||||
msgstr "Documentos favoritos"
|
||||
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
#: build/lib/core/models.py:1012 core/models.py:1012
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr "Este documento ya ha sido marcado como favorito por el usuario."
|
||||
|
||||
#: build/lib/core/models.py:1028 core/models.py:1028
|
||||
#: build/lib/core/models.py:1034 core/models.py:1034
|
||||
msgid "Document/user relation"
|
||||
msgstr "Relación documento/usuario"
|
||||
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
msgid "Document/user relations"
|
||||
msgstr "Relaciones documento/usuario"
|
||||
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
msgid "This user is already in this document."
|
||||
msgstr "Este usuario ya forma parte del documento."
|
||||
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
#: build/lib/core/models.py:1047 core/models.py:1047
|
||||
msgid "This team is already in this document."
|
||||
msgstr "Este equipo ya forma parte del documento."
|
||||
|
||||
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
|
||||
#: core/models.py:1047 core/models.py:1516
|
||||
#: build/lib/core/models.py:1053 core/models.py:1053
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Debe establecerse un usuario o un equipo, no ambos."
|
||||
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1204 core/models.py:1204
|
||||
msgid "Document ask for access"
|
||||
msgstr "Solicitud de acceso"
|
||||
|
||||
#: build/lib/core/models.py:1199 core/models.py:1199
|
||||
#: build/lib/core/models.py:1205 core/models.py:1205
|
||||
msgid "Document ask for accesses"
|
||||
msgstr "Solicitud de accesos"
|
||||
|
||||
#: build/lib/core/models.py:1205 core/models.py:1205
|
||||
#: build/lib/core/models.py:1211 core/models.py:1211
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr "Este usuario ya ha solicitado acceso a este documento."
|
||||
|
||||
#: build/lib/core/models.py:1262 core/models.py:1262
|
||||
#: build/lib/core/models.py:1268 core/models.py:1268
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr "¡{name} desea acceder a un documento!"
|
||||
|
||||
#: build/lib/core/models.py:1266 core/models.py:1266
|
||||
#: build/lib/core/models.py:1272 core/models.py:1272
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr "{name} desea acceso al siguiente documento:"
|
||||
|
||||
#: build/lib/core/models.py:1272 core/models.py:1272
|
||||
#: build/lib/core/models.py:1278 core/models.py:1278
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr "{name} está pidiendo acceso al documento: {title}"
|
||||
|
||||
#: build/lib/core/models.py:1314 core/models.py:1314
|
||||
#: build/lib/core/models.py:1320 core/models.py:1320
|
||||
msgid "Thread"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1315 core/models.py:1315
|
||||
#: build/lib/core/models.py:1321 core/models.py:1321
|
||||
msgid "Threads"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
|
||||
#: core/models.py:1318 core/models.py:1370
|
||||
#: build/lib/core/models.py:1324 build/lib/core/models.py:1376
|
||||
#: core/models.py:1324 core/models.py:1376
|
||||
msgid "Anonymous"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1365 core/models.py:1365
|
||||
#: build/lib/core/models.py:1371 core/models.py:1371
|
||||
msgid "Comment"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1366 core/models.py:1366
|
||||
#: build/lib/core/models.py:1372 core/models.py:1372
|
||||
msgid "Comments"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1415 core/models.py:1415
|
||||
#: build/lib/core/models.py:1421 core/models.py:1421
|
||||
msgid "This emoji has already been reacted to this comment."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1419 core/models.py:1419
|
||||
#: build/lib/core/models.py:1425 core/models.py:1425
|
||||
msgid "Reaction"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1420 core/models.py:1420
|
||||
#: build/lib/core/models.py:1426 core/models.py:1426
|
||||
msgid "Reactions"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1431 core/models.py:1431
|
||||
msgid "description"
|
||||
msgstr "descripción"
|
||||
|
||||
#: build/lib/core/models.py:1432 core/models.py:1432
|
||||
msgid "code"
|
||||
msgstr "código"
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
msgid "css"
|
||||
msgstr "css"
|
||||
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
msgid "public"
|
||||
msgstr "público"
|
||||
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr "Si esta plantilla es pública para que cualquiera la utilice."
|
||||
|
||||
#: build/lib/core/models.py:1443 core/models.py:1443
|
||||
msgid "Template"
|
||||
msgstr "Plantilla"
|
||||
|
||||
#: build/lib/core/models.py:1444 core/models.py:1444
|
||||
msgid "Templates"
|
||||
msgstr "Plantillas"
|
||||
|
||||
#: build/lib/core/models.py:1497 core/models.py:1497
|
||||
msgid "Template/user relation"
|
||||
msgstr "Relación plantilla/usuario"
|
||||
|
||||
#: build/lib/core/models.py:1498 core/models.py:1498
|
||||
msgid "Template/user relations"
|
||||
msgstr "Relaciones plantilla/usuario"
|
||||
|
||||
#: build/lib/core/models.py:1504 core/models.py:1504
|
||||
msgid "This user is already in this template."
|
||||
msgstr "Este usuario ya forma parte de la plantilla."
|
||||
|
||||
#: build/lib/core/models.py:1510 core/models.py:1510
|
||||
msgid "This team is already in this template."
|
||||
msgstr "Este equipo ya se encuentra en esta plantilla."
|
||||
|
||||
#: build/lib/core/models.py:1587 core/models.py:1587
|
||||
#: build/lib/core/models.py:1436 core/models.py:1436
|
||||
msgid "email address"
|
||||
msgstr "dirección de correo electrónico"
|
||||
|
||||
#: build/lib/core/models.py:1606 core/models.py:1606
|
||||
#: build/lib/core/models.py:1455 core/models.py:1455
|
||||
msgid "Document invitation"
|
||||
msgstr "Invitación al documento"
|
||||
|
||||
#: build/lib/core/models.py:1607 core/models.py:1607
|
||||
#: build/lib/core/models.py:1456 core/models.py:1456
|
||||
msgid "Document invitations"
|
||||
msgstr "Invitaciones a documentos"
|
||||
|
||||
#: build/lib/core/models.py:1627 core/models.py:1627
|
||||
#: build/lib/core/models.py:1476 core/models.py:1476
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr "Este correo electrónico está asociado a un usuario registrado."
|
||||
|
||||
@@ -446,17 +396,12 @@ msgstr "Este correo electrónico está asociado a un usuario registrado."
|
||||
msgid "Logo email"
|
||||
msgstr "Logo de correo electrónico"
|
||||
|
||||
#: core/templates/mail/html/template.html:200
|
||||
#: core/templates/mail/text/template.txt:10
|
||||
msgid "Open"
|
||||
msgstr "Abrir"
|
||||
|
||||
#: core/templates/mail/html/template.html:217
|
||||
#: core/templates/mail/html/template.html:219
|
||||
#: core/templates/mail/text/template.txt:14
|
||||
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
|
||||
msgstr "Docs, su nueva herramienta esencial para organizar, compartir y colaborar en sus documentos como equipo."
|
||||
|
||||
#: core/templates/mail/html/template.html:224
|
||||
#: core/templates/mail/html/template.html:226
|
||||
#: core/templates/mail/text/template.txt:16
|
||||
#, python-format
|
||||
msgid " Brought to you by %(brandname)s "
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-12-09 11:12\n"
|
||||
"POT-Creation-Date: 2026-01-21 09:53+0000\n"
|
||||
"PO-Revision-Date: 2026-01-28 20:12\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: French\n"
|
||||
"Language: fr_FR\n"
|
||||
@@ -17,20 +17,20 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 18\n"
|
||||
|
||||
#: build/lib/core/admin.py:36 core/admin.py:36
|
||||
#: build/lib/core/admin.py:28 core/admin.py:28
|
||||
msgid "Personal info"
|
||||
msgstr "Infos Personnelles"
|
||||
|
||||
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
|
||||
#: core/admin.py:137
|
||||
#: build/lib/core/admin.py:41 build/lib/core/admin.py:121 core/admin.py:41
|
||||
#: core/admin.py:121
|
||||
msgid "Permissions"
|
||||
msgstr "Permissions"
|
||||
|
||||
#: build/lib/core/admin.py:61 core/admin.py:61
|
||||
#: build/lib/core/admin.py:53 core/admin.py:53
|
||||
msgid "Important dates"
|
||||
msgstr "Dates importantes"
|
||||
|
||||
#: build/lib/core/admin.py:147 core/admin.py:147
|
||||
#: build/lib/core/admin.py:131 core/admin.py:131
|
||||
msgid "Tree structure"
|
||||
msgstr "Arborescence"
|
||||
|
||||
@@ -50,40 +50,32 @@ msgstr "Masqué"
|
||||
msgid "Favorite"
|
||||
msgstr "Favoris"
|
||||
|
||||
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
|
||||
#: build/lib/core/api/serializers.py:505 core/api/serializers.py:505
|
||||
msgid "A new document was created on your behalf!"
|
||||
msgstr "Un nouveau document a été créé pour vous !"
|
||||
|
||||
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
|
||||
#: build/lib/core/api/serializers.py:509 core/api/serializers.py:509
|
||||
msgid "You have been granted ownership of a new document:"
|
||||
msgstr "Vous avez été déclaré propriétaire d'un nouveau document :"
|
||||
|
||||
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
|
||||
#: build/lib/core/api/serializers.py:545 core/api/serializers.py:545
|
||||
msgid "This field is required."
|
||||
msgstr "Ce champ est obligatoire."
|
||||
|
||||
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
|
||||
#: build/lib/core/api/serializers.py:556 core/api/serializers.py:556
|
||||
#, python-format
|
||||
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
|
||||
msgstr "La portée du lien '%(link_reach)s' n'est pas autorisée en fonction de la configuration du document parent."
|
||||
|
||||
#: build/lib/core/api/serializers.py:694 core/api/serializers.py:694
|
||||
msgid "Body"
|
||||
msgstr "Corps"
|
||||
|
||||
#: build/lib/core/api/serializers.py:697 core/api/serializers.py:697
|
||||
msgid "Body type"
|
||||
msgstr "Type de corps"
|
||||
|
||||
#: build/lib/core/api/serializers.py:703 core/api/serializers.py:703
|
||||
msgid "Format"
|
||||
msgstr "Format"
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
|
||||
#: build/lib/core/api/viewsets.py:1122 core/api/viewsets.py:1122
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "copie de {title}"
|
||||
|
||||
#: build/lib/core/apps.py:12 core/apps.py:12
|
||||
msgid "Impress core application"
|
||||
msgstr "Noyau d'application Impress"
|
||||
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
|
||||
#: core/choices.py:43
|
||||
msgid "Reader"
|
||||
@@ -143,301 +135,259 @@ msgstr "Gauche"
|
||||
msgid "Right"
|
||||
msgstr "Droite"
|
||||
|
||||
#: build/lib/core/models.py:80 core/models.py:80
|
||||
#: build/lib/core/models.py:81 core/models.py:81
|
||||
msgid "id"
|
||||
msgstr "identifiant/id"
|
||||
|
||||
#: build/lib/core/models.py:81 core/models.py:81
|
||||
#: build/lib/core/models.py:82 core/models.py:82
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr "clé primaire pour l'enregistrement en tant que UUID"
|
||||
|
||||
#: build/lib/core/models.py:87 core/models.py:87
|
||||
#: build/lib/core/models.py:88 core/models.py:88
|
||||
msgid "created on"
|
||||
msgstr "créé le"
|
||||
|
||||
#: build/lib/core/models.py:88 core/models.py:88
|
||||
#: build/lib/core/models.py:89 core/models.py:89
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr "date et heure de création de l'enregistrement"
|
||||
|
||||
#: build/lib/core/models.py:93 core/models.py:93
|
||||
#: build/lib/core/models.py:94 core/models.py:94
|
||||
msgid "updated on"
|
||||
msgstr "mis à jour le"
|
||||
|
||||
#: build/lib/core/models.py:94 core/models.py:94
|
||||
#: build/lib/core/models.py:95 core/models.py:95
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr "date et heure de la dernière mise à jour de l'enregistrement"
|
||||
|
||||
#: build/lib/core/models.py:130 core/models.py:130
|
||||
#: build/lib/core/models.py:131 core/models.py:131
|
||||
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
|
||||
msgstr "Nous n'avons pas pu trouver un utilisateur avec ce sous-groupe mais l'e-mail est déjà associé à un utilisateur enregistré."
|
||||
|
||||
#: build/lib/core/models.py:141 core/models.py:141
|
||||
#: build/lib/core/models.py:142 core/models.py:142
|
||||
msgid "sub"
|
||||
msgstr "sous-groupe"
|
||||
|
||||
#: build/lib/core/models.py:142 core/models.py:142
|
||||
#: build/lib/core/models.py:143 core/models.py:143
|
||||
msgid "Required. 255 characters or fewer. ASCII characters only."
|
||||
msgstr "Obligatoire. 255 caractères ou moins. Caractères ASCII uniquement."
|
||||
|
||||
#: build/lib/core/models.py:150 core/models.py:150
|
||||
#: build/lib/core/models.py:151 core/models.py:151
|
||||
msgid "full name"
|
||||
msgstr "nom complet"
|
||||
|
||||
#: build/lib/core/models.py:152 core/models.py:152
|
||||
#: build/lib/core/models.py:153 core/models.py:153
|
||||
msgid "short name"
|
||||
msgstr "nom court"
|
||||
|
||||
#: build/lib/core/models.py:155 core/models.py:155
|
||||
#: build/lib/core/models.py:156 core/models.py:156
|
||||
msgid "identity email address"
|
||||
msgstr "adresse e-mail d'identité"
|
||||
|
||||
#: build/lib/core/models.py:160 core/models.py:160
|
||||
#: build/lib/core/models.py:161 core/models.py:161
|
||||
msgid "admin email address"
|
||||
msgstr "adresse e-mail de l'administrateur"
|
||||
|
||||
#: build/lib/core/models.py:167 core/models.py:167
|
||||
#: build/lib/core/models.py:168 core/models.py:168
|
||||
msgid "language"
|
||||
msgstr "langue"
|
||||
|
||||
#: build/lib/core/models.py:168 core/models.py:168
|
||||
#: build/lib/core/models.py:169 core/models.py:169
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr "La langue dans laquelle l'utilisateur veut voir l'interface."
|
||||
|
||||
#: build/lib/core/models.py:176 core/models.py:176
|
||||
#: build/lib/core/models.py:177 core/models.py:177
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures."
|
||||
|
||||
#: build/lib/core/models.py:179 core/models.py:179
|
||||
#: build/lib/core/models.py:180 core/models.py:180
|
||||
msgid "device"
|
||||
msgstr "appareil"
|
||||
|
||||
#: build/lib/core/models.py:181 core/models.py:181
|
||||
#: build/lib/core/models.py:182 core/models.py:182
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr "Si l'utilisateur est un appareil ou un utilisateur réel."
|
||||
|
||||
#: build/lib/core/models.py:184 core/models.py:184
|
||||
#: build/lib/core/models.py:185 core/models.py:185
|
||||
msgid "staff status"
|
||||
msgstr "statut d'équipe"
|
||||
|
||||
#: build/lib/core/models.py:186 core/models.py:186
|
||||
#: build/lib/core/models.py:187 core/models.py:187
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Si l'utilisateur peut se connecter à ce site d'administration."
|
||||
|
||||
#: build/lib/core/models.py:189 core/models.py:189
|
||||
#: build/lib/core/models.py:190 core/models.py:190
|
||||
msgid "active"
|
||||
msgstr "actif"
|
||||
|
||||
#: build/lib/core/models.py:192 core/models.py:192
|
||||
#: build/lib/core/models.py:193 core/models.py:193
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr "Si cet utilisateur doit être traité comme actif. Désélectionnez ceci au lieu de supprimer des comptes."
|
||||
|
||||
#: build/lib/core/models.py:204 core/models.py:204
|
||||
#: build/lib/core/models.py:205 core/models.py:205
|
||||
msgid "user"
|
||||
msgstr "utilisateur"
|
||||
|
||||
#: build/lib/core/models.py:205 core/models.py:205
|
||||
#: build/lib/core/models.py:206 core/models.py:206
|
||||
msgid "users"
|
||||
msgstr "utilisateurs"
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
|
||||
#: core/models.py:361 core/models.py:1430
|
||||
#: build/lib/core/models.py:362 core/models.py:362
|
||||
msgid "title"
|
||||
msgstr "titre"
|
||||
|
||||
#: build/lib/core/models.py:362 core/models.py:362
|
||||
#: build/lib/core/models.py:363 core/models.py:363
|
||||
msgid "excerpt"
|
||||
msgstr "extrait"
|
||||
|
||||
#: build/lib/core/models.py:411 core/models.py:411
|
||||
#: build/lib/core/models.py:412 core/models.py:412
|
||||
msgid "Document"
|
||||
msgstr "Document"
|
||||
|
||||
#: build/lib/core/models.py:412 core/models.py:412
|
||||
#: build/lib/core/models.py:413 core/models.py:413
|
||||
msgid "Documents"
|
||||
msgstr "Documents"
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
|
||||
#: core/models.py:824
|
||||
#: build/lib/core/models.py:425 build/lib/core/models.py:828 core/models.py:425
|
||||
#: core/models.py:828
|
||||
msgid "Untitled Document"
|
||||
msgstr "Document sans titre"
|
||||
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#: build/lib/core/models.py:829 core/models.py:829
|
||||
msgid "Open"
|
||||
msgstr "Ouvrir"
|
||||
|
||||
#: build/lib/core/models.py:864 core/models.py:864
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "{name} a partagé un document avec vous!"
|
||||
|
||||
#: build/lib/core/models.py:863 core/models.py:863
|
||||
#: build/lib/core/models.py:868 core/models.py:868
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr "{name} vous a invité avec le rôle \"{role}\" sur le document suivant :"
|
||||
|
||||
#: build/lib/core/models.py:869 core/models.py:869
|
||||
#: build/lib/core/models.py:874 core/models.py:874
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr "{name} a partagé un document avec vous : {title}"
|
||||
|
||||
#: build/lib/core/models.py:969 core/models.py:969
|
||||
#: build/lib/core/models.py:975 core/models.py:975
|
||||
msgid "Document/user link trace"
|
||||
msgstr "Trace du lien document/utilisateur"
|
||||
|
||||
#: build/lib/core/models.py:970 core/models.py:970
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
msgid "Document/user link traces"
|
||||
msgstr "Traces du lien document/utilisateur"
|
||||
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
#: build/lib/core/models.py:982 core/models.py:982
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr "Une trace de lien existe déjà pour ce document/utilisateur."
|
||||
|
||||
#: build/lib/core/models.py:999 core/models.py:999
|
||||
#: build/lib/core/models.py:1005 core/models.py:1005
|
||||
msgid "Document favorite"
|
||||
msgstr "Document favori"
|
||||
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
msgid "Document favorites"
|
||||
msgstr "Documents favoris"
|
||||
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
#: build/lib/core/models.py:1012 core/models.py:1012
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr "Ce document est déjà un favori de cet utilisateur."
|
||||
|
||||
#: build/lib/core/models.py:1028 core/models.py:1028
|
||||
#: build/lib/core/models.py:1034 core/models.py:1034
|
||||
msgid "Document/user relation"
|
||||
msgstr "Relation document/utilisateur"
|
||||
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
msgid "Document/user relations"
|
||||
msgstr "Relations document/utilisateur"
|
||||
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
msgid "This user is already in this document."
|
||||
msgstr "Cet utilisateur est déjà dans ce document."
|
||||
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
#: build/lib/core/models.py:1047 core/models.py:1047
|
||||
msgid "This team is already in this document."
|
||||
msgstr "Cette équipe est déjà dans ce document."
|
||||
|
||||
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
|
||||
#: core/models.py:1047 core/models.py:1516
|
||||
#: build/lib/core/models.py:1053 core/models.py:1053
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "L'utilisateur ou l'équipe doivent être définis, pas les deux."
|
||||
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1204 core/models.py:1204
|
||||
msgid "Document ask for access"
|
||||
msgstr "Demande d'accès au document"
|
||||
|
||||
#: build/lib/core/models.py:1199 core/models.py:1199
|
||||
#: build/lib/core/models.py:1205 core/models.py:1205
|
||||
msgid "Document ask for accesses"
|
||||
msgstr "Demande d'accès au document"
|
||||
|
||||
#: build/lib/core/models.py:1205 core/models.py:1205
|
||||
#: build/lib/core/models.py:1211 core/models.py:1211
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr "Cet utilisateur a déjà demandé l'accès à ce document."
|
||||
|
||||
#: build/lib/core/models.py:1262 core/models.py:1262
|
||||
#: build/lib/core/models.py:1268 core/models.py:1268
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr "{name} souhaiterait accéder au document suivant !"
|
||||
|
||||
#: build/lib/core/models.py:1266 core/models.py:1266
|
||||
#: build/lib/core/models.py:1272 core/models.py:1272
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr "{name} souhaiterait accéder au document suivant :"
|
||||
|
||||
#: build/lib/core/models.py:1272 core/models.py:1272
|
||||
#: build/lib/core/models.py:1278 core/models.py:1278
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr "{name} demande l'accès au document : {title}"
|
||||
|
||||
#: build/lib/core/models.py:1314 core/models.py:1314
|
||||
#: build/lib/core/models.py:1320 core/models.py:1320
|
||||
msgid "Thread"
|
||||
msgstr "Conversation"
|
||||
|
||||
#: build/lib/core/models.py:1315 core/models.py:1315
|
||||
#: build/lib/core/models.py:1321 core/models.py:1321
|
||||
msgid "Threads"
|
||||
msgstr "Conversations"
|
||||
|
||||
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
|
||||
#: core/models.py:1318 core/models.py:1370
|
||||
#: build/lib/core/models.py:1324 build/lib/core/models.py:1376
|
||||
#: core/models.py:1324 core/models.py:1376
|
||||
msgid "Anonymous"
|
||||
msgstr "Anonyme"
|
||||
|
||||
#: build/lib/core/models.py:1365 core/models.py:1365
|
||||
#: build/lib/core/models.py:1371 core/models.py:1371
|
||||
msgid "Comment"
|
||||
msgstr "Commentaire"
|
||||
|
||||
#: build/lib/core/models.py:1366 core/models.py:1366
|
||||
#: build/lib/core/models.py:1372 core/models.py:1372
|
||||
msgid "Comments"
|
||||
msgstr "Commentaires"
|
||||
|
||||
#: build/lib/core/models.py:1415 core/models.py:1415
|
||||
#: build/lib/core/models.py:1421 core/models.py:1421
|
||||
msgid "This emoji has already been reacted to this comment."
|
||||
msgstr "Cet émoji a déjà été réagi à ce commentaire."
|
||||
|
||||
#: build/lib/core/models.py:1419 core/models.py:1419
|
||||
#: build/lib/core/models.py:1425 core/models.py:1425
|
||||
msgid "Reaction"
|
||||
msgstr "Réaction"
|
||||
|
||||
#: build/lib/core/models.py:1420 core/models.py:1420
|
||||
#: build/lib/core/models.py:1426 core/models.py:1426
|
||||
msgid "Reactions"
|
||||
msgstr "Réactions"
|
||||
|
||||
#: build/lib/core/models.py:1431 core/models.py:1431
|
||||
msgid "description"
|
||||
msgstr "description"
|
||||
|
||||
#: build/lib/core/models.py:1432 core/models.py:1432
|
||||
msgid "code"
|
||||
msgstr "code"
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
msgid "css"
|
||||
msgstr "CSS"
|
||||
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
msgid "public"
|
||||
msgstr "public"
|
||||
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr "Si ce modèle est public, utilisable par n'importe qui."
|
||||
|
||||
#: build/lib/core/models.py:1443 core/models.py:1443
|
||||
msgid "Template"
|
||||
msgstr "Modèle"
|
||||
|
||||
#: build/lib/core/models.py:1444 core/models.py:1444
|
||||
msgid "Templates"
|
||||
msgstr "Modèles"
|
||||
|
||||
#: build/lib/core/models.py:1497 core/models.py:1497
|
||||
msgid "Template/user relation"
|
||||
msgstr "Relation modèle/utilisateur"
|
||||
|
||||
#: build/lib/core/models.py:1498 core/models.py:1498
|
||||
msgid "Template/user relations"
|
||||
msgstr "Relations modèle/utilisateur"
|
||||
|
||||
#: build/lib/core/models.py:1504 core/models.py:1504
|
||||
msgid "This user is already in this template."
|
||||
msgstr "Cet utilisateur est déjà dans ce modèle."
|
||||
|
||||
#: build/lib/core/models.py:1510 core/models.py:1510
|
||||
msgid "This team is already in this template."
|
||||
msgstr "Cette équipe est déjà modèle."
|
||||
|
||||
#: build/lib/core/models.py:1587 core/models.py:1587
|
||||
#: build/lib/core/models.py:1436 core/models.py:1436
|
||||
msgid "email address"
|
||||
msgstr "adresse e-mail"
|
||||
|
||||
#: build/lib/core/models.py:1606 core/models.py:1606
|
||||
#: build/lib/core/models.py:1455 core/models.py:1455
|
||||
msgid "Document invitation"
|
||||
msgstr "Invitation à un document"
|
||||
|
||||
#: build/lib/core/models.py:1607 core/models.py:1607
|
||||
#: build/lib/core/models.py:1456 core/models.py:1456
|
||||
msgid "Document invitations"
|
||||
msgstr "Invitations à un document"
|
||||
|
||||
#: build/lib/core/models.py:1627 core/models.py:1627
|
||||
#: build/lib/core/models.py:1476 core/models.py:1476
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr "Cette adresse email est déjà associée à un utilisateur inscrit."
|
||||
|
||||
@@ -446,17 +396,12 @@ msgstr "Cette adresse email est déjà associée à un utilisateur inscrit."
|
||||
msgid "Logo email"
|
||||
msgstr "Logo de l'e-mail"
|
||||
|
||||
#: core/templates/mail/html/template.html:200
|
||||
#: core/templates/mail/text/template.txt:10
|
||||
msgid "Open"
|
||||
msgstr "Ouvrir"
|
||||
|
||||
#: core/templates/mail/html/template.html:217
|
||||
#: core/templates/mail/html/template.html:219
|
||||
#: core/templates/mail/text/template.txt:14
|
||||
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
|
||||
msgstr " Docs, votre nouvel outil incontournable pour organiser, partager et collaborer sur vos documents en équipe. "
|
||||
|
||||
#: core/templates/mail/html/template.html:224
|
||||
#: core/templates/mail/html/template.html:226
|
||||
#: core/templates/mail/text/template.txt:16
|
||||
#, python-format
|
||||
msgid " Brought to you by %(brandname)s "
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-12-09 11:12\n"
|
||||
"POT-Creation-Date: 2026-01-21 09:53+0000\n"
|
||||
"PO-Revision-Date: 2026-01-28 20:12\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Italian\n"
|
||||
"Language: it_IT\n"
|
||||
@@ -17,20 +17,20 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 18\n"
|
||||
|
||||
#: build/lib/core/admin.py:36 core/admin.py:36
|
||||
#: build/lib/core/admin.py:28 core/admin.py:28
|
||||
msgid "Personal info"
|
||||
msgstr "Informazioni personali"
|
||||
|
||||
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
|
||||
#: core/admin.py:137
|
||||
#: build/lib/core/admin.py:41 build/lib/core/admin.py:121 core/admin.py:41
|
||||
#: core/admin.py:121
|
||||
msgid "Permissions"
|
||||
msgstr "Permessi"
|
||||
|
||||
#: build/lib/core/admin.py:61 core/admin.py:61
|
||||
#: build/lib/core/admin.py:53 core/admin.py:53
|
||||
msgid "Important dates"
|
||||
msgstr "Date importanti"
|
||||
|
||||
#: build/lib/core/admin.py:147 core/admin.py:147
|
||||
#: build/lib/core/admin.py:131 core/admin.py:131
|
||||
msgid "Tree structure"
|
||||
msgstr "Struttura ad albero"
|
||||
|
||||
@@ -50,40 +50,32 @@ msgstr ""
|
||||
msgid "Favorite"
|
||||
msgstr "Preferiti"
|
||||
|
||||
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
|
||||
#: build/lib/core/api/serializers.py:505 core/api/serializers.py:505
|
||||
msgid "A new document was created on your behalf!"
|
||||
msgstr "Un nuovo documento è stato creato a tuo nome!"
|
||||
|
||||
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
|
||||
#: build/lib/core/api/serializers.py:509 core/api/serializers.py:509
|
||||
msgid "You have been granted ownership of a new document:"
|
||||
msgstr "Sei ora proprietario di un nuovo documento:"
|
||||
|
||||
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
|
||||
#: build/lib/core/api/serializers.py:545 core/api/serializers.py:545
|
||||
msgid "This field is required."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
|
||||
#: build/lib/core/api/serializers.py:556 core/api/serializers.py:556
|
||||
#, python-format
|
||||
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:694 core/api/serializers.py:694
|
||||
msgid "Body"
|
||||
msgstr "Corpo"
|
||||
|
||||
#: build/lib/core/api/serializers.py:697 core/api/serializers.py:697
|
||||
msgid "Body type"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:703 core/api/serializers.py:703
|
||||
msgid "Format"
|
||||
msgstr "Formato"
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
|
||||
#: build/lib/core/api/viewsets.py:1122 core/api/viewsets.py:1122
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "copia di {title}"
|
||||
|
||||
#: build/lib/core/apps.py:12 core/apps.py:12
|
||||
msgid "Impress core application"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
|
||||
#: core/choices.py:43
|
||||
msgid "Reader"
|
||||
@@ -143,301 +135,259 @@ msgstr "Sinistra"
|
||||
msgid "Right"
|
||||
msgstr "Destra"
|
||||
|
||||
#: build/lib/core/models.py:80 core/models.py:80
|
||||
#: build/lib/core/models.py:81 core/models.py:81
|
||||
msgid "id"
|
||||
msgstr "Id"
|
||||
|
||||
#: build/lib/core/models.py:81 core/models.py:81
|
||||
#: build/lib/core/models.py:82 core/models.py:82
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr "chiave primaria per il record come UUID"
|
||||
|
||||
#: build/lib/core/models.py:87 core/models.py:87
|
||||
#: build/lib/core/models.py:88 core/models.py:88
|
||||
msgid "created on"
|
||||
msgstr "creato il"
|
||||
|
||||
#: build/lib/core/models.py:88 core/models.py:88
|
||||
#: build/lib/core/models.py:89 core/models.py:89
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr "data e ora in cui è stato creato un record"
|
||||
|
||||
#: build/lib/core/models.py:93 core/models.py:93
|
||||
#: build/lib/core/models.py:94 core/models.py:94
|
||||
msgid "updated on"
|
||||
msgstr "aggiornato il"
|
||||
|
||||
#: build/lib/core/models.py:94 core/models.py:94
|
||||
#: build/lib/core/models.py:95 core/models.py:95
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr "data e ora in cui l’ultimo record è stato aggiornato"
|
||||
|
||||
#: build/lib/core/models.py:130 core/models.py:130
|
||||
#: build/lib/core/models.py:131 core/models.py:131
|
||||
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:141 core/models.py:141
|
||||
#: build/lib/core/models.py:142 core/models.py:142
|
||||
msgid "sub"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:142 core/models.py:142
|
||||
#: build/lib/core/models.py:143 core/models.py:143
|
||||
msgid "Required. 255 characters or fewer. ASCII characters only."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:150 core/models.py:150
|
||||
#: build/lib/core/models.py:151 core/models.py:151
|
||||
msgid "full name"
|
||||
msgstr "nome completo"
|
||||
|
||||
#: build/lib/core/models.py:152 core/models.py:152
|
||||
#: build/lib/core/models.py:153 core/models.py:153
|
||||
msgid "short name"
|
||||
msgstr "nome"
|
||||
|
||||
#: build/lib/core/models.py:155 core/models.py:155
|
||||
#: build/lib/core/models.py:156 core/models.py:156
|
||||
msgid "identity email address"
|
||||
msgstr "indirizzo email di identità"
|
||||
|
||||
#: build/lib/core/models.py:160 core/models.py:160
|
||||
#: build/lib/core/models.py:161 core/models.py:161
|
||||
msgid "admin email address"
|
||||
msgstr "Indirizzo email dell'amministratore"
|
||||
|
||||
#: build/lib/core/models.py:167 core/models.py:167
|
||||
#: build/lib/core/models.py:168 core/models.py:168
|
||||
msgid "language"
|
||||
msgstr "lingua"
|
||||
|
||||
#: build/lib/core/models.py:168 core/models.py:168
|
||||
#: build/lib/core/models.py:169 core/models.py:169
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr "La lingua in cui l'utente vuole vedere l'interfaccia."
|
||||
|
||||
#: build/lib/core/models.py:176 core/models.py:176
|
||||
#: build/lib/core/models.py:177 core/models.py:177
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr "Il fuso orario in cui l'utente vuole vedere gli orari."
|
||||
|
||||
#: build/lib/core/models.py:179 core/models.py:179
|
||||
#: build/lib/core/models.py:180 core/models.py:180
|
||||
msgid "device"
|
||||
msgstr "dispositivo"
|
||||
|
||||
#: build/lib/core/models.py:181 core/models.py:181
|
||||
#: build/lib/core/models.py:182 core/models.py:182
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr "Se l'utente è un dispositivo o un utente reale."
|
||||
|
||||
#: build/lib/core/models.py:184 core/models.py:184
|
||||
#: build/lib/core/models.py:185 core/models.py:185
|
||||
msgid "staff status"
|
||||
msgstr "stato del personale"
|
||||
|
||||
#: build/lib/core/models.py:186 core/models.py:186
|
||||
#: build/lib/core/models.py:187 core/models.py:187
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Indica se l'utente può accedere a questo sito amministratore."
|
||||
|
||||
#: build/lib/core/models.py:189 core/models.py:189
|
||||
#: build/lib/core/models.py:190 core/models.py:190
|
||||
msgid "active"
|
||||
msgstr "attivo"
|
||||
|
||||
#: build/lib/core/models.py:192 core/models.py:192
|
||||
#: build/lib/core/models.py:193 core/models.py:193
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr "Indica se questo utente deve essere trattato come attivo. Deseleziona invece di eliminare gli account."
|
||||
|
||||
#: build/lib/core/models.py:204 core/models.py:204
|
||||
#: build/lib/core/models.py:205 core/models.py:205
|
||||
msgid "user"
|
||||
msgstr "utente"
|
||||
|
||||
#: build/lib/core/models.py:205 core/models.py:205
|
||||
#: build/lib/core/models.py:206 core/models.py:206
|
||||
msgid "users"
|
||||
msgstr "utenti"
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
|
||||
#: core/models.py:361 core/models.py:1430
|
||||
#: build/lib/core/models.py:362 core/models.py:362
|
||||
msgid "title"
|
||||
msgstr "titolo"
|
||||
|
||||
#: build/lib/core/models.py:362 core/models.py:362
|
||||
#: build/lib/core/models.py:363 core/models.py:363
|
||||
msgid "excerpt"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:411 core/models.py:411
|
||||
#: build/lib/core/models.py:412 core/models.py:412
|
||||
msgid "Document"
|
||||
msgstr "Documento"
|
||||
|
||||
#: build/lib/core/models.py:412 core/models.py:412
|
||||
#: build/lib/core/models.py:413 core/models.py:413
|
||||
msgid "Documents"
|
||||
msgstr "Documenti"
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
|
||||
#: core/models.py:824
|
||||
#: build/lib/core/models.py:425 build/lib/core/models.py:828 core/models.py:425
|
||||
#: core/models.py:828
|
||||
msgid "Untitled Document"
|
||||
msgstr "Documento senza titolo"
|
||||
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#: build/lib/core/models.py:829 core/models.py:829
|
||||
msgid "Open"
|
||||
msgstr "Apri"
|
||||
|
||||
#: build/lib/core/models.py:864 core/models.py:864
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "{name} ha condiviso un documento con te!"
|
||||
|
||||
#: build/lib/core/models.py:863 core/models.py:863
|
||||
#: build/lib/core/models.py:868 core/models.py:868
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr "{name} ti ha invitato con il ruolo \"{role}\" nel seguente documento:"
|
||||
|
||||
#: build/lib/core/models.py:869 core/models.py:869
|
||||
#: build/lib/core/models.py:874 core/models.py:874
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr "{name} ha condiviso un documento con te: {title}"
|
||||
|
||||
#: build/lib/core/models.py:969 core/models.py:969
|
||||
#: build/lib/core/models.py:975 core/models.py:975
|
||||
msgid "Document/user link trace"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:970 core/models.py:970
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
msgid "Document/user link traces"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
#: build/lib/core/models.py:982 core/models.py:982
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:999 core/models.py:999
|
||||
#: build/lib/core/models.py:1005 core/models.py:1005
|
||||
msgid "Document favorite"
|
||||
msgstr "Documento preferito"
|
||||
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
msgid "Document favorites"
|
||||
msgstr "Documenti preferiti"
|
||||
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
#: build/lib/core/models.py:1012 core/models.py:1012
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1028 core/models.py:1028
|
||||
#: build/lib/core/models.py:1034 core/models.py:1034
|
||||
msgid "Document/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
msgid "Document/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
msgid "This user is already in this document."
|
||||
msgstr "Questo utente è già presente in questo documento."
|
||||
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
#: build/lib/core/models.py:1047 core/models.py:1047
|
||||
msgid "This team is already in this document."
|
||||
msgstr "Questo team è già presente in questo documento."
|
||||
|
||||
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
|
||||
#: core/models.py:1047 core/models.py:1516
|
||||
#: build/lib/core/models.py:1053 core/models.py:1053
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1204 core/models.py:1204
|
||||
msgid "Document ask for access"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1199 core/models.py:1199
|
||||
#: build/lib/core/models.py:1205 core/models.py:1205
|
||||
msgid "Document ask for accesses"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1205 core/models.py:1205
|
||||
#: build/lib/core/models.py:1211 core/models.py:1211
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1262 core/models.py:1262
|
||||
#: build/lib/core/models.py:1268 core/models.py:1268
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1266 core/models.py:1266
|
||||
#: build/lib/core/models.py:1272 core/models.py:1272
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1272 core/models.py:1272
|
||||
#: build/lib/core/models.py:1278 core/models.py:1278
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1314 core/models.py:1314
|
||||
#: build/lib/core/models.py:1320 core/models.py:1320
|
||||
msgid "Thread"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1315 core/models.py:1315
|
||||
#: build/lib/core/models.py:1321 core/models.py:1321
|
||||
msgid "Threads"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
|
||||
#: core/models.py:1318 core/models.py:1370
|
||||
#: build/lib/core/models.py:1324 build/lib/core/models.py:1376
|
||||
#: core/models.py:1324 core/models.py:1376
|
||||
msgid "Anonymous"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1365 core/models.py:1365
|
||||
#: build/lib/core/models.py:1371 core/models.py:1371
|
||||
msgid "Comment"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1366 core/models.py:1366
|
||||
#: build/lib/core/models.py:1372 core/models.py:1372
|
||||
msgid "Comments"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1415 core/models.py:1415
|
||||
#: build/lib/core/models.py:1421 core/models.py:1421
|
||||
msgid "This emoji has already been reacted to this comment."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1419 core/models.py:1419
|
||||
#: build/lib/core/models.py:1425 core/models.py:1425
|
||||
msgid "Reaction"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1420 core/models.py:1420
|
||||
#: build/lib/core/models.py:1426 core/models.py:1426
|
||||
msgid "Reactions"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1431 core/models.py:1431
|
||||
msgid "description"
|
||||
msgstr "descrizione"
|
||||
|
||||
#: build/lib/core/models.py:1432 core/models.py:1432
|
||||
msgid "code"
|
||||
msgstr "code"
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
msgid "css"
|
||||
msgstr "css"
|
||||
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
msgid "public"
|
||||
msgstr "pubblico"
|
||||
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr "Indica se questo modello è pubblico per chiunque."
|
||||
|
||||
#: build/lib/core/models.py:1443 core/models.py:1443
|
||||
msgid "Template"
|
||||
msgstr "Modello"
|
||||
|
||||
#: build/lib/core/models.py:1444 core/models.py:1444
|
||||
msgid "Templates"
|
||||
msgstr "Modelli"
|
||||
|
||||
#: build/lib/core/models.py:1497 core/models.py:1497
|
||||
msgid "Template/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1498 core/models.py:1498
|
||||
msgid "Template/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1504 core/models.py:1504
|
||||
msgid "This user is already in this template."
|
||||
msgstr "Questo utente è già in questo modello."
|
||||
|
||||
#: build/lib/core/models.py:1510 core/models.py:1510
|
||||
msgid "This team is already in this template."
|
||||
msgstr "Questo team è già in questo modello."
|
||||
|
||||
#: build/lib/core/models.py:1587 core/models.py:1587
|
||||
#: build/lib/core/models.py:1436 core/models.py:1436
|
||||
msgid "email address"
|
||||
msgstr "indirizzo e-mail"
|
||||
|
||||
#: build/lib/core/models.py:1606 core/models.py:1606
|
||||
#: build/lib/core/models.py:1455 core/models.py:1455
|
||||
msgid "Document invitation"
|
||||
msgstr "Invito al documento"
|
||||
|
||||
#: build/lib/core/models.py:1607 core/models.py:1607
|
||||
#: build/lib/core/models.py:1456 core/models.py:1456
|
||||
msgid "Document invitations"
|
||||
msgstr "Inviti al documento"
|
||||
|
||||
#: build/lib/core/models.py:1627 core/models.py:1627
|
||||
#: build/lib/core/models.py:1476 core/models.py:1476
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr "Questa email è già associata a un utente registrato."
|
||||
|
||||
@@ -446,17 +396,12 @@ msgstr "Questa email è già associata a un utente registrato."
|
||||
msgid "Logo email"
|
||||
msgstr "Logo e-mail"
|
||||
|
||||
#: core/templates/mail/html/template.html:200
|
||||
#: core/templates/mail/text/template.txt:10
|
||||
msgid "Open"
|
||||
msgstr "Apri"
|
||||
|
||||
#: core/templates/mail/html/template.html:217
|
||||
#: core/templates/mail/html/template.html:219
|
||||
#: core/templates/mail/text/template.txt:14
|
||||
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/template.html:224
|
||||
#: core/templates/mail/html/template.html:226
|
||||
#: core/templates/mail/text/template.txt:16
|
||||
#, python-format
|
||||
msgid " Brought to you by %(brandname)s "
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-12-09 11:12\n"
|
||||
"POT-Creation-Date: 2026-01-21 09:53+0000\n"
|
||||
"PO-Revision-Date: 2026-01-28 20:12\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Dutch\n"
|
||||
"Language: nl_NL\n"
|
||||
@@ -17,20 +17,20 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 18\n"
|
||||
|
||||
#: build/lib/core/admin.py:36 core/admin.py:36
|
||||
#: build/lib/core/admin.py:28 core/admin.py:28
|
||||
msgid "Personal info"
|
||||
msgstr "Persoonlijke informatie"
|
||||
|
||||
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
|
||||
#: core/admin.py:137
|
||||
#: build/lib/core/admin.py:41 build/lib/core/admin.py:121 core/admin.py:41
|
||||
#: core/admin.py:121
|
||||
msgid "Permissions"
|
||||
msgstr "Machtigingen"
|
||||
|
||||
#: build/lib/core/admin.py:61 core/admin.py:61
|
||||
#: build/lib/core/admin.py:53 core/admin.py:53
|
||||
msgid "Important dates"
|
||||
msgstr "Belangrijke data"
|
||||
|
||||
#: build/lib/core/admin.py:147 core/admin.py:147
|
||||
#: build/lib/core/admin.py:131 core/admin.py:131
|
||||
msgid "Tree structure"
|
||||
msgstr "Boomstructuur"
|
||||
|
||||
@@ -50,40 +50,32 @@ msgstr "Gemaskeerd"
|
||||
msgid "Favorite"
|
||||
msgstr "Favoriet"
|
||||
|
||||
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
|
||||
#: build/lib/core/api/serializers.py:505 core/api/serializers.py:505
|
||||
msgid "A new document was created on your behalf!"
|
||||
msgstr "Een nieuw document is namens u gemaakt!"
|
||||
|
||||
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
|
||||
#: build/lib/core/api/serializers.py:509 core/api/serializers.py:509
|
||||
msgid "You have been granted ownership of a new document:"
|
||||
msgstr "U heeft eigenaarschap van een nieuw document gekregen:"
|
||||
|
||||
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
|
||||
#: build/lib/core/api/serializers.py:545 core/api/serializers.py:545
|
||||
msgid "This field is required."
|
||||
msgstr "Dit veld is verplicht."
|
||||
|
||||
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
|
||||
#: build/lib/core/api/serializers.py:556 core/api/serializers.py:556
|
||||
#, python-format
|
||||
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
|
||||
msgstr "Link bereik '%(link_reach)s' is niet toegestaan op basis van bovenliggende documentconfiguratie."
|
||||
|
||||
#: build/lib/core/api/serializers.py:694 core/api/serializers.py:694
|
||||
msgid "Body"
|
||||
msgstr "Text"
|
||||
|
||||
#: build/lib/core/api/serializers.py:697 core/api/serializers.py:697
|
||||
msgid "Body type"
|
||||
msgstr "Text type"
|
||||
|
||||
#: build/lib/core/api/serializers.py:703 core/api/serializers.py:703
|
||||
msgid "Format"
|
||||
msgstr "Formaat"
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
|
||||
#: build/lib/core/api/viewsets.py:1122 core/api/viewsets.py:1122
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "kopie van {title}"
|
||||
|
||||
#: build/lib/core/apps.py:12 core/apps.py:12
|
||||
msgid "Impress core application"
|
||||
msgstr "Docs kern applicatie"
|
||||
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
|
||||
#: core/choices.py:43
|
||||
msgid "Reader"
|
||||
@@ -143,301 +135,259 @@ msgstr "Links"
|
||||
msgid "Right"
|
||||
msgstr "Rechts"
|
||||
|
||||
#: build/lib/core/models.py:80 core/models.py:80
|
||||
#: build/lib/core/models.py:81 core/models.py:81
|
||||
msgid "id"
|
||||
msgstr "id"
|
||||
|
||||
#: build/lib/core/models.py:81 core/models.py:81
|
||||
#: build/lib/core/models.py:82 core/models.py:82
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr "primaire sleutel voor dossier als UUID"
|
||||
|
||||
#: build/lib/core/models.py:87 core/models.py:87
|
||||
#: build/lib/core/models.py:88 core/models.py:88
|
||||
msgid "created on"
|
||||
msgstr "gecreëerd op"
|
||||
|
||||
#: build/lib/core/models.py:88 core/models.py:88
|
||||
#: build/lib/core/models.py:89 core/models.py:89
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr "datum en tijd waarop dossier is gecreeërd"
|
||||
|
||||
#: build/lib/core/models.py:93 core/models.py:93
|
||||
#: build/lib/core/models.py:94 core/models.py:94
|
||||
msgid "updated on"
|
||||
msgstr "Laatst gewijzigd op"
|
||||
|
||||
#: build/lib/core/models.py:94 core/models.py:94
|
||||
#: build/lib/core/models.py:95 core/models.py:95
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr "datum en tijd waarop dossier laatst was gewijzigd"
|
||||
|
||||
#: build/lib/core/models.py:130 core/models.py:130
|
||||
#: build/lib/core/models.py:131 core/models.py:131
|
||||
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
|
||||
msgstr "Wij konden geen gebruiker vinden met dit id, maar de email is al geassocieerd met een geregistreerde gebruiker."
|
||||
|
||||
#: build/lib/core/models.py:141 core/models.py:141
|
||||
#: build/lib/core/models.py:142 core/models.py:142
|
||||
msgid "sub"
|
||||
msgstr "id"
|
||||
|
||||
#: build/lib/core/models.py:142 core/models.py:142
|
||||
#: build/lib/core/models.py:143 core/models.py:143
|
||||
msgid "Required. 255 characters or fewer. ASCII characters only."
|
||||
msgstr "Vereist. 255 tekens of minder. Alleen ASCII tekens."
|
||||
|
||||
#: build/lib/core/models.py:150 core/models.py:150
|
||||
#: build/lib/core/models.py:151 core/models.py:151
|
||||
msgid "full name"
|
||||
msgstr "volledige naam"
|
||||
|
||||
#: build/lib/core/models.py:152 core/models.py:152
|
||||
#: build/lib/core/models.py:153 core/models.py:153
|
||||
msgid "short name"
|
||||
msgstr "gebruikersnaam"
|
||||
|
||||
#: build/lib/core/models.py:155 core/models.py:155
|
||||
#: build/lib/core/models.py:156 core/models.py:156
|
||||
msgid "identity email address"
|
||||
msgstr "identiteit emailadres"
|
||||
|
||||
#: build/lib/core/models.py:160 core/models.py:160
|
||||
#: build/lib/core/models.py:161 core/models.py:161
|
||||
msgid "admin email address"
|
||||
msgstr "admin emailadres"
|
||||
|
||||
#: build/lib/core/models.py:167 core/models.py:167
|
||||
#: build/lib/core/models.py:168 core/models.py:168
|
||||
msgid "language"
|
||||
msgstr "taal"
|
||||
|
||||
#: build/lib/core/models.py:168 core/models.py:168
|
||||
#: build/lib/core/models.py:169 core/models.py:169
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr "De taal waarin de gebruiker de interface wil zien."
|
||||
|
||||
#: build/lib/core/models.py:176 core/models.py:176
|
||||
#: build/lib/core/models.py:177 core/models.py:177
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr "De tijdzone waarin de gebruiker de tijden wil zien."
|
||||
|
||||
#: build/lib/core/models.py:179 core/models.py:179
|
||||
#: build/lib/core/models.py:180 core/models.py:180
|
||||
msgid "device"
|
||||
msgstr "apparaat"
|
||||
|
||||
#: build/lib/core/models.py:181 core/models.py:181
|
||||
#: build/lib/core/models.py:182 core/models.py:182
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr "Of de gebruiker een apparaat is of een echte gebruiker."
|
||||
|
||||
#: build/lib/core/models.py:184 core/models.py:184
|
||||
#: build/lib/core/models.py:185 core/models.py:185
|
||||
msgid "staff status"
|
||||
msgstr "beheerder status"
|
||||
|
||||
#: build/lib/core/models.py:186 core/models.py:186
|
||||
#: build/lib/core/models.py:187 core/models.py:187
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Of de gebruiker kan inloggen in het beheer gedeelte."
|
||||
|
||||
#: build/lib/core/models.py:189 core/models.py:189
|
||||
#: build/lib/core/models.py:190 core/models.py:190
|
||||
msgid "active"
|
||||
msgstr "actief"
|
||||
|
||||
#: build/lib/core/models.py:192 core/models.py:192
|
||||
#: build/lib/core/models.py:193 core/models.py:193
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr "Of een gebruiker als actief moet worden beschouwd. Deselecteer dit in plaats van het account te deleten."
|
||||
|
||||
#: build/lib/core/models.py:204 core/models.py:204
|
||||
#: build/lib/core/models.py:205 core/models.py:205
|
||||
msgid "user"
|
||||
msgstr "gebruiker"
|
||||
|
||||
#: build/lib/core/models.py:205 core/models.py:205
|
||||
#: build/lib/core/models.py:206 core/models.py:206
|
||||
msgid "users"
|
||||
msgstr "gebruikers"
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
|
||||
#: core/models.py:361 core/models.py:1430
|
||||
#: build/lib/core/models.py:362 core/models.py:362
|
||||
msgid "title"
|
||||
msgstr "titel"
|
||||
|
||||
#: build/lib/core/models.py:362 core/models.py:362
|
||||
#: build/lib/core/models.py:363 core/models.py:363
|
||||
msgid "excerpt"
|
||||
msgstr "uittreksel"
|
||||
|
||||
#: build/lib/core/models.py:411 core/models.py:411
|
||||
#: build/lib/core/models.py:412 core/models.py:412
|
||||
msgid "Document"
|
||||
msgstr "Document"
|
||||
|
||||
#: build/lib/core/models.py:412 core/models.py:412
|
||||
#: build/lib/core/models.py:413 core/models.py:413
|
||||
msgid "Documents"
|
||||
msgstr "Documenten"
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
|
||||
#: core/models.py:824
|
||||
#: build/lib/core/models.py:425 build/lib/core/models.py:828 core/models.py:425
|
||||
#: core/models.py:828
|
||||
msgid "Untitled Document"
|
||||
msgstr "Naamloos Document"
|
||||
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#: build/lib/core/models.py:829 core/models.py:829
|
||||
msgid "Open"
|
||||
msgstr "Open"
|
||||
|
||||
#: build/lib/core/models.py:864 core/models.py:864
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "{name} heeft een document met u gedeeld!"
|
||||
|
||||
#: build/lib/core/models.py:863 core/models.py:863
|
||||
#: build/lib/core/models.py:868 core/models.py:868
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr "{name} heeft u uitgenodigd met de rol \"{role}\" op het volgende document:"
|
||||
|
||||
#: build/lib/core/models.py:869 core/models.py:869
|
||||
#: build/lib/core/models.py:874 core/models.py:874
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr "{name} heeft een document met u gedeeld: {title}"
|
||||
|
||||
#: build/lib/core/models.py:969 core/models.py:969
|
||||
#: build/lib/core/models.py:975 core/models.py:975
|
||||
msgid "Document/user link trace"
|
||||
msgstr "Document/gebruiker link"
|
||||
|
||||
#: build/lib/core/models.py:970 core/models.py:970
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
msgid "Document/user link traces"
|
||||
msgstr "Document/gebruiker link"
|
||||
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
#: build/lib/core/models.py:982 core/models.py:982
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr "Een link bestaat al voor dit document/deze gebruiker."
|
||||
|
||||
#: build/lib/core/models.py:999 core/models.py:999
|
||||
#: build/lib/core/models.py:1005 core/models.py:1005
|
||||
msgid "Document favorite"
|
||||
msgstr "Document favoriet"
|
||||
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
msgid "Document favorites"
|
||||
msgstr "Document favorieten"
|
||||
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
#: build/lib/core/models.py:1012 core/models.py:1012
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr "Dit document is al in gebruik als favoriet door dezelfde gebruiker."
|
||||
|
||||
#: build/lib/core/models.py:1028 core/models.py:1028
|
||||
#: build/lib/core/models.py:1034 core/models.py:1034
|
||||
msgid "Document/user relation"
|
||||
msgstr "Document/gebruiker relatie"
|
||||
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
msgid "Document/user relations"
|
||||
msgstr "Document/gebruiker relaties"
|
||||
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
msgid "This user is already in this document."
|
||||
msgstr "De gebruiker bestaat al in dit document."
|
||||
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
#: build/lib/core/models.py:1047 core/models.py:1047
|
||||
msgid "This team is already in this document."
|
||||
msgstr "Dit team bestaat al in dit document."
|
||||
|
||||
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
|
||||
#: core/models.py:1047 core/models.py:1516
|
||||
#: build/lib/core/models.py:1053 core/models.py:1053
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Een gebruiker of team moet gekozen worden, maar niet beide."
|
||||
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1204 core/models.py:1204
|
||||
msgid "Document ask for access"
|
||||
msgstr "Document verzoekt om toegang"
|
||||
|
||||
#: build/lib/core/models.py:1199 core/models.py:1199
|
||||
#: build/lib/core/models.py:1205 core/models.py:1205
|
||||
msgid "Document ask for accesses"
|
||||
msgstr "Document verzoekt om toegangen"
|
||||
|
||||
#: build/lib/core/models.py:1205 core/models.py:1205
|
||||
#: build/lib/core/models.py:1211 core/models.py:1211
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr "Deze gebruiker heeft al om toegang tot dit document gevraagd."
|
||||
|
||||
#: build/lib/core/models.py:1262 core/models.py:1262
|
||||
#: build/lib/core/models.py:1268 core/models.py:1268
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr "{name} verzoekt toegang tot een document!"
|
||||
|
||||
#: build/lib/core/models.py:1266 core/models.py:1266
|
||||
#: build/lib/core/models.py:1272 core/models.py:1272
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr "{name} verzoekt toegang tot het volgende document:"
|
||||
|
||||
#: build/lib/core/models.py:1272 core/models.py:1272
|
||||
#: build/lib/core/models.py:1278 core/models.py:1278
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr "{name} verzoekt toegang tot het document: {title}"
|
||||
|
||||
#: build/lib/core/models.py:1314 core/models.py:1314
|
||||
#: build/lib/core/models.py:1320 core/models.py:1320
|
||||
msgid "Thread"
|
||||
msgstr "Kanaal"
|
||||
|
||||
#: build/lib/core/models.py:1315 core/models.py:1315
|
||||
#: build/lib/core/models.py:1321 core/models.py:1321
|
||||
msgid "Threads"
|
||||
msgstr "Kanalen"
|
||||
|
||||
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
|
||||
#: core/models.py:1318 core/models.py:1370
|
||||
#: build/lib/core/models.py:1324 build/lib/core/models.py:1376
|
||||
#: core/models.py:1324 core/models.py:1376
|
||||
msgid "Anonymous"
|
||||
msgstr "Anoniem"
|
||||
|
||||
#: build/lib/core/models.py:1365 core/models.py:1365
|
||||
#: build/lib/core/models.py:1371 core/models.py:1371
|
||||
msgid "Comment"
|
||||
msgstr "Reactie"
|
||||
|
||||
#: build/lib/core/models.py:1366 core/models.py:1366
|
||||
#: build/lib/core/models.py:1372 core/models.py:1372
|
||||
msgid "Comments"
|
||||
msgstr "Reacties"
|
||||
|
||||
#: build/lib/core/models.py:1415 core/models.py:1415
|
||||
#: build/lib/core/models.py:1421 core/models.py:1421
|
||||
msgid "This emoji has already been reacted to this comment."
|
||||
msgstr "Deze emoji is al op deze opmerking gereageerd."
|
||||
|
||||
#: build/lib/core/models.py:1419 core/models.py:1419
|
||||
#: build/lib/core/models.py:1425 core/models.py:1425
|
||||
msgid "Reaction"
|
||||
msgstr "Reactie"
|
||||
|
||||
#: build/lib/core/models.py:1420 core/models.py:1420
|
||||
#: build/lib/core/models.py:1426 core/models.py:1426
|
||||
msgid "Reactions"
|
||||
msgstr "Reacties"
|
||||
|
||||
#: build/lib/core/models.py:1431 core/models.py:1431
|
||||
msgid "description"
|
||||
msgstr "omschrijving"
|
||||
|
||||
#: build/lib/core/models.py:1432 core/models.py:1432
|
||||
msgid "code"
|
||||
msgstr "code"
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
msgid "css"
|
||||
msgstr "css"
|
||||
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
msgid "public"
|
||||
msgstr "publiek"
|
||||
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr "Of dit sjabloon door iedereen publiekelijk te gebruiken is."
|
||||
|
||||
#: build/lib/core/models.py:1443 core/models.py:1443
|
||||
msgid "Template"
|
||||
msgstr "Sjabloon"
|
||||
|
||||
#: build/lib/core/models.py:1444 core/models.py:1444
|
||||
msgid "Templates"
|
||||
msgstr "Sjabloon"
|
||||
|
||||
#: build/lib/core/models.py:1497 core/models.py:1497
|
||||
msgid "Template/user relation"
|
||||
msgstr "Sjabloon/gebruiker relatie"
|
||||
|
||||
#: build/lib/core/models.py:1498 core/models.py:1498
|
||||
msgid "Template/user relations"
|
||||
msgstr "Sjabloon/gebruiker relaties"
|
||||
|
||||
#: build/lib/core/models.py:1504 core/models.py:1504
|
||||
msgid "This user is already in this template."
|
||||
msgstr "De gebruiker bestaat al in dit sjabloon."
|
||||
|
||||
#: build/lib/core/models.py:1510 core/models.py:1510
|
||||
msgid "This team is already in this template."
|
||||
msgstr "Het team bestaat al in dit sjabloon."
|
||||
|
||||
#: build/lib/core/models.py:1587 core/models.py:1587
|
||||
#: build/lib/core/models.py:1436 core/models.py:1436
|
||||
msgid "email address"
|
||||
msgstr "e-mailadres"
|
||||
|
||||
#: build/lib/core/models.py:1606 core/models.py:1606
|
||||
#: build/lib/core/models.py:1455 core/models.py:1455
|
||||
msgid "Document invitation"
|
||||
msgstr "Document uitnodiging"
|
||||
|
||||
#: build/lib/core/models.py:1607 core/models.py:1607
|
||||
#: build/lib/core/models.py:1456 core/models.py:1456
|
||||
msgid "Document invitations"
|
||||
msgstr "Document uitnodigingen"
|
||||
|
||||
#: build/lib/core/models.py:1627 core/models.py:1627
|
||||
#: build/lib/core/models.py:1476 core/models.py:1476
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr "Deze email is al geassocieerd met een geregistreerde gebruiker."
|
||||
|
||||
@@ -446,17 +396,12 @@ msgstr "Deze email is al geassocieerd met een geregistreerde gebruiker."
|
||||
msgid "Logo email"
|
||||
msgstr "Logo email"
|
||||
|
||||
#: core/templates/mail/html/template.html:200
|
||||
#: core/templates/mail/text/template.txt:10
|
||||
msgid "Open"
|
||||
msgstr "Open"
|
||||
|
||||
#: core/templates/mail/html/template.html:217
|
||||
#: core/templates/mail/html/template.html:219
|
||||
#: core/templates/mail/text/template.txt:14
|
||||
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
|
||||
msgstr " Docs, jouw nieuwe essentiële tool voor het organiseren, delen en collaboreren van documenten als team. "
|
||||
|
||||
#: core/templates/mail/html/template.html:224
|
||||
#: core/templates/mail/html/template.html:226
|
||||
#: core/templates/mail/text/template.txt:16
|
||||
#, python-format
|
||||
msgid " Brought to you by %(brandname)s "
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-12-09 11:12\n"
|
||||
"POT-Creation-Date: 2026-01-21 09:53+0000\n"
|
||||
"PO-Revision-Date: 2026-01-28 20:12\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Portuguese\n"
|
||||
"Language: pt_PT\n"
|
||||
@@ -17,20 +17,20 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 18\n"
|
||||
|
||||
#: build/lib/core/admin.py:36 core/admin.py:36
|
||||
#: build/lib/core/admin.py:28 core/admin.py:28
|
||||
msgid "Personal info"
|
||||
msgstr "Informações Pessoais"
|
||||
|
||||
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
|
||||
#: core/admin.py:137
|
||||
#: build/lib/core/admin.py:41 build/lib/core/admin.py:121 core/admin.py:41
|
||||
#: core/admin.py:121
|
||||
msgid "Permissions"
|
||||
msgstr "Permissões"
|
||||
|
||||
#: build/lib/core/admin.py:61 core/admin.py:61
|
||||
#: build/lib/core/admin.py:53 core/admin.py:53
|
||||
msgid "Important dates"
|
||||
msgstr "Datas importantes"
|
||||
|
||||
#: build/lib/core/admin.py:147 core/admin.py:147
|
||||
#: build/lib/core/admin.py:131 core/admin.py:131
|
||||
msgid "Tree structure"
|
||||
msgstr "Estrutura de árvore"
|
||||
|
||||
@@ -50,40 +50,32 @@ msgstr ""
|
||||
msgid "Favorite"
|
||||
msgstr "Favorito"
|
||||
|
||||
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
|
||||
#: build/lib/core/api/serializers.py:505 core/api/serializers.py:505
|
||||
msgid "A new document was created on your behalf!"
|
||||
msgstr "Um novo documento foi criado em seu nome!"
|
||||
|
||||
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
|
||||
#: build/lib/core/api/serializers.py:509 core/api/serializers.py:509
|
||||
msgid "You have been granted ownership of a new document:"
|
||||
msgstr "A propriedade de um novo documento foi concedida a você:"
|
||||
|
||||
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
|
||||
#: build/lib/core/api/serializers.py:545 core/api/serializers.py:545
|
||||
msgid "This field is required."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
|
||||
#: build/lib/core/api/serializers.py:556 core/api/serializers.py:556
|
||||
#, python-format
|
||||
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:694 core/api/serializers.py:694
|
||||
msgid "Body"
|
||||
msgstr "Corpo"
|
||||
|
||||
#: build/lib/core/api/serializers.py:697 core/api/serializers.py:697
|
||||
msgid "Body type"
|
||||
msgstr "Tipo de corpo"
|
||||
|
||||
#: build/lib/core/api/serializers.py:703 core/api/serializers.py:703
|
||||
msgid "Format"
|
||||
msgstr "Formato"
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
|
||||
#: build/lib/core/api/viewsets.py:1122 core/api/viewsets.py:1122
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "cópia de {title}"
|
||||
|
||||
#: build/lib/core/apps.py:12 core/apps.py:12
|
||||
msgid "Impress core application"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
|
||||
#: core/choices.py:43
|
||||
msgid "Reader"
|
||||
@@ -143,301 +135,259 @@ msgstr ""
|
||||
msgid "Right"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:80 core/models.py:80
|
||||
#: build/lib/core/models.py:81 core/models.py:81
|
||||
msgid "id"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:81 core/models.py:81
|
||||
#: build/lib/core/models.py:82 core/models.py:82
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:87 core/models.py:87
|
||||
#: build/lib/core/models.py:88 core/models.py:88
|
||||
msgid "created on"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:88 core/models.py:88
|
||||
#: build/lib/core/models.py:89 core/models.py:89
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:93 core/models.py:93
|
||||
#: build/lib/core/models.py:94 core/models.py:94
|
||||
msgid "updated on"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:94 core/models.py:94
|
||||
#: build/lib/core/models.py:95 core/models.py:95
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:130 core/models.py:130
|
||||
#: build/lib/core/models.py:131 core/models.py:131
|
||||
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:141 core/models.py:141
|
||||
#: build/lib/core/models.py:142 core/models.py:142
|
||||
msgid "sub"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:142 core/models.py:142
|
||||
#: build/lib/core/models.py:143 core/models.py:143
|
||||
msgid "Required. 255 characters or fewer. ASCII characters only."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:150 core/models.py:150
|
||||
#: build/lib/core/models.py:151 core/models.py:151
|
||||
msgid "full name"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:152 core/models.py:152
|
||||
#: build/lib/core/models.py:153 core/models.py:153
|
||||
msgid "short name"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:155 core/models.py:155
|
||||
#: build/lib/core/models.py:156 core/models.py:156
|
||||
msgid "identity email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:160 core/models.py:160
|
||||
#: build/lib/core/models.py:161 core/models.py:161
|
||||
msgid "admin email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:167 core/models.py:167
|
||||
#: build/lib/core/models.py:168 core/models.py:168
|
||||
msgid "language"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:168 core/models.py:168
|
||||
#: build/lib/core/models.py:169 core/models.py:169
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:176 core/models.py:176
|
||||
#: build/lib/core/models.py:177 core/models.py:177
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:179 core/models.py:179
|
||||
#: build/lib/core/models.py:180 core/models.py:180
|
||||
msgid "device"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:181 core/models.py:181
|
||||
#: build/lib/core/models.py:182 core/models.py:182
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:184 core/models.py:184
|
||||
#: build/lib/core/models.py:185 core/models.py:185
|
||||
msgid "staff status"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:186 core/models.py:186
|
||||
#: build/lib/core/models.py:187 core/models.py:187
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:189 core/models.py:189
|
||||
#: build/lib/core/models.py:190 core/models.py:190
|
||||
msgid "active"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:192 core/models.py:192
|
||||
#: build/lib/core/models.py:193 core/models.py:193
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:204 core/models.py:204
|
||||
#: build/lib/core/models.py:205 core/models.py:205
|
||||
msgid "user"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:205 core/models.py:205
|
||||
#: build/lib/core/models.py:206 core/models.py:206
|
||||
msgid "users"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
|
||||
#: core/models.py:361 core/models.py:1430
|
||||
#: build/lib/core/models.py:362 core/models.py:362
|
||||
msgid "title"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:362 core/models.py:362
|
||||
#: build/lib/core/models.py:363 core/models.py:363
|
||||
msgid "excerpt"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:411 core/models.py:411
|
||||
#: build/lib/core/models.py:412 core/models.py:412
|
||||
msgid "Document"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:412 core/models.py:412
|
||||
#: build/lib/core/models.py:413 core/models.py:413
|
||||
msgid "Documents"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
|
||||
#: core/models.py:824
|
||||
#: build/lib/core/models.py:425 build/lib/core/models.py:828 core/models.py:425
|
||||
#: core/models.py:828
|
||||
msgid "Untitled Document"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#: build/lib/core/models.py:829 core/models.py:829
|
||||
msgid "Open"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:864 core/models.py:864
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:863 core/models.py:863
|
||||
#: build/lib/core/models.py:868 core/models.py:868
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:869 core/models.py:869
|
||||
#: build/lib/core/models.py:874 core/models.py:874
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:969 core/models.py:969
|
||||
#: build/lib/core/models.py:975 core/models.py:975
|
||||
msgid "Document/user link trace"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:970 core/models.py:970
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
msgid "Document/user link traces"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
#: build/lib/core/models.py:982 core/models.py:982
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:999 core/models.py:999
|
||||
#: build/lib/core/models.py:1005 core/models.py:1005
|
||||
msgid "Document favorite"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
msgid "Document favorites"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
#: build/lib/core/models.py:1012 core/models.py:1012
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1028 core/models.py:1028
|
||||
#: build/lib/core/models.py:1034 core/models.py:1034
|
||||
msgid "Document/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
msgid "Document/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
msgid "This user is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
#: build/lib/core/models.py:1047 core/models.py:1047
|
||||
msgid "This team is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
|
||||
#: core/models.py:1047 core/models.py:1516
|
||||
#: build/lib/core/models.py:1053 core/models.py:1053
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1204 core/models.py:1204
|
||||
msgid "Document ask for access"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1199 core/models.py:1199
|
||||
#: build/lib/core/models.py:1205 core/models.py:1205
|
||||
msgid "Document ask for accesses"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1205 core/models.py:1205
|
||||
#: build/lib/core/models.py:1211 core/models.py:1211
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1262 core/models.py:1262
|
||||
#: build/lib/core/models.py:1268 core/models.py:1268
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1266 core/models.py:1266
|
||||
#: build/lib/core/models.py:1272 core/models.py:1272
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1272 core/models.py:1272
|
||||
#: build/lib/core/models.py:1278 core/models.py:1278
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1314 core/models.py:1314
|
||||
#: build/lib/core/models.py:1320 core/models.py:1320
|
||||
msgid "Thread"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1315 core/models.py:1315
|
||||
#: build/lib/core/models.py:1321 core/models.py:1321
|
||||
msgid "Threads"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
|
||||
#: core/models.py:1318 core/models.py:1370
|
||||
#: build/lib/core/models.py:1324 build/lib/core/models.py:1376
|
||||
#: core/models.py:1324 core/models.py:1376
|
||||
msgid "Anonymous"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1365 core/models.py:1365
|
||||
#: build/lib/core/models.py:1371 core/models.py:1371
|
||||
msgid "Comment"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1366 core/models.py:1366
|
||||
#: build/lib/core/models.py:1372 core/models.py:1372
|
||||
msgid "Comments"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1415 core/models.py:1415
|
||||
#: build/lib/core/models.py:1421 core/models.py:1421
|
||||
msgid "This emoji has already been reacted to this comment."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1419 core/models.py:1419
|
||||
#: build/lib/core/models.py:1425 core/models.py:1425
|
||||
msgid "Reaction"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1420 core/models.py:1420
|
||||
#: build/lib/core/models.py:1426 core/models.py:1426
|
||||
msgid "Reactions"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1431 core/models.py:1431
|
||||
msgid "description"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1432 core/models.py:1432
|
||||
msgid "code"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
msgid "css"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
msgid "public"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1443 core/models.py:1443
|
||||
msgid "Template"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1444 core/models.py:1444
|
||||
msgid "Templates"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1497 core/models.py:1497
|
||||
msgid "Template/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1498 core/models.py:1498
|
||||
msgid "Template/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1504 core/models.py:1504
|
||||
msgid "This user is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1510 core/models.py:1510
|
||||
msgid "This team is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1587 core/models.py:1587
|
||||
#: build/lib/core/models.py:1436 core/models.py:1436
|
||||
msgid "email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1606 core/models.py:1606
|
||||
#: build/lib/core/models.py:1455 core/models.py:1455
|
||||
msgid "Document invitation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1607 core/models.py:1607
|
||||
#: build/lib/core/models.py:1456 core/models.py:1456
|
||||
msgid "Document invitations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1627 core/models.py:1627
|
||||
#: build/lib/core/models.py:1476 core/models.py:1476
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr ""
|
||||
|
||||
@@ -446,17 +396,12 @@ msgstr ""
|
||||
msgid "Logo email"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/template.html:200
|
||||
#: core/templates/mail/text/template.txt:10
|
||||
msgid "Open"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/template.html:217
|
||||
#: core/templates/mail/html/template.html:219
|
||||
#: core/templates/mail/text/template.txt:14
|
||||
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/template.html:224
|
||||
#: core/templates/mail/html/template.html:226
|
||||
#: core/templates/mail/text/template.txt:16
|
||||
#, python-format
|
||||
msgid " Brought to you by %(brandname)s "
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-12-09 11:12\n"
|
||||
"POT-Creation-Date: 2026-01-21 09:53+0000\n"
|
||||
"PO-Revision-Date: 2026-01-28 20:12\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Russian\n"
|
||||
"Language: ru_RU\n"
|
||||
@@ -17,20 +17,20 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 18\n"
|
||||
|
||||
#: build/lib/core/admin.py:36 core/admin.py:36
|
||||
#: build/lib/core/admin.py:28 core/admin.py:28
|
||||
msgid "Personal info"
|
||||
msgstr "Личная информация"
|
||||
|
||||
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
|
||||
#: core/admin.py:137
|
||||
#: build/lib/core/admin.py:41 build/lib/core/admin.py:121 core/admin.py:41
|
||||
#: core/admin.py:121
|
||||
msgid "Permissions"
|
||||
msgstr "Разрешения"
|
||||
|
||||
#: build/lib/core/admin.py:61 core/admin.py:61
|
||||
#: build/lib/core/admin.py:53 core/admin.py:53
|
||||
msgid "Important dates"
|
||||
msgstr "Важные даты"
|
||||
|
||||
#: build/lib/core/admin.py:147 core/admin.py:147
|
||||
#: build/lib/core/admin.py:131 core/admin.py:131
|
||||
msgid "Tree structure"
|
||||
msgstr "Древовидная структура"
|
||||
|
||||
@@ -50,40 +50,32 @@ msgstr "Скрытый"
|
||||
msgid "Favorite"
|
||||
msgstr "Избранное"
|
||||
|
||||
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
|
||||
#: build/lib/core/api/serializers.py:505 core/api/serializers.py:505
|
||||
msgid "A new document was created on your behalf!"
|
||||
msgstr "Новый документ был создан от вашего имени!"
|
||||
|
||||
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
|
||||
#: build/lib/core/api/serializers.py:509 core/api/serializers.py:509
|
||||
msgid "You have been granted ownership of a new document:"
|
||||
msgstr "Вы назначены владельцем для нового документа:"
|
||||
|
||||
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
|
||||
#: build/lib/core/api/serializers.py:545 core/api/serializers.py:545
|
||||
msgid "This field is required."
|
||||
msgstr "Это поле обязательное."
|
||||
|
||||
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
|
||||
#: build/lib/core/api/serializers.py:556 core/api/serializers.py:556
|
||||
#, python-format
|
||||
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
|
||||
msgstr "Доступ по ссылке '%(link_reach)s' запрещён в соответствии с настройками родительского документа."
|
||||
|
||||
#: build/lib/core/api/serializers.py:694 core/api/serializers.py:694
|
||||
msgid "Body"
|
||||
msgstr "Текст сообщения"
|
||||
|
||||
#: build/lib/core/api/serializers.py:697 core/api/serializers.py:697
|
||||
msgid "Body type"
|
||||
msgstr "Тип сообщения"
|
||||
|
||||
#: build/lib/core/api/serializers.py:703 core/api/serializers.py:703
|
||||
msgid "Format"
|
||||
msgstr "Формат"
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
|
||||
#: build/lib/core/api/viewsets.py:1122 core/api/viewsets.py:1122
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "копия {title}"
|
||||
|
||||
#: build/lib/core/apps.py:12 core/apps.py:12
|
||||
msgid "Impress core application"
|
||||
msgstr "Ядро приложения Impress"
|
||||
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
|
||||
#: core/choices.py:43
|
||||
msgid "Reader"
|
||||
@@ -143,301 +135,259 @@ msgstr "Слева"
|
||||
msgid "Right"
|
||||
msgstr "Справа"
|
||||
|
||||
#: build/lib/core/models.py:80 core/models.py:80
|
||||
#: build/lib/core/models.py:81 core/models.py:81
|
||||
msgid "id"
|
||||
msgstr "id"
|
||||
|
||||
#: build/lib/core/models.py:81 core/models.py:81
|
||||
#: build/lib/core/models.py:82 core/models.py:82
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr "первичный ключ для записи как UUID"
|
||||
|
||||
#: build/lib/core/models.py:87 core/models.py:87
|
||||
#: build/lib/core/models.py:88 core/models.py:88
|
||||
msgid "created on"
|
||||
msgstr "создано"
|
||||
|
||||
#: build/lib/core/models.py:88 core/models.py:88
|
||||
#: build/lib/core/models.py:89 core/models.py:89
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr "дата и время создания записи"
|
||||
|
||||
#: build/lib/core/models.py:93 core/models.py:93
|
||||
#: build/lib/core/models.py:94 core/models.py:94
|
||||
msgid "updated on"
|
||||
msgstr "обновлено"
|
||||
|
||||
#: build/lib/core/models.py:94 core/models.py:94
|
||||
#: build/lib/core/models.py:95 core/models.py:95
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr "дата и время последнего обновления записи"
|
||||
|
||||
#: build/lib/core/models.py:130 core/models.py:130
|
||||
#: build/lib/core/models.py:131 core/models.py:131
|
||||
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
|
||||
msgstr "Мы не смогли найти пользователя с этими данными, но этот адрес уже связан с зарегистрированным пользователем."
|
||||
|
||||
#: build/lib/core/models.py:141 core/models.py:141
|
||||
#: build/lib/core/models.py:142 core/models.py:142
|
||||
msgid "sub"
|
||||
msgstr "вложение"
|
||||
|
||||
#: build/lib/core/models.py:142 core/models.py:142
|
||||
#: build/lib/core/models.py:143 core/models.py:143
|
||||
msgid "Required. 255 characters or fewer. ASCII characters only."
|
||||
msgstr "Обязательно. 255 символов или меньше. Только ASCII символы."
|
||||
|
||||
#: build/lib/core/models.py:150 core/models.py:150
|
||||
#: build/lib/core/models.py:151 core/models.py:151
|
||||
msgid "full name"
|
||||
msgstr "полное имя"
|
||||
|
||||
#: build/lib/core/models.py:152 core/models.py:152
|
||||
#: build/lib/core/models.py:153 core/models.py:153
|
||||
msgid "short name"
|
||||
msgstr "короткое имя"
|
||||
|
||||
#: build/lib/core/models.py:155 core/models.py:155
|
||||
#: build/lib/core/models.py:156 core/models.py:156
|
||||
msgid "identity email address"
|
||||
msgstr "личный адрес электронной почты"
|
||||
|
||||
#: build/lib/core/models.py:160 core/models.py:160
|
||||
#: build/lib/core/models.py:161 core/models.py:161
|
||||
msgid "admin email address"
|
||||
msgstr "e-mail администратора"
|
||||
|
||||
#: build/lib/core/models.py:167 core/models.py:167
|
||||
#: build/lib/core/models.py:168 core/models.py:168
|
||||
msgid "language"
|
||||
msgstr "язык"
|
||||
|
||||
#: build/lib/core/models.py:168 core/models.py:168
|
||||
#: build/lib/core/models.py:169 core/models.py:169
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr "Язык, на котором пользователь хочет видеть интерфейс."
|
||||
|
||||
#: build/lib/core/models.py:176 core/models.py:176
|
||||
#: build/lib/core/models.py:177 core/models.py:177
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr "Часовой пояс, в котором пользователь хочет видеть время."
|
||||
|
||||
#: build/lib/core/models.py:179 core/models.py:179
|
||||
#: build/lib/core/models.py:180 core/models.py:180
|
||||
msgid "device"
|
||||
msgstr "устройство"
|
||||
|
||||
#: build/lib/core/models.py:181 core/models.py:181
|
||||
#: build/lib/core/models.py:182 core/models.py:182
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr "Пользователь является устройством или человеком."
|
||||
|
||||
#: build/lib/core/models.py:184 core/models.py:184
|
||||
#: build/lib/core/models.py:185 core/models.py:185
|
||||
msgid "staff status"
|
||||
msgstr "статус сотрудника"
|
||||
|
||||
#: build/lib/core/models.py:186 core/models.py:186
|
||||
#: build/lib/core/models.py:187 core/models.py:187
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Может ли пользователь войти на этот административный сайт."
|
||||
|
||||
#: build/lib/core/models.py:189 core/models.py:189
|
||||
#: build/lib/core/models.py:190 core/models.py:190
|
||||
msgid "active"
|
||||
msgstr "активный"
|
||||
|
||||
#: build/lib/core/models.py:192 core/models.py:192
|
||||
#: build/lib/core/models.py:193 core/models.py:193
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr "Должен ли пользователь рассматриваться как активный. Альтернатива удалению учётных записей."
|
||||
|
||||
#: build/lib/core/models.py:204 core/models.py:204
|
||||
#: build/lib/core/models.py:205 core/models.py:205
|
||||
msgid "user"
|
||||
msgstr "пользователь"
|
||||
|
||||
#: build/lib/core/models.py:205 core/models.py:205
|
||||
#: build/lib/core/models.py:206 core/models.py:206
|
||||
msgid "users"
|
||||
msgstr "пользователи"
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
|
||||
#: core/models.py:361 core/models.py:1430
|
||||
#: build/lib/core/models.py:362 core/models.py:362
|
||||
msgid "title"
|
||||
msgstr "заголовок"
|
||||
|
||||
#: build/lib/core/models.py:362 core/models.py:362
|
||||
#: build/lib/core/models.py:363 core/models.py:363
|
||||
msgid "excerpt"
|
||||
msgstr "отрывок"
|
||||
|
||||
#: build/lib/core/models.py:411 core/models.py:411
|
||||
#: build/lib/core/models.py:412 core/models.py:412
|
||||
msgid "Document"
|
||||
msgstr "Документ"
|
||||
|
||||
#: build/lib/core/models.py:412 core/models.py:412
|
||||
#: build/lib/core/models.py:413 core/models.py:413
|
||||
msgid "Documents"
|
||||
msgstr "Документы"
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
|
||||
#: core/models.py:824
|
||||
#: build/lib/core/models.py:425 build/lib/core/models.py:828 core/models.py:425
|
||||
#: core/models.py:828
|
||||
msgid "Untitled Document"
|
||||
msgstr "Безымянный документ"
|
||||
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#: build/lib/core/models.py:829 core/models.py:829
|
||||
msgid "Open"
|
||||
msgstr "Открыть"
|
||||
|
||||
#: build/lib/core/models.py:864 core/models.py:864
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "{name} делится с вами документом!"
|
||||
|
||||
#: build/lib/core/models.py:863 core/models.py:863
|
||||
#: build/lib/core/models.py:868 core/models.py:868
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr "{name} приглашает вас присоединиться к следующему документу с ролью \"{role}\":"
|
||||
|
||||
#: build/lib/core/models.py:869 core/models.py:869
|
||||
#: build/lib/core/models.py:874 core/models.py:874
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr "{name} делится с вами документом: {title}"
|
||||
|
||||
#: build/lib/core/models.py:969 core/models.py:969
|
||||
#: build/lib/core/models.py:975 core/models.py:975
|
||||
msgid "Document/user link trace"
|
||||
msgstr "Трассировка связи документ/пользователь"
|
||||
|
||||
#: build/lib/core/models.py:970 core/models.py:970
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
msgid "Document/user link traces"
|
||||
msgstr "Трассировка связей документ/пользователь"
|
||||
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
#: build/lib/core/models.py:982 core/models.py:982
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr "Для этого документа/пользователя уже существует трассировка ссылки."
|
||||
|
||||
#: build/lib/core/models.py:999 core/models.py:999
|
||||
#: build/lib/core/models.py:1005 core/models.py:1005
|
||||
msgid "Document favorite"
|
||||
msgstr "Избранный документ"
|
||||
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
msgid "Document favorites"
|
||||
msgstr "Избранные документы"
|
||||
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
#: build/lib/core/models.py:1012 core/models.py:1012
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr "Этот документ уже помечен как избранный для этого пользователя."
|
||||
|
||||
#: build/lib/core/models.py:1028 core/models.py:1028
|
||||
#: build/lib/core/models.py:1034 core/models.py:1034
|
||||
msgid "Document/user relation"
|
||||
msgstr "Отношение документ/пользователь"
|
||||
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
msgid "Document/user relations"
|
||||
msgstr "Отношения документ/пользователь"
|
||||
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
msgid "This user is already in this document."
|
||||
msgstr "Этот пользователь уже имеет доступ к этому документу."
|
||||
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
#: build/lib/core/models.py:1047 core/models.py:1047
|
||||
msgid "This team is already in this document."
|
||||
msgstr "Эта команда уже имеет доступ к этому документу."
|
||||
|
||||
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
|
||||
#: core/models.py:1047 core/models.py:1516
|
||||
#: build/lib/core/models.py:1053 core/models.py:1053
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Может быть выбран либо пользователь, либо команда, но не оба варианта сразу."
|
||||
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1204 core/models.py:1204
|
||||
msgid "Document ask for access"
|
||||
msgstr "Документ запрашивает доступ"
|
||||
|
||||
#: build/lib/core/models.py:1199 core/models.py:1199
|
||||
#: build/lib/core/models.py:1205 core/models.py:1205
|
||||
msgid "Document ask for accesses"
|
||||
msgstr "Документ запрашивает доступы"
|
||||
|
||||
#: build/lib/core/models.py:1205 core/models.py:1205
|
||||
#: build/lib/core/models.py:1211 core/models.py:1211
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr "Этот пользователь уже запросил доступ к этому документу."
|
||||
|
||||
#: build/lib/core/models.py:1262 core/models.py:1262
|
||||
#: build/lib/core/models.py:1268 core/models.py:1268
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr "{name} хочет получить доступ к документу!"
|
||||
|
||||
#: build/lib/core/models.py:1266 core/models.py:1266
|
||||
#: build/lib/core/models.py:1272 core/models.py:1272
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr "{name} хочет получить доступ к следующему документу:"
|
||||
|
||||
#: build/lib/core/models.py:1272 core/models.py:1272
|
||||
#: build/lib/core/models.py:1278 core/models.py:1278
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr "{name} запрашивает доступ к документу: {title}"
|
||||
|
||||
#: build/lib/core/models.py:1314 core/models.py:1314
|
||||
#: build/lib/core/models.py:1320 core/models.py:1320
|
||||
msgid "Thread"
|
||||
msgstr "Обсуждение"
|
||||
|
||||
#: build/lib/core/models.py:1315 core/models.py:1315
|
||||
#: build/lib/core/models.py:1321 core/models.py:1321
|
||||
msgid "Threads"
|
||||
msgstr "Обсуждения"
|
||||
|
||||
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
|
||||
#: core/models.py:1318 core/models.py:1370
|
||||
#: build/lib/core/models.py:1324 build/lib/core/models.py:1376
|
||||
#: core/models.py:1324 core/models.py:1376
|
||||
msgid "Anonymous"
|
||||
msgstr "Аноним"
|
||||
|
||||
#: build/lib/core/models.py:1365 core/models.py:1365
|
||||
#: build/lib/core/models.py:1371 core/models.py:1371
|
||||
msgid "Comment"
|
||||
msgstr "Комментарий"
|
||||
|
||||
#: build/lib/core/models.py:1366 core/models.py:1366
|
||||
#: build/lib/core/models.py:1372 core/models.py:1372
|
||||
msgid "Comments"
|
||||
msgstr "Комментарии"
|
||||
|
||||
#: build/lib/core/models.py:1415 core/models.py:1415
|
||||
#: build/lib/core/models.py:1421 core/models.py:1421
|
||||
msgid "This emoji has already been reacted to this comment."
|
||||
msgstr "Этот эмодзи уже использован в этом комментарии."
|
||||
|
||||
#: build/lib/core/models.py:1419 core/models.py:1419
|
||||
#: build/lib/core/models.py:1425 core/models.py:1425
|
||||
msgid "Reaction"
|
||||
msgstr "Реакция"
|
||||
|
||||
#: build/lib/core/models.py:1420 core/models.py:1420
|
||||
#: build/lib/core/models.py:1426 core/models.py:1426
|
||||
msgid "Reactions"
|
||||
msgstr "Реакции"
|
||||
|
||||
#: build/lib/core/models.py:1431 core/models.py:1431
|
||||
msgid "description"
|
||||
msgstr "описание"
|
||||
|
||||
#: build/lib/core/models.py:1432 core/models.py:1432
|
||||
msgid "code"
|
||||
msgstr "код"
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
msgid "css"
|
||||
msgstr "css"
|
||||
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
msgid "public"
|
||||
msgstr "доступно всем"
|
||||
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr "Этот шаблон доступен всем пользователям."
|
||||
|
||||
#: build/lib/core/models.py:1443 core/models.py:1443
|
||||
msgid "Template"
|
||||
msgstr "Шаблон"
|
||||
|
||||
#: build/lib/core/models.py:1444 core/models.py:1444
|
||||
msgid "Templates"
|
||||
msgstr "Шаблоны"
|
||||
|
||||
#: build/lib/core/models.py:1497 core/models.py:1497
|
||||
msgid "Template/user relation"
|
||||
msgstr "Отношение шаблон/пользователь"
|
||||
|
||||
#: build/lib/core/models.py:1498 core/models.py:1498
|
||||
msgid "Template/user relations"
|
||||
msgstr "Отношения шаблон/пользователь"
|
||||
|
||||
#: build/lib/core/models.py:1504 core/models.py:1504
|
||||
msgid "This user is already in this template."
|
||||
msgstr "Этот пользователь уже указан в этом шаблоне."
|
||||
|
||||
#: build/lib/core/models.py:1510 core/models.py:1510
|
||||
msgid "This team is already in this template."
|
||||
msgstr "Эта команда уже указана в этом шаблоне."
|
||||
|
||||
#: build/lib/core/models.py:1587 core/models.py:1587
|
||||
#: build/lib/core/models.py:1436 core/models.py:1436
|
||||
msgid "email address"
|
||||
msgstr "адрес электронной почты"
|
||||
|
||||
#: build/lib/core/models.py:1606 core/models.py:1606
|
||||
#: build/lib/core/models.py:1455 core/models.py:1455
|
||||
msgid "Document invitation"
|
||||
msgstr "Приглашение для документа"
|
||||
|
||||
#: build/lib/core/models.py:1607 core/models.py:1607
|
||||
#: build/lib/core/models.py:1456 core/models.py:1456
|
||||
msgid "Document invitations"
|
||||
msgstr "Приглашения для документов"
|
||||
|
||||
#: build/lib/core/models.py:1627 core/models.py:1627
|
||||
#: build/lib/core/models.py:1476 core/models.py:1476
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr "Этот адрес уже связан с зарегистрированным пользователем."
|
||||
|
||||
@@ -446,17 +396,12 @@ msgstr "Этот адрес уже связан с зарегистрирова
|
||||
msgid "Logo email"
|
||||
msgstr "Логотип email"
|
||||
|
||||
#: core/templates/mail/html/template.html:200
|
||||
#: core/templates/mail/text/template.txt:10
|
||||
msgid "Open"
|
||||
msgstr "Открыть"
|
||||
|
||||
#: core/templates/mail/html/template.html:217
|
||||
#: core/templates/mail/html/template.html:219
|
||||
#: core/templates/mail/text/template.txt:14
|
||||
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
|
||||
msgstr " Docs, ваш новый инструмент для организации и совместного использования документов в вашей команде. "
|
||||
|
||||
#: core/templates/mail/html/template.html:224
|
||||
#: core/templates/mail/html/template.html:226
|
||||
#: core/templates/mail/text/template.txt:16
|
||||
#, python-format
|
||||
msgid " Brought to you by %(brandname)s "
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-12-09 11:12\n"
|
||||
"POT-Creation-Date: 2026-01-21 09:53+0000\n"
|
||||
"PO-Revision-Date: 2026-01-28 20:12\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Slovenian\n"
|
||||
"Language: sl_SI\n"
|
||||
@@ -17,20 +17,20 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 18\n"
|
||||
|
||||
#: build/lib/core/admin.py:36 core/admin.py:36
|
||||
#: build/lib/core/admin.py:28 core/admin.py:28
|
||||
msgid "Personal info"
|
||||
msgstr "Osebni podatki"
|
||||
|
||||
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
|
||||
#: core/admin.py:137
|
||||
#: build/lib/core/admin.py:41 build/lib/core/admin.py:121 core/admin.py:41
|
||||
#: core/admin.py:121
|
||||
msgid "Permissions"
|
||||
msgstr "Dovoljenja"
|
||||
|
||||
#: build/lib/core/admin.py:61 core/admin.py:61
|
||||
#: build/lib/core/admin.py:53 core/admin.py:53
|
||||
msgid "Important dates"
|
||||
msgstr "Pomembni datumi"
|
||||
|
||||
#: build/lib/core/admin.py:147 core/admin.py:147
|
||||
#: build/lib/core/admin.py:131 core/admin.py:131
|
||||
msgid "Tree structure"
|
||||
msgstr "Drevesna struktura"
|
||||
|
||||
@@ -50,40 +50,32 @@ msgstr ""
|
||||
msgid "Favorite"
|
||||
msgstr "Priljubljena"
|
||||
|
||||
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
|
||||
#: build/lib/core/api/serializers.py:505 core/api/serializers.py:505
|
||||
msgid "A new document was created on your behalf!"
|
||||
msgstr "Nov dokument je bil ustvarjen v vašem imenu!"
|
||||
|
||||
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
|
||||
#: build/lib/core/api/serializers.py:509 core/api/serializers.py:509
|
||||
msgid "You have been granted ownership of a new document:"
|
||||
msgstr "Dodeljeno vam je bilo lastništvo nad novim dokumentom:"
|
||||
|
||||
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
|
||||
#: build/lib/core/api/serializers.py:545 core/api/serializers.py:545
|
||||
msgid "This field is required."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
|
||||
#: build/lib/core/api/serializers.py:556 core/api/serializers.py:556
|
||||
#, python-format
|
||||
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:694 core/api/serializers.py:694
|
||||
msgid "Body"
|
||||
msgstr "Telo"
|
||||
|
||||
#: build/lib/core/api/serializers.py:697 core/api/serializers.py:697
|
||||
msgid "Body type"
|
||||
msgstr "Vrsta telesa"
|
||||
|
||||
#: build/lib/core/api/serializers.py:703 core/api/serializers.py:703
|
||||
msgid "Format"
|
||||
msgstr "Oblika"
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
|
||||
#: build/lib/core/api/viewsets.py:1122 core/api/viewsets.py:1122
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/apps.py:12 core/apps.py:12
|
||||
msgid "Impress core application"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
|
||||
#: core/choices.py:43
|
||||
msgid "Reader"
|
||||
@@ -143,301 +135,259 @@ msgstr "Levo"
|
||||
msgid "Right"
|
||||
msgstr "Desno"
|
||||
|
||||
#: build/lib/core/models.py:80 core/models.py:80
|
||||
#: build/lib/core/models.py:81 core/models.py:81
|
||||
msgid "id"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:81 core/models.py:81
|
||||
#: build/lib/core/models.py:82 core/models.py:82
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr "primarni ključ za zapis kot UUID"
|
||||
|
||||
#: build/lib/core/models.py:87 core/models.py:87
|
||||
#: build/lib/core/models.py:88 core/models.py:88
|
||||
msgid "created on"
|
||||
msgstr "ustvarjen na"
|
||||
|
||||
#: build/lib/core/models.py:88 core/models.py:88
|
||||
#: build/lib/core/models.py:89 core/models.py:89
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr "datum in čas, ko je bil zapis ustvarjen"
|
||||
|
||||
#: build/lib/core/models.py:93 core/models.py:93
|
||||
#: build/lib/core/models.py:94 core/models.py:94
|
||||
msgid "updated on"
|
||||
msgstr "posodobljeno dne"
|
||||
|
||||
#: build/lib/core/models.py:94 core/models.py:94
|
||||
#: build/lib/core/models.py:95 core/models.py:95
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr "datum in čas, ko je bil zapis nazadnje posodobljen"
|
||||
|
||||
#: build/lib/core/models.py:130 core/models.py:130
|
||||
#: build/lib/core/models.py:131 core/models.py:131
|
||||
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
|
||||
msgstr "Nismo mogli najti uporabnika s tem sub, vendar je e-poštni naslov že povezan z registriranim uporabnikom."
|
||||
|
||||
#: build/lib/core/models.py:141 core/models.py:141
|
||||
#: build/lib/core/models.py:142 core/models.py:142
|
||||
msgid "sub"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:142 core/models.py:142
|
||||
#: build/lib/core/models.py:143 core/models.py:143
|
||||
msgid "Required. 255 characters or fewer. ASCII characters only."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:150 core/models.py:150
|
||||
#: build/lib/core/models.py:151 core/models.py:151
|
||||
msgid "full name"
|
||||
msgstr "polno ime"
|
||||
|
||||
#: build/lib/core/models.py:152 core/models.py:152
|
||||
#: build/lib/core/models.py:153 core/models.py:153
|
||||
msgid "short name"
|
||||
msgstr "kratko ime"
|
||||
|
||||
#: build/lib/core/models.py:155 core/models.py:155
|
||||
#: build/lib/core/models.py:156 core/models.py:156
|
||||
msgid "identity email address"
|
||||
msgstr "elektronski naslov identitete"
|
||||
|
||||
#: build/lib/core/models.py:160 core/models.py:160
|
||||
#: build/lib/core/models.py:161 core/models.py:161
|
||||
msgid "admin email address"
|
||||
msgstr "elektronski naslov skrbnika"
|
||||
|
||||
#: build/lib/core/models.py:167 core/models.py:167
|
||||
#: build/lib/core/models.py:168 core/models.py:168
|
||||
msgid "language"
|
||||
msgstr "jezik"
|
||||
|
||||
#: build/lib/core/models.py:168 core/models.py:168
|
||||
#: build/lib/core/models.py:169 core/models.py:169
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr "Jezik, v katerem uporabnik želi videti vmesnik."
|
||||
|
||||
#: build/lib/core/models.py:176 core/models.py:176
|
||||
#: build/lib/core/models.py:177 core/models.py:177
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr "Časovni pas, v katerem želi uporabnik videti uro."
|
||||
|
||||
#: build/lib/core/models.py:179 core/models.py:179
|
||||
#: build/lib/core/models.py:180 core/models.py:180
|
||||
msgid "device"
|
||||
msgstr "naprava"
|
||||
|
||||
#: build/lib/core/models.py:181 core/models.py:181
|
||||
#: build/lib/core/models.py:182 core/models.py:182
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr "Ali je uporabnik naprava ali pravi uporabnik."
|
||||
|
||||
#: build/lib/core/models.py:184 core/models.py:184
|
||||
#: build/lib/core/models.py:185 core/models.py:185
|
||||
msgid "staff status"
|
||||
msgstr "kadrovski status"
|
||||
|
||||
#: build/lib/core/models.py:186 core/models.py:186
|
||||
#: build/lib/core/models.py:187 core/models.py:187
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Ali se uporabnik lahko prijavi na to skrbniško mesto."
|
||||
|
||||
#: build/lib/core/models.py:189 core/models.py:189
|
||||
#: build/lib/core/models.py:190 core/models.py:190
|
||||
msgid "active"
|
||||
msgstr "aktivni"
|
||||
|
||||
#: build/lib/core/models.py:192 core/models.py:192
|
||||
#: build/lib/core/models.py:193 core/models.py:193
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr "Ali je treba tega uporabnika obravnavati kot aktivnega. Namesto brisanja računov počistite to izbiro."
|
||||
|
||||
#: build/lib/core/models.py:204 core/models.py:204
|
||||
#: build/lib/core/models.py:205 core/models.py:205
|
||||
msgid "user"
|
||||
msgstr "uporabnik"
|
||||
|
||||
#: build/lib/core/models.py:205 core/models.py:205
|
||||
#: build/lib/core/models.py:206 core/models.py:206
|
||||
msgid "users"
|
||||
msgstr "uporabniki"
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
|
||||
#: core/models.py:361 core/models.py:1430
|
||||
#: build/lib/core/models.py:362 core/models.py:362
|
||||
msgid "title"
|
||||
msgstr "naslov"
|
||||
|
||||
#: build/lib/core/models.py:362 core/models.py:362
|
||||
#: build/lib/core/models.py:363 core/models.py:363
|
||||
msgid "excerpt"
|
||||
msgstr "odlomek"
|
||||
|
||||
#: build/lib/core/models.py:411 core/models.py:411
|
||||
#: build/lib/core/models.py:412 core/models.py:412
|
||||
msgid "Document"
|
||||
msgstr "Dokument"
|
||||
|
||||
#: build/lib/core/models.py:412 core/models.py:412
|
||||
#: build/lib/core/models.py:413 core/models.py:413
|
||||
msgid "Documents"
|
||||
msgstr "Dokumenti"
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
|
||||
#: core/models.py:824
|
||||
#: build/lib/core/models.py:425 build/lib/core/models.py:828 core/models.py:425
|
||||
#: core/models.py:828
|
||||
msgid "Untitled Document"
|
||||
msgstr "Dokument brez naslova"
|
||||
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#: build/lib/core/models.py:829 core/models.py:829
|
||||
msgid "Open"
|
||||
msgstr "Odpri"
|
||||
|
||||
#: build/lib/core/models.py:864 core/models.py:864
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "{name} je delil dokument z vami!"
|
||||
|
||||
#: build/lib/core/models.py:863 core/models.py:863
|
||||
#: build/lib/core/models.py:868 core/models.py:868
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr "{name} vas je povabil z vlogo \"{role}\" na naslednjem dokumentu:"
|
||||
|
||||
#: build/lib/core/models.py:869 core/models.py:869
|
||||
#: build/lib/core/models.py:874 core/models.py:874
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr "{name} je delil dokument z vami: {title}"
|
||||
|
||||
#: build/lib/core/models.py:969 core/models.py:969
|
||||
#: build/lib/core/models.py:975 core/models.py:975
|
||||
msgid "Document/user link trace"
|
||||
msgstr "Dokument/sled povezave uporabnika"
|
||||
|
||||
#: build/lib/core/models.py:970 core/models.py:970
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
msgid "Document/user link traces"
|
||||
msgstr "Sledi povezav dokumenta/uporabnika"
|
||||
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
#: build/lib/core/models.py:982 core/models.py:982
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr "Za ta dokument/uporabnika že obstaja sled povezave."
|
||||
|
||||
#: build/lib/core/models.py:999 core/models.py:999
|
||||
#: build/lib/core/models.py:1005 core/models.py:1005
|
||||
msgid "Document favorite"
|
||||
msgstr "Priljubljeni dokument"
|
||||
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
msgid "Document favorites"
|
||||
msgstr "Priljubljeni dokumenti"
|
||||
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
#: build/lib/core/models.py:1012 core/models.py:1012
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr "Ta dokument je že ciljno usmerjen s priljubljenim primerkom relacije za istega uporabnika."
|
||||
|
||||
#: build/lib/core/models.py:1028 core/models.py:1028
|
||||
#: build/lib/core/models.py:1034 core/models.py:1034
|
||||
msgid "Document/user relation"
|
||||
msgstr "Odnos dokument/uporabnik"
|
||||
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
msgid "Document/user relations"
|
||||
msgstr "Odnosi dokument/uporabnik"
|
||||
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
msgid "This user is already in this document."
|
||||
msgstr "Ta uporabnik je že v tem dokumentu."
|
||||
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
#: build/lib/core/models.py:1047 core/models.py:1047
|
||||
msgid "This team is already in this document."
|
||||
msgstr "Ta ekipa je že v tem dokumentu."
|
||||
|
||||
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
|
||||
#: core/models.py:1047 core/models.py:1516
|
||||
#: build/lib/core/models.py:1053 core/models.py:1053
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Nastaviti je treba bodisi uporabnika ali ekipo, a ne obojega."
|
||||
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1204 core/models.py:1204
|
||||
msgid "Document ask for access"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1199 core/models.py:1199
|
||||
#: build/lib/core/models.py:1205 core/models.py:1205
|
||||
msgid "Document ask for accesses"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1205 core/models.py:1205
|
||||
#: build/lib/core/models.py:1211 core/models.py:1211
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1262 core/models.py:1262
|
||||
#: build/lib/core/models.py:1268 core/models.py:1268
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1266 core/models.py:1266
|
||||
#: build/lib/core/models.py:1272 core/models.py:1272
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1272 core/models.py:1272
|
||||
#: build/lib/core/models.py:1278 core/models.py:1278
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1314 core/models.py:1314
|
||||
#: build/lib/core/models.py:1320 core/models.py:1320
|
||||
msgid "Thread"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1315 core/models.py:1315
|
||||
#: build/lib/core/models.py:1321 core/models.py:1321
|
||||
msgid "Threads"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
|
||||
#: core/models.py:1318 core/models.py:1370
|
||||
#: build/lib/core/models.py:1324 build/lib/core/models.py:1376
|
||||
#: core/models.py:1324 core/models.py:1376
|
||||
msgid "Anonymous"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1365 core/models.py:1365
|
||||
#: build/lib/core/models.py:1371 core/models.py:1371
|
||||
msgid "Comment"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1366 core/models.py:1366
|
||||
#: build/lib/core/models.py:1372 core/models.py:1372
|
||||
msgid "Comments"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1415 core/models.py:1415
|
||||
#: build/lib/core/models.py:1421 core/models.py:1421
|
||||
msgid "This emoji has already been reacted to this comment."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1419 core/models.py:1419
|
||||
#: build/lib/core/models.py:1425 core/models.py:1425
|
||||
msgid "Reaction"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1420 core/models.py:1420
|
||||
#: build/lib/core/models.py:1426 core/models.py:1426
|
||||
msgid "Reactions"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1431 core/models.py:1431
|
||||
msgid "description"
|
||||
msgstr "opis"
|
||||
|
||||
#: build/lib/core/models.py:1432 core/models.py:1432
|
||||
msgid "code"
|
||||
msgstr "koda"
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
msgid "css"
|
||||
msgstr "css"
|
||||
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
msgid "public"
|
||||
msgstr "javno"
|
||||
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr "Ali je ta predloga javna za uporabo."
|
||||
|
||||
#: build/lib/core/models.py:1443 core/models.py:1443
|
||||
msgid "Template"
|
||||
msgstr "Predloga"
|
||||
|
||||
#: build/lib/core/models.py:1444 core/models.py:1444
|
||||
msgid "Templates"
|
||||
msgstr "Predloge"
|
||||
|
||||
#: build/lib/core/models.py:1497 core/models.py:1497
|
||||
msgid "Template/user relation"
|
||||
msgstr "Odnos predloga/uporabnik"
|
||||
|
||||
#: build/lib/core/models.py:1498 core/models.py:1498
|
||||
msgid "Template/user relations"
|
||||
msgstr "Odnosi med predlogo in uporabnikom"
|
||||
|
||||
#: build/lib/core/models.py:1504 core/models.py:1504
|
||||
msgid "This user is already in this template."
|
||||
msgstr "Ta uporabnik je že v tej predlogi."
|
||||
|
||||
#: build/lib/core/models.py:1510 core/models.py:1510
|
||||
msgid "This team is already in this template."
|
||||
msgstr "Ta ekipa je že v tej predlogi."
|
||||
|
||||
#: build/lib/core/models.py:1587 core/models.py:1587
|
||||
#: build/lib/core/models.py:1436 core/models.py:1436
|
||||
msgid "email address"
|
||||
msgstr "elektronski naslov"
|
||||
|
||||
#: build/lib/core/models.py:1606 core/models.py:1606
|
||||
#: build/lib/core/models.py:1455 core/models.py:1455
|
||||
msgid "Document invitation"
|
||||
msgstr "Vabilo na dokument"
|
||||
|
||||
#: build/lib/core/models.py:1607 core/models.py:1607
|
||||
#: build/lib/core/models.py:1456 core/models.py:1456
|
||||
msgid "Document invitations"
|
||||
msgstr "Vabila na dokument"
|
||||
|
||||
#: build/lib/core/models.py:1627 core/models.py:1627
|
||||
#: build/lib/core/models.py:1476 core/models.py:1476
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr "Ta e-poštni naslov je že povezan z registriranim uporabnikom."
|
||||
|
||||
@@ -446,17 +396,12 @@ msgstr "Ta e-poštni naslov je že povezan z registriranim uporabnikom."
|
||||
msgid "Logo email"
|
||||
msgstr "E-pošta z logotipom"
|
||||
|
||||
#: core/templates/mail/html/template.html:200
|
||||
#: core/templates/mail/text/template.txt:10
|
||||
msgid "Open"
|
||||
msgstr "Odpri"
|
||||
|
||||
#: core/templates/mail/html/template.html:217
|
||||
#: core/templates/mail/html/template.html:219
|
||||
#: core/templates/mail/text/template.txt:14
|
||||
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
|
||||
msgstr " Dokumenti, vaše novo bistveno orodje za organiziranje, skupno rabo in skupinsko sodelovanje pri dokumentih. "
|
||||
|
||||
#: core/templates/mail/html/template.html:224
|
||||
#: core/templates/mail/html/template.html:226
|
||||
#: core/templates/mail/text/template.txt:16
|
||||
#, python-format
|
||||
msgid " Brought to you by %(brandname)s "
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-12-09 11:12\n"
|
||||
"POT-Creation-Date: 2026-01-21 09:53+0000\n"
|
||||
"PO-Revision-Date: 2026-01-28 20:12\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Swedish\n"
|
||||
"Language: sv_SE\n"
|
||||
@@ -17,20 +17,20 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 18\n"
|
||||
|
||||
#: build/lib/core/admin.py:36 core/admin.py:36
|
||||
#: build/lib/core/admin.py:28 core/admin.py:28
|
||||
msgid "Personal info"
|
||||
msgstr "Personuppgifter"
|
||||
|
||||
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
|
||||
#: core/admin.py:137
|
||||
#: build/lib/core/admin.py:41 build/lib/core/admin.py:121 core/admin.py:41
|
||||
#: core/admin.py:121
|
||||
msgid "Permissions"
|
||||
msgstr "Behörigheter"
|
||||
|
||||
#: build/lib/core/admin.py:61 core/admin.py:61
|
||||
#: build/lib/core/admin.py:53 core/admin.py:53
|
||||
msgid "Important dates"
|
||||
msgstr "Viktiga datum"
|
||||
|
||||
#: build/lib/core/admin.py:147 core/admin.py:147
|
||||
#: build/lib/core/admin.py:131 core/admin.py:131
|
||||
msgid "Tree structure"
|
||||
msgstr ""
|
||||
|
||||
@@ -50,40 +50,32 @@ msgstr ""
|
||||
msgid "Favorite"
|
||||
msgstr "Favoriter"
|
||||
|
||||
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
|
||||
#: build/lib/core/api/serializers.py:505 core/api/serializers.py:505
|
||||
msgid "A new document was created on your behalf!"
|
||||
msgstr "Ett nytt dokument skapades åt dig!"
|
||||
|
||||
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
|
||||
#: build/lib/core/api/serializers.py:509 core/api/serializers.py:509
|
||||
msgid "You have been granted ownership of a new document:"
|
||||
msgstr "Du har beviljats äganderätt till ett nytt dokument:"
|
||||
|
||||
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
|
||||
#: build/lib/core/api/serializers.py:545 core/api/serializers.py:545
|
||||
msgid "This field is required."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
|
||||
#: build/lib/core/api/serializers.py:556 core/api/serializers.py:556
|
||||
#, python-format
|
||||
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:694 core/api/serializers.py:694
|
||||
msgid "Body"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:697 core/api/serializers.py:697
|
||||
msgid "Body type"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:703 core/api/serializers.py:703
|
||||
msgid "Format"
|
||||
msgstr "Format"
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
|
||||
#: build/lib/core/api/viewsets.py:1122 core/api/viewsets.py:1122
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/apps.py:12 core/apps.py:12
|
||||
msgid "Impress core application"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
|
||||
#: core/choices.py:43
|
||||
msgid "Reader"
|
||||
@@ -143,301 +135,259 @@ msgstr ""
|
||||
msgid "Right"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:80 core/models.py:80
|
||||
#: build/lib/core/models.py:81 core/models.py:81
|
||||
msgid "id"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:81 core/models.py:81
|
||||
#: build/lib/core/models.py:82 core/models.py:82
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:87 core/models.py:87
|
||||
#: build/lib/core/models.py:88 core/models.py:88
|
||||
msgid "created on"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:88 core/models.py:88
|
||||
#: build/lib/core/models.py:89 core/models.py:89
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:93 core/models.py:93
|
||||
#: build/lib/core/models.py:94 core/models.py:94
|
||||
msgid "updated on"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:94 core/models.py:94
|
||||
#: build/lib/core/models.py:95 core/models.py:95
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:130 core/models.py:130
|
||||
#: build/lib/core/models.py:131 core/models.py:131
|
||||
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:141 core/models.py:141
|
||||
#: build/lib/core/models.py:142 core/models.py:142
|
||||
msgid "sub"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:142 core/models.py:142
|
||||
#: build/lib/core/models.py:143 core/models.py:143
|
||||
msgid "Required. 255 characters or fewer. ASCII characters only."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:150 core/models.py:150
|
||||
#: build/lib/core/models.py:151 core/models.py:151
|
||||
msgid "full name"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:152 core/models.py:152
|
||||
#: build/lib/core/models.py:153 core/models.py:153
|
||||
msgid "short name"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:155 core/models.py:155
|
||||
#: build/lib/core/models.py:156 core/models.py:156
|
||||
msgid "identity email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:160 core/models.py:160
|
||||
#: build/lib/core/models.py:161 core/models.py:161
|
||||
msgid "admin email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:167 core/models.py:167
|
||||
#: build/lib/core/models.py:168 core/models.py:168
|
||||
msgid "language"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:168 core/models.py:168
|
||||
#: build/lib/core/models.py:169 core/models.py:169
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:176 core/models.py:176
|
||||
#: build/lib/core/models.py:177 core/models.py:177
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:179 core/models.py:179
|
||||
#: build/lib/core/models.py:180 core/models.py:180
|
||||
msgid "device"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:181 core/models.py:181
|
||||
#: build/lib/core/models.py:182 core/models.py:182
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:184 core/models.py:184
|
||||
#: build/lib/core/models.py:185 core/models.py:185
|
||||
msgid "staff status"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:186 core/models.py:186
|
||||
#: build/lib/core/models.py:187 core/models.py:187
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:189 core/models.py:189
|
||||
#: build/lib/core/models.py:190 core/models.py:190
|
||||
msgid "active"
|
||||
msgstr "aktiv"
|
||||
|
||||
#: build/lib/core/models.py:192 core/models.py:192
|
||||
#: build/lib/core/models.py:193 core/models.py:193
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:204 core/models.py:204
|
||||
#: build/lib/core/models.py:205 core/models.py:205
|
||||
msgid "user"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:205 core/models.py:205
|
||||
#: build/lib/core/models.py:206 core/models.py:206
|
||||
msgid "users"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
|
||||
#: core/models.py:361 core/models.py:1430
|
||||
#: build/lib/core/models.py:362 core/models.py:362
|
||||
msgid "title"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:362 core/models.py:362
|
||||
#: build/lib/core/models.py:363 core/models.py:363
|
||||
msgid "excerpt"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:411 core/models.py:411
|
||||
#: build/lib/core/models.py:412 core/models.py:412
|
||||
msgid "Document"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:412 core/models.py:412
|
||||
#: build/lib/core/models.py:413 core/models.py:413
|
||||
msgid "Documents"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
|
||||
#: core/models.py:824
|
||||
#: build/lib/core/models.py:425 build/lib/core/models.py:828 core/models.py:425
|
||||
#: core/models.py:828
|
||||
msgid "Untitled Document"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#: build/lib/core/models.py:829 core/models.py:829
|
||||
msgid "Open"
|
||||
msgstr "Öppna"
|
||||
|
||||
#: build/lib/core/models.py:864 core/models.py:864
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:863 core/models.py:863
|
||||
#: build/lib/core/models.py:868 core/models.py:868
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:869 core/models.py:869
|
||||
#: build/lib/core/models.py:874 core/models.py:874
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:969 core/models.py:969
|
||||
#: build/lib/core/models.py:975 core/models.py:975
|
||||
msgid "Document/user link trace"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:970 core/models.py:970
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
msgid "Document/user link traces"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
#: build/lib/core/models.py:982 core/models.py:982
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:999 core/models.py:999
|
||||
#: build/lib/core/models.py:1005 core/models.py:1005
|
||||
msgid "Document favorite"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
msgid "Document favorites"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
#: build/lib/core/models.py:1012 core/models.py:1012
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1028 core/models.py:1028
|
||||
#: build/lib/core/models.py:1034 core/models.py:1034
|
||||
msgid "Document/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
msgid "Document/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
msgid "This user is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
#: build/lib/core/models.py:1047 core/models.py:1047
|
||||
msgid "This team is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
|
||||
#: core/models.py:1047 core/models.py:1516
|
||||
#: build/lib/core/models.py:1053 core/models.py:1053
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1204 core/models.py:1204
|
||||
msgid "Document ask for access"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1199 core/models.py:1199
|
||||
#: build/lib/core/models.py:1205 core/models.py:1205
|
||||
msgid "Document ask for accesses"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1205 core/models.py:1205
|
||||
#: build/lib/core/models.py:1211 core/models.py:1211
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1262 core/models.py:1262
|
||||
#: build/lib/core/models.py:1268 core/models.py:1268
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1266 core/models.py:1266
|
||||
#: build/lib/core/models.py:1272 core/models.py:1272
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1272 core/models.py:1272
|
||||
#: build/lib/core/models.py:1278 core/models.py:1278
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1314 core/models.py:1314
|
||||
#: build/lib/core/models.py:1320 core/models.py:1320
|
||||
msgid "Thread"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1315 core/models.py:1315
|
||||
#: build/lib/core/models.py:1321 core/models.py:1321
|
||||
msgid "Threads"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
|
||||
#: core/models.py:1318 core/models.py:1370
|
||||
#: build/lib/core/models.py:1324 build/lib/core/models.py:1376
|
||||
#: core/models.py:1324 core/models.py:1376
|
||||
msgid "Anonymous"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1365 core/models.py:1365
|
||||
#: build/lib/core/models.py:1371 core/models.py:1371
|
||||
msgid "Comment"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1366 core/models.py:1366
|
||||
#: build/lib/core/models.py:1372 core/models.py:1372
|
||||
msgid "Comments"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1415 core/models.py:1415
|
||||
#: build/lib/core/models.py:1421 core/models.py:1421
|
||||
msgid "This emoji has already been reacted to this comment."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1419 core/models.py:1419
|
||||
#: build/lib/core/models.py:1425 core/models.py:1425
|
||||
msgid "Reaction"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1420 core/models.py:1420
|
||||
#: build/lib/core/models.py:1426 core/models.py:1426
|
||||
msgid "Reactions"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1431 core/models.py:1431
|
||||
msgid "description"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1432 core/models.py:1432
|
||||
msgid "code"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
msgid "css"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
msgid "public"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1443 core/models.py:1443
|
||||
msgid "Template"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1444 core/models.py:1444
|
||||
msgid "Templates"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1497 core/models.py:1497
|
||||
msgid "Template/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1498 core/models.py:1498
|
||||
msgid "Template/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1504 core/models.py:1504
|
||||
msgid "This user is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1510 core/models.py:1510
|
||||
msgid "This team is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1587 core/models.py:1587
|
||||
#: build/lib/core/models.py:1436 core/models.py:1436
|
||||
msgid "email address"
|
||||
msgstr "e-postadress"
|
||||
|
||||
#: build/lib/core/models.py:1606 core/models.py:1606
|
||||
#: build/lib/core/models.py:1455 core/models.py:1455
|
||||
msgid "Document invitation"
|
||||
msgstr "Bjud in dokument"
|
||||
|
||||
#: build/lib/core/models.py:1607 core/models.py:1607
|
||||
#: build/lib/core/models.py:1456 core/models.py:1456
|
||||
msgid "Document invitations"
|
||||
msgstr "Inbjudningar dokument"
|
||||
|
||||
#: build/lib/core/models.py:1627 core/models.py:1627
|
||||
#: build/lib/core/models.py:1476 core/models.py:1476
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr "Denna e-postadress är redan associerad med en registrerad användare."
|
||||
|
||||
@@ -446,17 +396,12 @@ msgstr "Denna e-postadress är redan associerad med en registrerad användare."
|
||||
msgid "Logo email"
|
||||
msgstr "Logotyp e-post"
|
||||
|
||||
#: core/templates/mail/html/template.html:200
|
||||
#: core/templates/mail/text/template.txt:10
|
||||
msgid "Open"
|
||||
msgstr "Öppna"
|
||||
|
||||
#: core/templates/mail/html/template.html:217
|
||||
#: core/templates/mail/html/template.html:219
|
||||
#: core/templates/mail/text/template.txt:14
|
||||
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/template.html:224
|
||||
#: core/templates/mail/html/template.html:226
|
||||
#: core/templates/mail/text/template.txt:16
|
||||
#, python-format
|
||||
msgid " Brought to you by %(brandname)s "
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-12-09 11:12\n"
|
||||
"POT-Creation-Date: 2026-01-21 09:53+0000\n"
|
||||
"PO-Revision-Date: 2026-01-28 20:12\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Turkish\n"
|
||||
"Language: tr_TR\n"
|
||||
@@ -17,20 +17,20 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 18\n"
|
||||
|
||||
#: build/lib/core/admin.py:36 core/admin.py:36
|
||||
#: build/lib/core/admin.py:28 core/admin.py:28
|
||||
msgid "Personal info"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
|
||||
#: core/admin.py:137
|
||||
#: build/lib/core/admin.py:41 build/lib/core/admin.py:121 core/admin.py:41
|
||||
#: core/admin.py:121
|
||||
msgid "Permissions"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/admin.py:61 core/admin.py:61
|
||||
#: build/lib/core/admin.py:53 core/admin.py:53
|
||||
msgid "Important dates"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/admin.py:147 core/admin.py:147
|
||||
#: build/lib/core/admin.py:131 core/admin.py:131
|
||||
msgid "Tree structure"
|
||||
msgstr ""
|
||||
|
||||
@@ -50,40 +50,32 @@ msgstr ""
|
||||
msgid "Favorite"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
|
||||
#: build/lib/core/api/serializers.py:505 core/api/serializers.py:505
|
||||
msgid "A new document was created on your behalf!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
|
||||
#: build/lib/core/api/serializers.py:509 core/api/serializers.py:509
|
||||
msgid "You have been granted ownership of a new document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
|
||||
#: build/lib/core/api/serializers.py:545 core/api/serializers.py:545
|
||||
msgid "This field is required."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
|
||||
#: build/lib/core/api/serializers.py:556 core/api/serializers.py:556
|
||||
#, python-format
|
||||
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:694 core/api/serializers.py:694
|
||||
msgid "Body"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:697 core/api/serializers.py:697
|
||||
msgid "Body type"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:703 core/api/serializers.py:703
|
||||
msgid "Format"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
|
||||
#: build/lib/core/api/viewsets.py:1122 core/api/viewsets.py:1122
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/apps.py:12 core/apps.py:12
|
||||
msgid "Impress core application"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
|
||||
#: core/choices.py:43
|
||||
msgid "Reader"
|
||||
@@ -143,301 +135,259 @@ msgstr ""
|
||||
msgid "Right"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:80 core/models.py:80
|
||||
#: build/lib/core/models.py:81 core/models.py:81
|
||||
msgid "id"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:81 core/models.py:81
|
||||
#: build/lib/core/models.py:82 core/models.py:82
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:87 core/models.py:87
|
||||
#: build/lib/core/models.py:88 core/models.py:88
|
||||
msgid "created on"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:88 core/models.py:88
|
||||
#: build/lib/core/models.py:89 core/models.py:89
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:93 core/models.py:93
|
||||
#: build/lib/core/models.py:94 core/models.py:94
|
||||
msgid "updated on"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:94 core/models.py:94
|
||||
#: build/lib/core/models.py:95 core/models.py:95
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:130 core/models.py:130
|
||||
#: build/lib/core/models.py:131 core/models.py:131
|
||||
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:141 core/models.py:141
|
||||
#: build/lib/core/models.py:142 core/models.py:142
|
||||
msgid "sub"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:142 core/models.py:142
|
||||
#: build/lib/core/models.py:143 core/models.py:143
|
||||
msgid "Required. 255 characters or fewer. ASCII characters only."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:150 core/models.py:150
|
||||
#: build/lib/core/models.py:151 core/models.py:151
|
||||
msgid "full name"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:152 core/models.py:152
|
||||
#: build/lib/core/models.py:153 core/models.py:153
|
||||
msgid "short name"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:155 core/models.py:155
|
||||
#: build/lib/core/models.py:156 core/models.py:156
|
||||
msgid "identity email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:160 core/models.py:160
|
||||
#: build/lib/core/models.py:161 core/models.py:161
|
||||
msgid "admin email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:167 core/models.py:167
|
||||
#: build/lib/core/models.py:168 core/models.py:168
|
||||
msgid "language"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:168 core/models.py:168
|
||||
#: build/lib/core/models.py:169 core/models.py:169
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:176 core/models.py:176
|
||||
#: build/lib/core/models.py:177 core/models.py:177
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:179 core/models.py:179
|
||||
#: build/lib/core/models.py:180 core/models.py:180
|
||||
msgid "device"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:181 core/models.py:181
|
||||
#: build/lib/core/models.py:182 core/models.py:182
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:184 core/models.py:184
|
||||
#: build/lib/core/models.py:185 core/models.py:185
|
||||
msgid "staff status"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:186 core/models.py:186
|
||||
#: build/lib/core/models.py:187 core/models.py:187
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:189 core/models.py:189
|
||||
#: build/lib/core/models.py:190 core/models.py:190
|
||||
msgid "active"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:192 core/models.py:192
|
||||
#: build/lib/core/models.py:193 core/models.py:193
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:204 core/models.py:204
|
||||
#: build/lib/core/models.py:205 core/models.py:205
|
||||
msgid "user"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:205 core/models.py:205
|
||||
#: build/lib/core/models.py:206 core/models.py:206
|
||||
msgid "users"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
|
||||
#: core/models.py:361 core/models.py:1430
|
||||
#: build/lib/core/models.py:362 core/models.py:362
|
||||
msgid "title"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:362 core/models.py:362
|
||||
#: build/lib/core/models.py:363 core/models.py:363
|
||||
msgid "excerpt"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:411 core/models.py:411
|
||||
#: build/lib/core/models.py:412 core/models.py:412
|
||||
msgid "Document"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:412 core/models.py:412
|
||||
#: build/lib/core/models.py:413 core/models.py:413
|
||||
msgid "Documents"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
|
||||
#: core/models.py:824
|
||||
#: build/lib/core/models.py:425 build/lib/core/models.py:828 core/models.py:425
|
||||
#: core/models.py:828
|
||||
msgid "Untitled Document"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#: build/lib/core/models.py:829 core/models.py:829
|
||||
msgid "Open"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:864 core/models.py:864
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:863 core/models.py:863
|
||||
#: build/lib/core/models.py:868 core/models.py:868
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:869 core/models.py:869
|
||||
#: build/lib/core/models.py:874 core/models.py:874
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:969 core/models.py:969
|
||||
#: build/lib/core/models.py:975 core/models.py:975
|
||||
msgid "Document/user link trace"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:970 core/models.py:970
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
msgid "Document/user link traces"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
#: build/lib/core/models.py:982 core/models.py:982
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:999 core/models.py:999
|
||||
#: build/lib/core/models.py:1005 core/models.py:1005
|
||||
msgid "Document favorite"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
msgid "Document favorites"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
#: build/lib/core/models.py:1012 core/models.py:1012
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1028 core/models.py:1028
|
||||
#: build/lib/core/models.py:1034 core/models.py:1034
|
||||
msgid "Document/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
msgid "Document/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
msgid "This user is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
#: build/lib/core/models.py:1047 core/models.py:1047
|
||||
msgid "This team is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
|
||||
#: core/models.py:1047 core/models.py:1516
|
||||
#: build/lib/core/models.py:1053 core/models.py:1053
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1204 core/models.py:1204
|
||||
msgid "Document ask for access"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1199 core/models.py:1199
|
||||
#: build/lib/core/models.py:1205 core/models.py:1205
|
||||
msgid "Document ask for accesses"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1205 core/models.py:1205
|
||||
#: build/lib/core/models.py:1211 core/models.py:1211
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1262 core/models.py:1262
|
||||
#: build/lib/core/models.py:1268 core/models.py:1268
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1266 core/models.py:1266
|
||||
#: build/lib/core/models.py:1272 core/models.py:1272
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1272 core/models.py:1272
|
||||
#: build/lib/core/models.py:1278 core/models.py:1278
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1314 core/models.py:1314
|
||||
#: build/lib/core/models.py:1320 core/models.py:1320
|
||||
msgid "Thread"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1315 core/models.py:1315
|
||||
#: build/lib/core/models.py:1321 core/models.py:1321
|
||||
msgid "Threads"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
|
||||
#: core/models.py:1318 core/models.py:1370
|
||||
#: build/lib/core/models.py:1324 build/lib/core/models.py:1376
|
||||
#: core/models.py:1324 core/models.py:1376
|
||||
msgid "Anonymous"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1365 core/models.py:1365
|
||||
#: build/lib/core/models.py:1371 core/models.py:1371
|
||||
msgid "Comment"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1366 core/models.py:1366
|
||||
#: build/lib/core/models.py:1372 core/models.py:1372
|
||||
msgid "Comments"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1415 core/models.py:1415
|
||||
#: build/lib/core/models.py:1421 core/models.py:1421
|
||||
msgid "This emoji has already been reacted to this comment."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1419 core/models.py:1419
|
||||
#: build/lib/core/models.py:1425 core/models.py:1425
|
||||
msgid "Reaction"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1420 core/models.py:1420
|
||||
#: build/lib/core/models.py:1426 core/models.py:1426
|
||||
msgid "Reactions"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1431 core/models.py:1431
|
||||
msgid "description"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1432 core/models.py:1432
|
||||
msgid "code"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
msgid "css"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
msgid "public"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1443 core/models.py:1443
|
||||
msgid "Template"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1444 core/models.py:1444
|
||||
msgid "Templates"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1497 core/models.py:1497
|
||||
msgid "Template/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1498 core/models.py:1498
|
||||
msgid "Template/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1504 core/models.py:1504
|
||||
msgid "This user is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1510 core/models.py:1510
|
||||
msgid "This team is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1587 core/models.py:1587
|
||||
#: build/lib/core/models.py:1436 core/models.py:1436
|
||||
msgid "email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1606 core/models.py:1606
|
||||
#: build/lib/core/models.py:1455 core/models.py:1455
|
||||
msgid "Document invitation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1607 core/models.py:1607
|
||||
#: build/lib/core/models.py:1456 core/models.py:1456
|
||||
msgid "Document invitations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1627 core/models.py:1627
|
||||
#: build/lib/core/models.py:1476 core/models.py:1476
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr ""
|
||||
|
||||
@@ -446,17 +396,12 @@ msgstr ""
|
||||
msgid "Logo email"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/template.html:200
|
||||
#: core/templates/mail/text/template.txt:10
|
||||
msgid "Open"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/template.html:217
|
||||
#: core/templates/mail/html/template.html:219
|
||||
#: core/templates/mail/text/template.txt:14
|
||||
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/template.html:224
|
||||
#: core/templates/mail/html/template.html:226
|
||||
#: core/templates/mail/text/template.txt:16
|
||||
#, python-format
|
||||
msgid " Brought to you by %(brandname)s "
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-12-09 11:12\n"
|
||||
"POT-Creation-Date: 2026-01-21 09:53+0000\n"
|
||||
"PO-Revision-Date: 2026-01-28 20:12\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Ukrainian\n"
|
||||
"Language: uk_UA\n"
|
||||
@@ -17,20 +17,20 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 18\n"
|
||||
|
||||
#: build/lib/core/admin.py:36 core/admin.py:36
|
||||
#: build/lib/core/admin.py:28 core/admin.py:28
|
||||
msgid "Personal info"
|
||||
msgstr "Особисті дані"
|
||||
|
||||
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
|
||||
#: core/admin.py:137
|
||||
#: build/lib/core/admin.py:41 build/lib/core/admin.py:121 core/admin.py:41
|
||||
#: core/admin.py:121
|
||||
msgid "Permissions"
|
||||
msgstr "Дозволи"
|
||||
|
||||
#: build/lib/core/admin.py:61 core/admin.py:61
|
||||
#: build/lib/core/admin.py:53 core/admin.py:53
|
||||
msgid "Important dates"
|
||||
msgstr "Важливі дати"
|
||||
|
||||
#: build/lib/core/admin.py:147 core/admin.py:147
|
||||
#: build/lib/core/admin.py:131 core/admin.py:131
|
||||
msgid "Tree structure"
|
||||
msgstr "Ієрархічна структура"
|
||||
|
||||
@@ -50,40 +50,32 @@ msgstr "Приховано"
|
||||
msgid "Favorite"
|
||||
msgstr "Обране"
|
||||
|
||||
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
|
||||
#: build/lib/core/api/serializers.py:505 core/api/serializers.py:505
|
||||
msgid "A new document was created on your behalf!"
|
||||
msgstr "Новий документ був створений від вашого імені!"
|
||||
|
||||
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
|
||||
#: build/lib/core/api/serializers.py:509 core/api/serializers.py:509
|
||||
msgid "You have been granted ownership of a new document:"
|
||||
msgstr "Ви тепер є власником нового документа:"
|
||||
|
||||
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
|
||||
#: build/lib/core/api/serializers.py:545 core/api/serializers.py:545
|
||||
msgid "This field is required."
|
||||
msgstr "Це поле є обов’язковим."
|
||||
|
||||
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
|
||||
#: build/lib/core/api/serializers.py:556 core/api/serializers.py:556
|
||||
#, python-format
|
||||
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
|
||||
msgstr "Доступ до посилання '%(link_reach)s' заборонено на основі конфігурації батьківського документа."
|
||||
|
||||
#: build/lib/core/api/serializers.py:694 core/api/serializers.py:694
|
||||
msgid "Body"
|
||||
msgstr "Вміст"
|
||||
|
||||
#: build/lib/core/api/serializers.py:697 core/api/serializers.py:697
|
||||
msgid "Body type"
|
||||
msgstr "Тип вмісту"
|
||||
|
||||
#: build/lib/core/api/serializers.py:703 core/api/serializers.py:703
|
||||
msgid "Format"
|
||||
msgstr "Формат"
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
|
||||
#: build/lib/core/api/viewsets.py:1122 core/api/viewsets.py:1122
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "копія {title}"
|
||||
|
||||
#: build/lib/core/apps.py:12 core/apps.py:12
|
||||
msgid "Impress core application"
|
||||
msgstr "Ядро додатку Impress"
|
||||
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
|
||||
#: core/choices.py:43
|
||||
msgid "Reader"
|
||||
@@ -143,301 +135,259 @@ msgstr "Ліворуч"
|
||||
msgid "Right"
|
||||
msgstr "Праворуч"
|
||||
|
||||
#: build/lib/core/models.py:80 core/models.py:80
|
||||
#: build/lib/core/models.py:81 core/models.py:81
|
||||
msgid "id"
|
||||
msgstr "id"
|
||||
|
||||
#: build/lib/core/models.py:81 core/models.py:81
|
||||
#: build/lib/core/models.py:82 core/models.py:82
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr "первинний ключ для запису як UUID"
|
||||
|
||||
#: build/lib/core/models.py:87 core/models.py:87
|
||||
#: build/lib/core/models.py:88 core/models.py:88
|
||||
msgid "created on"
|
||||
msgstr "створено"
|
||||
|
||||
#: build/lib/core/models.py:88 core/models.py:88
|
||||
#: build/lib/core/models.py:89 core/models.py:89
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr "дата і час, коли запис було створено"
|
||||
|
||||
#: build/lib/core/models.py:93 core/models.py:93
|
||||
#: build/lib/core/models.py:94 core/models.py:94
|
||||
msgid "updated on"
|
||||
msgstr "оновлено"
|
||||
|
||||
#: build/lib/core/models.py:94 core/models.py:94
|
||||
#: build/lib/core/models.py:95 core/models.py:95
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr "дата і час, коли запис був востаннє оновлений"
|
||||
|
||||
#: build/lib/core/models.py:130 core/models.py:130
|
||||
#: build/lib/core/models.py:131 core/models.py:131
|
||||
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
|
||||
msgstr "Ми не змогли знайти користувача з цими даними, але адреса вже пов'язана з зареєстрованим користувачем."
|
||||
|
||||
#: build/lib/core/models.py:141 core/models.py:141
|
||||
#: build/lib/core/models.py:142 core/models.py:142
|
||||
msgid "sub"
|
||||
msgstr "вкладений документ"
|
||||
|
||||
#: build/lib/core/models.py:142 core/models.py:142
|
||||
#: build/lib/core/models.py:143 core/models.py:143
|
||||
msgid "Required. 255 characters or fewer. ASCII characters only."
|
||||
msgstr "Обов'язкове. 255 символів або менше. Тільки символи ASCII."
|
||||
|
||||
#: build/lib/core/models.py:150 core/models.py:150
|
||||
#: build/lib/core/models.py:151 core/models.py:151
|
||||
msgid "full name"
|
||||
msgstr "повне ім'я"
|
||||
|
||||
#: build/lib/core/models.py:152 core/models.py:152
|
||||
#: build/lib/core/models.py:153 core/models.py:153
|
||||
msgid "short name"
|
||||
msgstr "коротке ім'я"
|
||||
|
||||
#: build/lib/core/models.py:155 core/models.py:155
|
||||
#: build/lib/core/models.py:156 core/models.py:156
|
||||
msgid "identity email address"
|
||||
msgstr "адреса електронної пошти особи"
|
||||
|
||||
#: build/lib/core/models.py:160 core/models.py:160
|
||||
#: build/lib/core/models.py:161 core/models.py:161
|
||||
msgid "admin email address"
|
||||
msgstr "електронна адреса адміністратора"
|
||||
|
||||
#: build/lib/core/models.py:167 core/models.py:167
|
||||
#: build/lib/core/models.py:168 core/models.py:168
|
||||
msgid "language"
|
||||
msgstr "мова"
|
||||
|
||||
#: build/lib/core/models.py:168 core/models.py:168
|
||||
#: build/lib/core/models.py:169 core/models.py:169
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr "Мова, якою користувач хоче бачити інтерфейс."
|
||||
|
||||
#: build/lib/core/models.py:176 core/models.py:176
|
||||
#: build/lib/core/models.py:177 core/models.py:177
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr "Часовий пояс, в якому користувач хоче бачити час."
|
||||
|
||||
#: build/lib/core/models.py:179 core/models.py:179
|
||||
#: build/lib/core/models.py:180 core/models.py:180
|
||||
msgid "device"
|
||||
msgstr "пристрій"
|
||||
|
||||
#: build/lib/core/models.py:181 core/models.py:181
|
||||
#: build/lib/core/models.py:182 core/models.py:182
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr "Чи є користувач пристроєм чи реальним користувачем."
|
||||
|
||||
#: build/lib/core/models.py:184 core/models.py:184
|
||||
#: build/lib/core/models.py:185 core/models.py:185
|
||||
msgid "staff status"
|
||||
msgstr "статус співробітника"
|
||||
|
||||
#: build/lib/core/models.py:186 core/models.py:186
|
||||
#: build/lib/core/models.py:187 core/models.py:187
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Чи може користувач увійти на цей сайт адміністратора."
|
||||
|
||||
#: build/lib/core/models.py:189 core/models.py:189
|
||||
#: build/lib/core/models.py:190 core/models.py:190
|
||||
msgid "active"
|
||||
msgstr "активний"
|
||||
|
||||
#: build/lib/core/models.py:192 core/models.py:192
|
||||
#: build/lib/core/models.py:193 core/models.py:193
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr "Чи слід ставитися до цього користувача як до активного. Зніміть вибір замість видалення облікового запису."
|
||||
|
||||
#: build/lib/core/models.py:204 core/models.py:204
|
||||
#: build/lib/core/models.py:205 core/models.py:205
|
||||
msgid "user"
|
||||
msgstr "користувач"
|
||||
|
||||
#: build/lib/core/models.py:205 core/models.py:205
|
||||
#: build/lib/core/models.py:206 core/models.py:206
|
||||
msgid "users"
|
||||
msgstr "користувачі"
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
|
||||
#: core/models.py:361 core/models.py:1430
|
||||
#: build/lib/core/models.py:362 core/models.py:362
|
||||
msgid "title"
|
||||
msgstr "заголовок"
|
||||
|
||||
#: build/lib/core/models.py:362 core/models.py:362
|
||||
#: build/lib/core/models.py:363 core/models.py:363
|
||||
msgid "excerpt"
|
||||
msgstr "уривок"
|
||||
|
||||
#: build/lib/core/models.py:411 core/models.py:411
|
||||
#: build/lib/core/models.py:412 core/models.py:412
|
||||
msgid "Document"
|
||||
msgstr "Документ"
|
||||
|
||||
#: build/lib/core/models.py:412 core/models.py:412
|
||||
#: build/lib/core/models.py:413 core/models.py:413
|
||||
msgid "Documents"
|
||||
msgstr "Документи"
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
|
||||
#: core/models.py:824
|
||||
#: build/lib/core/models.py:425 build/lib/core/models.py:828 core/models.py:425
|
||||
#: core/models.py:828
|
||||
msgid "Untitled Document"
|
||||
msgstr "Документ без назви"
|
||||
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#: build/lib/core/models.py:829 core/models.py:829
|
||||
msgid "Open"
|
||||
msgstr "Відкрити"
|
||||
|
||||
#: build/lib/core/models.py:864 core/models.py:864
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "{name} ділиться з вами документом!"
|
||||
|
||||
#: build/lib/core/models.py:863 core/models.py:863
|
||||
#: build/lib/core/models.py:868 core/models.py:868
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr "{name} запрошує вас для роботи з документом із роллю \"{role}\":"
|
||||
|
||||
#: build/lib/core/models.py:869 core/models.py:869
|
||||
#: build/lib/core/models.py:874 core/models.py:874
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr "{name} ділиться з вами документом: {title}"
|
||||
|
||||
#: build/lib/core/models.py:969 core/models.py:969
|
||||
#: build/lib/core/models.py:975 core/models.py:975
|
||||
msgid "Document/user link trace"
|
||||
msgstr "Трасування посилання Документ/користувач"
|
||||
|
||||
#: build/lib/core/models.py:970 core/models.py:970
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
msgid "Document/user link traces"
|
||||
msgstr "Трасування посилань Документ/користувач"
|
||||
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
#: build/lib/core/models.py:982 core/models.py:982
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr "Відстеження вже існуючих посилань для цього документа/користувача."
|
||||
|
||||
#: build/lib/core/models.py:999 core/models.py:999
|
||||
#: build/lib/core/models.py:1005 core/models.py:1005
|
||||
msgid "Document favorite"
|
||||
msgstr "Обраний документ"
|
||||
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
msgid "Document favorites"
|
||||
msgstr "Обрані документи"
|
||||
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
#: build/lib/core/models.py:1012 core/models.py:1012
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr "Цей документ вже вказаний як обраний для одного користувача."
|
||||
|
||||
#: build/lib/core/models.py:1028 core/models.py:1028
|
||||
#: build/lib/core/models.py:1034 core/models.py:1034
|
||||
msgid "Document/user relation"
|
||||
msgstr "Відносини документ/користувач"
|
||||
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
msgid "Document/user relations"
|
||||
msgstr "Відносини документ/користувач"
|
||||
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
msgid "This user is already in this document."
|
||||
msgstr "Цей користувач вже має доступ до цього документу."
|
||||
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
#: build/lib/core/models.py:1047 core/models.py:1047
|
||||
msgid "This team is already in this document."
|
||||
msgstr "Ця команда вже має доступ до цього документа."
|
||||
|
||||
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
|
||||
#: core/models.py:1047 core/models.py:1516
|
||||
#: build/lib/core/models.py:1053 core/models.py:1053
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Вкажіть користувача або команду, а не обох."
|
||||
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1204 core/models.py:1204
|
||||
msgid "Document ask for access"
|
||||
msgstr "Запит доступу до документа"
|
||||
|
||||
#: build/lib/core/models.py:1199 core/models.py:1199
|
||||
#: build/lib/core/models.py:1205 core/models.py:1205
|
||||
msgid "Document ask for accesses"
|
||||
msgstr "Запит доступу для документа"
|
||||
|
||||
#: build/lib/core/models.py:1205 core/models.py:1205
|
||||
#: build/lib/core/models.py:1211 core/models.py:1211
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr "Цей користувач вже попросив доступ до цього документа."
|
||||
|
||||
#: build/lib/core/models.py:1262 core/models.py:1262
|
||||
#: build/lib/core/models.py:1268 core/models.py:1268
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr "{name} хоче отримати доступ до документа!"
|
||||
|
||||
#: build/lib/core/models.py:1266 core/models.py:1266
|
||||
#: build/lib/core/models.py:1272 core/models.py:1272
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr "{name} бажає отримати доступ до наступного документа:"
|
||||
|
||||
#: build/lib/core/models.py:1272 core/models.py:1272
|
||||
#: build/lib/core/models.py:1278 core/models.py:1278
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr "{name} запитує доступ до документа: {title}"
|
||||
|
||||
#: build/lib/core/models.py:1314 core/models.py:1314
|
||||
#: build/lib/core/models.py:1320 core/models.py:1320
|
||||
msgid "Thread"
|
||||
msgstr "Обговорення"
|
||||
|
||||
#: build/lib/core/models.py:1315 core/models.py:1315
|
||||
#: build/lib/core/models.py:1321 core/models.py:1321
|
||||
msgid "Threads"
|
||||
msgstr "Обговорення"
|
||||
|
||||
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
|
||||
#: core/models.py:1318 core/models.py:1370
|
||||
#: build/lib/core/models.py:1324 build/lib/core/models.py:1376
|
||||
#: core/models.py:1324 core/models.py:1376
|
||||
msgid "Anonymous"
|
||||
msgstr "Анонім"
|
||||
|
||||
#: build/lib/core/models.py:1365 core/models.py:1365
|
||||
#: build/lib/core/models.py:1371 core/models.py:1371
|
||||
msgid "Comment"
|
||||
msgstr "Коментар"
|
||||
|
||||
#: build/lib/core/models.py:1366 core/models.py:1366
|
||||
#: build/lib/core/models.py:1372 core/models.py:1372
|
||||
msgid "Comments"
|
||||
msgstr "Коментарі"
|
||||
|
||||
#: build/lib/core/models.py:1415 core/models.py:1415
|
||||
#: build/lib/core/models.py:1421 core/models.py:1421
|
||||
msgid "This emoji has already been reacted to this comment."
|
||||
msgstr "Цим емодзі вже відреагували на цей коментар."
|
||||
|
||||
#: build/lib/core/models.py:1419 core/models.py:1419
|
||||
#: build/lib/core/models.py:1425 core/models.py:1425
|
||||
msgid "Reaction"
|
||||
msgstr "Реакція"
|
||||
|
||||
#: build/lib/core/models.py:1420 core/models.py:1420
|
||||
#: build/lib/core/models.py:1426 core/models.py:1426
|
||||
msgid "Reactions"
|
||||
msgstr "Реакції"
|
||||
|
||||
#: build/lib/core/models.py:1431 core/models.py:1431
|
||||
msgid "description"
|
||||
msgstr "опис"
|
||||
|
||||
#: build/lib/core/models.py:1432 core/models.py:1432
|
||||
msgid "code"
|
||||
msgstr "код"
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
msgid "css"
|
||||
msgstr "css"
|
||||
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
msgid "public"
|
||||
msgstr "публічне"
|
||||
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr "Чи є цей шаблон публічним для будь-кого користувача."
|
||||
|
||||
#: build/lib/core/models.py:1443 core/models.py:1443
|
||||
msgid "Template"
|
||||
msgstr "Шаблон"
|
||||
|
||||
#: build/lib/core/models.py:1444 core/models.py:1444
|
||||
msgid "Templates"
|
||||
msgstr "Шаблони"
|
||||
|
||||
#: build/lib/core/models.py:1497 core/models.py:1497
|
||||
msgid "Template/user relation"
|
||||
msgstr "Відношення шаблон/користувач"
|
||||
|
||||
#: build/lib/core/models.py:1498 core/models.py:1498
|
||||
msgid "Template/user relations"
|
||||
msgstr "Відношення шаблон/користувач"
|
||||
|
||||
#: build/lib/core/models.py:1504 core/models.py:1504
|
||||
msgid "This user is already in this template."
|
||||
msgstr "Цей користувач вже має доступ до цього шаблону."
|
||||
|
||||
#: build/lib/core/models.py:1510 core/models.py:1510
|
||||
msgid "This team is already in this template."
|
||||
msgstr "Ця команда вже має доступ до цього шаблону."
|
||||
|
||||
#: build/lib/core/models.py:1587 core/models.py:1587
|
||||
#: build/lib/core/models.py:1436 core/models.py:1436
|
||||
msgid "email address"
|
||||
msgstr "електронна адреса"
|
||||
|
||||
#: build/lib/core/models.py:1606 core/models.py:1606
|
||||
#: build/lib/core/models.py:1455 core/models.py:1455
|
||||
msgid "Document invitation"
|
||||
msgstr "Запрошення до редагування документа"
|
||||
|
||||
#: build/lib/core/models.py:1607 core/models.py:1607
|
||||
#: build/lib/core/models.py:1456 core/models.py:1456
|
||||
msgid "Document invitations"
|
||||
msgstr "Запрошення до редагування документів"
|
||||
|
||||
#: build/lib/core/models.py:1627 core/models.py:1627
|
||||
#: build/lib/core/models.py:1476 core/models.py:1476
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr "Ця електронна пошта вже пов'язана з зареєстрованим користувачем."
|
||||
|
||||
@@ -446,17 +396,12 @@ msgstr "Ця електронна пошта вже пов'язана з зар
|
||||
msgid "Logo email"
|
||||
msgstr "Логотип пошти"
|
||||
|
||||
#: core/templates/mail/html/template.html:200
|
||||
#: core/templates/mail/text/template.txt:10
|
||||
msgid "Open"
|
||||
msgstr "Відкрити"
|
||||
|
||||
#: core/templates/mail/html/template.html:217
|
||||
#: core/templates/mail/html/template.html:219
|
||||
#: core/templates/mail/text/template.txt:14
|
||||
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
|
||||
msgstr " Docs, ваш новий важливий інструмент для організації, обміну та командної співпраці над вашими документами. "
|
||||
|
||||
#: core/templates/mail/html/template.html:224
|
||||
#: core/templates/mail/html/template.html:226
|
||||
#: core/templates/mail/text/template.txt:16
|
||||
#, python-format
|
||||
msgid " Brought to you by %(brandname)s "
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-12-09 11:12\n"
|
||||
"POT-Creation-Date: 2026-01-21 09:53+0000\n"
|
||||
"PO-Revision-Date: 2026-01-28 20:12\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Chinese Simplified\n"
|
||||
"Language: zh_CN\n"
|
||||
@@ -17,123 +17,115 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 18\n"
|
||||
|
||||
#: build/lib/core/admin.py:36 core/admin.py:36
|
||||
#: build/lib/core/admin.py:28 core/admin.py:28
|
||||
msgid "Personal info"
|
||||
msgstr "个人信息"
|
||||
msgstr "個人資訊"
|
||||
|
||||
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
|
||||
#: core/admin.py:137
|
||||
#: build/lib/core/admin.py:41 build/lib/core/admin.py:121 core/admin.py:41
|
||||
#: core/admin.py:121
|
||||
msgid "Permissions"
|
||||
msgstr "权限"
|
||||
msgstr "權限"
|
||||
|
||||
#: build/lib/core/admin.py:61 core/admin.py:61
|
||||
#: build/lib/core/admin.py:53 core/admin.py:53
|
||||
msgid "Important dates"
|
||||
msgstr "重要日期"
|
||||
|
||||
#: build/lib/core/admin.py:147 core/admin.py:147
|
||||
#: build/lib/core/admin.py:131 core/admin.py:131
|
||||
msgid "Tree structure"
|
||||
msgstr "树状结构"
|
||||
msgstr "樹狀結構"
|
||||
|
||||
#: build/lib/core/api/filters.py:47 core/api/filters.py:47
|
||||
msgid "Title"
|
||||
msgstr "标题"
|
||||
msgstr "標題"
|
||||
|
||||
#: build/lib/core/api/filters.py:61 core/api/filters.py:61
|
||||
msgid "Creator is me"
|
||||
msgstr "创建者是我"
|
||||
msgstr "建立者是我"
|
||||
|
||||
#: build/lib/core/api/filters.py:64 core/api/filters.py:64
|
||||
msgid "Masked"
|
||||
msgstr "已屏蔽"
|
||||
msgstr "已隱藏"
|
||||
|
||||
#: build/lib/core/api/filters.py:67 core/api/filters.py:67
|
||||
msgid "Favorite"
|
||||
msgstr "收藏"
|
||||
msgstr "我的最愛"
|
||||
|
||||
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
|
||||
#: build/lib/core/api/serializers.py:505 core/api/serializers.py:505
|
||||
msgid "A new document was created on your behalf!"
|
||||
msgstr "已为您创建了一份新文档!"
|
||||
msgstr "已代表您建立新文件!"
|
||||
|
||||
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
|
||||
#: build/lib/core/api/serializers.py:509 core/api/serializers.py:509
|
||||
msgid "You have been granted ownership of a new document:"
|
||||
msgstr "您已被授予新文档的所有权:"
|
||||
msgstr "您已獲得新文件的所有權:"
|
||||
|
||||
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
|
||||
#: build/lib/core/api/serializers.py:545 core/api/serializers.py:545
|
||||
msgid "This field is required."
|
||||
msgstr "必填字段。"
|
||||
msgstr "此欄位為必填。"
|
||||
|
||||
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
|
||||
#: build/lib/core/api/serializers.py:556 core/api/serializers.py:556
|
||||
#, python-format
|
||||
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
|
||||
msgstr ""
|
||||
msgstr "根據父文件設定,不允許連結範圍「%(link_reach)s」。"
|
||||
|
||||
#: build/lib/core/api/serializers.py:694 core/api/serializers.py:694
|
||||
msgid "Body"
|
||||
msgstr "正文"
|
||||
|
||||
#: build/lib/core/api/serializers.py:697 core/api/serializers.py:697
|
||||
msgid "Body type"
|
||||
msgstr "正文类型"
|
||||
|
||||
#: build/lib/core/api/serializers.py:703 core/api/serializers.py:703
|
||||
msgid "Format"
|
||||
msgstr "格式"
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
|
||||
#: build/lib/core/api/viewsets.py:1122 core/api/viewsets.py:1122
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "{title} 的副本"
|
||||
|
||||
#: build/lib/core/apps.py:12 core/apps.py:12
|
||||
msgid "Impress core application"
|
||||
msgstr "Impress 核心應用程式"
|
||||
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
|
||||
#: core/choices.py:43
|
||||
msgid "Reader"
|
||||
msgstr "阅读者"
|
||||
msgstr "檢視者"
|
||||
|
||||
#: build/lib/core/choices.py:36 build/lib/core/choices.py:44 core/choices.py:36
|
||||
#: core/choices.py:44
|
||||
msgid "Commenter"
|
||||
msgstr ""
|
||||
msgstr "評論者"
|
||||
|
||||
#: build/lib/core/choices.py:37 build/lib/core/choices.py:45 core/choices.py:37
|
||||
#: core/choices.py:45
|
||||
msgid "Editor"
|
||||
msgstr "编辑者"
|
||||
msgstr "編輯者"
|
||||
|
||||
#: build/lib/core/choices.py:46 core/choices.py:46
|
||||
msgid "Administrator"
|
||||
msgstr "超级管理员"
|
||||
msgstr "管理員"
|
||||
|
||||
#: build/lib/core/choices.py:47 core/choices.py:47
|
||||
msgid "Owner"
|
||||
msgstr "所有者"
|
||||
msgstr "擁有者"
|
||||
|
||||
#: build/lib/core/choices.py:58 core/choices.py:58
|
||||
msgid "Restricted"
|
||||
msgstr "受限的"
|
||||
msgstr "受限"
|
||||
|
||||
#: build/lib/core/choices.py:62 core/choices.py:62
|
||||
msgid "Authenticated"
|
||||
msgstr "已验证"
|
||||
msgstr "已驗證"
|
||||
|
||||
#: build/lib/core/choices.py:64 core/choices.py:64
|
||||
msgid "Public"
|
||||
msgstr "公开"
|
||||
msgstr "公開"
|
||||
|
||||
#: build/lib/core/enums.py:36 core/enums.py:36
|
||||
msgid "First child"
|
||||
msgstr "第一个子项"
|
||||
msgstr "第一個子項目"
|
||||
|
||||
#: build/lib/core/enums.py:37 core/enums.py:37
|
||||
msgid "Last child"
|
||||
msgstr "最后一个子项"
|
||||
msgstr "最後一個子項目"
|
||||
|
||||
#: build/lib/core/enums.py:38 core/enums.py:38
|
||||
msgid "First sibling"
|
||||
msgstr "第一个同级项"
|
||||
msgstr "第一個同級項目"
|
||||
|
||||
#: build/lib/core/enums.py:39 core/enums.py:39
|
||||
msgid "Last sibling"
|
||||
msgstr "最后一个同级项"
|
||||
msgstr "最後一個同級項目"
|
||||
|
||||
#: build/lib/core/enums.py:40 core/enums.py:40
|
||||
msgid "Left"
|
||||
@@ -143,322 +135,275 @@ msgstr "左"
|
||||
msgid "Right"
|
||||
msgstr "右"
|
||||
|
||||
#: build/lib/core/models.py:80 core/models.py:80
|
||||
msgid "id"
|
||||
msgstr "id"
|
||||
|
||||
#: build/lib/core/models.py:81 core/models.py:81
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr "记录的主密钥为 UUID"
|
||||
msgid "id"
|
||||
msgstr "ID"
|
||||
|
||||
#: build/lib/core/models.py:87 core/models.py:87
|
||||
msgid "created on"
|
||||
msgstr "创建时间"
|
||||
#: build/lib/core/models.py:82 core/models.py:82
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr "記錄的主鍵(UUID)"
|
||||
|
||||
#: build/lib/core/models.py:88 core/models.py:88
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr "记录的创建日期和时间"
|
||||
msgid "created on"
|
||||
msgstr "建立於"
|
||||
|
||||
#: build/lib/core/models.py:93 core/models.py:93
|
||||
msgid "updated on"
|
||||
msgstr "更新时间"
|
||||
#: build/lib/core/models.py:89 core/models.py:89
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr "記錄建立的日期與時間"
|
||||
|
||||
#: build/lib/core/models.py:94 core/models.py:94
|
||||
msgid "updated on"
|
||||
msgstr "更新於"
|
||||
|
||||
#: build/lib/core/models.py:95 core/models.py:95
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr "记录的最后更新时间"
|
||||
msgstr "記錄最後更新的日期與時間"
|
||||
|
||||
#: build/lib/core/models.py:130 core/models.py:130
|
||||
#: build/lib/core/models.py:131 core/models.py:131
|
||||
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
|
||||
msgstr "未找到具有该 sub 的用户,但该邮箱已关联到一个注册用户。"
|
||||
msgstr "我們找不到具有此 sub 的使用者,但此電子郵件地址已與已註冊使用者關聯。"
|
||||
|
||||
#: build/lib/core/models.py:141 core/models.py:141
|
||||
#: build/lib/core/models.py:142 core/models.py:142
|
||||
msgid "sub"
|
||||
msgstr "sub"
|
||||
|
||||
#: build/lib/core/models.py:142 core/models.py:142
|
||||
#: build/lib/core/models.py:143 core/models.py:143
|
||||
msgid "Required. 255 characters or fewer. ASCII characters only."
|
||||
msgstr "必填项。限255个字符以内。仅支持ASCII字符。"
|
||||
msgstr "必填。255 個字元(含)以下。僅限 ASCII 字元。"
|
||||
|
||||
#: build/lib/core/models.py:150 core/models.py:150
|
||||
#: build/lib/core/models.py:151 core/models.py:151
|
||||
msgid "full name"
|
||||
msgstr "全名"
|
||||
|
||||
#: build/lib/core/models.py:152 core/models.py:152
|
||||
#: build/lib/core/models.py:153 core/models.py:153
|
||||
msgid "short name"
|
||||
msgstr "简称"
|
||||
msgstr "簡稱"
|
||||
|
||||
#: build/lib/core/models.py:155 core/models.py:155
|
||||
#: build/lib/core/models.py:156 core/models.py:156
|
||||
msgid "identity email address"
|
||||
msgstr "身份电子邮件地址"
|
||||
msgstr "身份驗證電子郵件地址"
|
||||
|
||||
#: build/lib/core/models.py:160 core/models.py:160
|
||||
#: build/lib/core/models.py:161 core/models.py:161
|
||||
msgid "admin email address"
|
||||
msgstr "管理员电子邮件地址"
|
||||
|
||||
#: build/lib/core/models.py:167 core/models.py:167
|
||||
msgid "language"
|
||||
msgstr "语言"
|
||||
msgstr "管理員電子郵件地址"
|
||||
|
||||
#: build/lib/core/models.py:168 core/models.py:168
|
||||
msgid "language"
|
||||
msgstr "語言"
|
||||
|
||||
#: build/lib/core/models.py:169 core/models.py:169
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr "用户希望看到的界面语言。"
|
||||
msgstr "使用者希望介面顯示的語言。"
|
||||
|
||||
#: build/lib/core/models.py:176 core/models.py:176
|
||||
#: build/lib/core/models.py:177 core/models.py:177
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr "用户查看时间希望的时区。"
|
||||
msgstr "使用者希望時間顯示的時區。"
|
||||
|
||||
#: build/lib/core/models.py:179 core/models.py:179
|
||||
#: build/lib/core/models.py:180 core/models.py:180
|
||||
msgid "device"
|
||||
msgstr "设备"
|
||||
msgstr "裝置"
|
||||
|
||||
#: build/lib/core/models.py:181 core/models.py:181
|
||||
#: build/lib/core/models.py:182 core/models.py:182
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr "用户是设备还是真实用户。"
|
||||
msgstr "使用者是裝置還是真實使用者。"
|
||||
|
||||
#: build/lib/core/models.py:184 core/models.py:184
|
||||
#: build/lib/core/models.py:185 core/models.py:185
|
||||
msgid "staff status"
|
||||
msgstr "员工状态"
|
||||
msgstr "工作人員狀態"
|
||||
|
||||
#: build/lib/core/models.py:186 core/models.py:186
|
||||
#: build/lib/core/models.py:187 core/models.py:187
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "用户是否可以登录该管理员站点。"
|
||||
msgstr "使用者是否可以登入此管理後台。"
|
||||
|
||||
#: build/lib/core/models.py:189 core/models.py:189
|
||||
#: build/lib/core/models.py:190 core/models.py:190
|
||||
msgid "active"
|
||||
msgstr "激活"
|
||||
msgstr "啟用"
|
||||
|
||||
#: build/lib/core/models.py:192 core/models.py:192
|
||||
#: build/lib/core/models.py:193 core/models.py:193
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr "是否应将此用户视为活跃用户。取消选择此选项而不是删除账户。"
|
||||
|
||||
#: build/lib/core/models.py:204 core/models.py:204
|
||||
msgid "user"
|
||||
msgstr "用户"
|
||||
msgstr "此使用者是否應被視為處於啟用狀態。請取消勾選此項而非刪除帳號。"
|
||||
|
||||
#: build/lib/core/models.py:205 core/models.py:205
|
||||
msgid "users"
|
||||
msgstr "个用户"
|
||||
msgid "user"
|
||||
msgstr "使用者"
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
|
||||
#: core/models.py:361 core/models.py:1430
|
||||
msgid "title"
|
||||
msgstr "标题"
|
||||
#: build/lib/core/models.py:206 core/models.py:206
|
||||
msgid "users"
|
||||
msgstr "使用者"
|
||||
|
||||
#: build/lib/core/models.py:362 core/models.py:362
|
||||
msgid "title"
|
||||
msgstr "標題"
|
||||
|
||||
#: build/lib/core/models.py:363 core/models.py:363
|
||||
msgid "excerpt"
|
||||
msgstr "摘要"
|
||||
|
||||
#: build/lib/core/models.py:411 core/models.py:411
|
||||
msgid "Document"
|
||||
msgstr "文档"
|
||||
|
||||
#: build/lib/core/models.py:412 core/models.py:412
|
||||
msgid "Document"
|
||||
msgstr "文件"
|
||||
|
||||
#: build/lib/core/models.py:413 core/models.py:413
|
||||
msgid "Documents"
|
||||
msgstr "个文档"
|
||||
msgstr "文件"
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
|
||||
#: core/models.py:824
|
||||
#: build/lib/core/models.py:425 build/lib/core/models.py:828 core/models.py:425
|
||||
#: core/models.py:828
|
||||
msgid "Untitled Document"
|
||||
msgstr "未命名文档"
|
||||
msgstr "未命名文件"
|
||||
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#: build/lib/core/models.py:829 core/models.py:829
|
||||
msgid "Open"
|
||||
msgstr "開啟"
|
||||
|
||||
#: build/lib/core/models.py:864 core/models.py:864
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "{name} 与您共享了一个文档!"
|
||||
msgstr "{name} 與您分享了一份文件!"
|
||||
|
||||
#: build/lib/core/models.py:863 core/models.py:863
|
||||
#: build/lib/core/models.py:868 core/models.py:868
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr "{name} 邀请您以“{role}”角色访问以下文档:"
|
||||
msgstr "{name} 邀請您以「{role}」角色參與以下文件:"
|
||||
|
||||
#: build/lib/core/models.py:869 core/models.py:869
|
||||
#: build/lib/core/models.py:874 core/models.py:874
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr "{name} 与您共享了一个文档:{title}"
|
||||
msgstr "{name} 與您分享了一份文件:{title}"
|
||||
|
||||
#: build/lib/core/models.py:969 core/models.py:969
|
||||
#: build/lib/core/models.py:975 core/models.py:975
|
||||
msgid "Document/user link trace"
|
||||
msgstr "文档/用户链接跟踪"
|
||||
|
||||
#: build/lib/core/models.py:970 core/models.py:970
|
||||
msgid "Document/user link traces"
|
||||
msgstr "个文档/用户链接跟踪"
|
||||
msgstr "文件/使用者連結追蹤"
|
||||
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
msgid "Document/user link traces"
|
||||
msgstr "文件/使用者連結追蹤"
|
||||
|
||||
#: build/lib/core/models.py:982 core/models.py:982
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr "此文档/用户的链接跟踪已存在。"
|
||||
msgstr "此文件/使用者已存在連結追蹤。"
|
||||
|
||||
#: build/lib/core/models.py:999 core/models.py:999
|
||||
#: build/lib/core/models.py:1005 core/models.py:1005
|
||||
msgid "Document favorite"
|
||||
msgstr "文档收藏"
|
||||
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
msgid "Document favorites"
|
||||
msgstr "文档收藏夹"
|
||||
msgstr "文件收藏"
|
||||
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
msgid "Document favorites"
|
||||
msgstr "文件收藏"
|
||||
|
||||
#: build/lib/core/models.py:1012 core/models.py:1012
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr "该文档已被同一用户的收藏关系实例关联。"
|
||||
msgstr "此使用者已將此文件加入收藏。"
|
||||
|
||||
#: build/lib/core/models.py:1028 core/models.py:1028
|
||||
#: build/lib/core/models.py:1034 core/models.py:1034
|
||||
msgid "Document/user relation"
|
||||
msgstr "文档/用户关系"
|
||||
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
msgid "Document/user relations"
|
||||
msgstr "文档/用户关系集"
|
||||
msgstr "文件/使用者關聯"
|
||||
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
msgid "This user is already in this document."
|
||||
msgstr "该用户已在此文档中。"
|
||||
msgid "Document/user relations"
|
||||
msgstr "文件/使用者關聯"
|
||||
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
msgid "This user is already in this document."
|
||||
msgstr "此使用者已在此文件中。"
|
||||
|
||||
#: build/lib/core/models.py:1047 core/models.py:1047
|
||||
msgid "This team is already in this document."
|
||||
msgstr "该团队已在此文档中。"
|
||||
msgstr "此團隊已在此文件中。"
|
||||
|
||||
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
|
||||
#: core/models.py:1047 core/models.py:1516
|
||||
#: build/lib/core/models.py:1053 core/models.py:1053
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "必须设置用户或团队之一,不能同时设置两者。"
|
||||
msgstr "必須設定使用者或團隊其中之一,不能同時設定兩者。"
|
||||
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1204 core/models.py:1204
|
||||
msgid "Document ask for access"
|
||||
msgstr "文档需要访问权限"
|
||||
|
||||
#: build/lib/core/models.py:1199 core/models.py:1199
|
||||
msgid "Document ask for accesses"
|
||||
msgstr "文档需要访问权限"
|
||||
msgstr "要求文件存取權"
|
||||
|
||||
#: build/lib/core/models.py:1205 core/models.py:1205
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr "用户已申请该文档的访问权限。"
|
||||
msgid "Document ask for accesses"
|
||||
msgstr "要求文件存取權"
|
||||
|
||||
#: build/lib/core/models.py:1262 core/models.py:1262
|
||||
#: build/lib/core/models.py:1211 core/models.py:1211
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr "此使用者已要求過存取此文件的權限。"
|
||||
|
||||
#: build/lib/core/models.py:1268 core/models.py:1268
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr "{name} 申请访问文档!"
|
||||
|
||||
#: build/lib/core/models.py:1266 core/models.py:1266
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr "{name} 申请访问以下文档:"
|
||||
msgstr "{name} 想要存取文件!"
|
||||
|
||||
#: build/lib/core/models.py:1272 core/models.py:1272
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr "{name} 想要存取以下文件:"
|
||||
|
||||
#: build/lib/core/models.py:1278 core/models.py:1278
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr "{name}申请文档:{title}的访问权限"
|
||||
msgstr "{name} 正要求存取文件:{title}"
|
||||
|
||||
#: build/lib/core/models.py:1314 core/models.py:1314
|
||||
#: build/lib/core/models.py:1320 core/models.py:1320
|
||||
msgid "Thread"
|
||||
msgstr ""
|
||||
msgstr "對話串"
|
||||
|
||||
#: build/lib/core/models.py:1315 core/models.py:1315
|
||||
#: build/lib/core/models.py:1321 core/models.py:1321
|
||||
msgid "Threads"
|
||||
msgstr ""
|
||||
msgstr "對話串"
|
||||
|
||||
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
|
||||
#: core/models.py:1318 core/models.py:1370
|
||||
#: build/lib/core/models.py:1324 build/lib/core/models.py:1376
|
||||
#: core/models.py:1324 core/models.py:1376
|
||||
msgid "Anonymous"
|
||||
msgstr ""
|
||||
msgstr "匿名"
|
||||
|
||||
#: build/lib/core/models.py:1365 core/models.py:1365
|
||||
#: build/lib/core/models.py:1371 core/models.py:1371
|
||||
msgid "Comment"
|
||||
msgstr ""
|
||||
msgstr "評論"
|
||||
|
||||
#: build/lib/core/models.py:1366 core/models.py:1366
|
||||
#: build/lib/core/models.py:1372 core/models.py:1372
|
||||
msgid "Comments"
|
||||
msgstr ""
|
||||
msgstr "評論"
|
||||
|
||||
#: build/lib/core/models.py:1415 core/models.py:1415
|
||||
#: build/lib/core/models.py:1421 core/models.py:1421
|
||||
msgid "This emoji has already been reacted to this comment."
|
||||
msgstr ""
|
||||
msgstr "此評論已標記過此表情符號。"
|
||||
|
||||
#: build/lib/core/models.py:1419 core/models.py:1419
|
||||
#: build/lib/core/models.py:1425 core/models.py:1425
|
||||
msgid "Reaction"
|
||||
msgstr ""
|
||||
msgstr "回應"
|
||||
|
||||
#: build/lib/core/models.py:1420 core/models.py:1420
|
||||
#: build/lib/core/models.py:1426 core/models.py:1426
|
||||
msgid "Reactions"
|
||||
msgstr ""
|
||||
msgstr "回應"
|
||||
|
||||
#: build/lib/core/models.py:1431 core/models.py:1431
|
||||
msgid "description"
|
||||
msgstr "说明"
|
||||
|
||||
#: build/lib/core/models.py:1432 core/models.py:1432
|
||||
msgid "code"
|
||||
msgstr "代码"
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
msgid "css"
|
||||
msgstr "css"
|
||||
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
msgid "public"
|
||||
msgstr "公开"
|
||||
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr "该模板是否公开供任何人使用。"
|
||||
|
||||
#: build/lib/core/models.py:1443 core/models.py:1443
|
||||
msgid "Template"
|
||||
msgstr "模板"
|
||||
|
||||
#: build/lib/core/models.py:1444 core/models.py:1444
|
||||
msgid "Templates"
|
||||
msgstr "模板"
|
||||
|
||||
#: build/lib/core/models.py:1497 core/models.py:1497
|
||||
msgid "Template/user relation"
|
||||
msgstr "模板/用户关系"
|
||||
|
||||
#: build/lib/core/models.py:1498 core/models.py:1498
|
||||
msgid "Template/user relations"
|
||||
msgstr "模板/用户关系集"
|
||||
|
||||
#: build/lib/core/models.py:1504 core/models.py:1504
|
||||
msgid "This user is already in this template."
|
||||
msgstr "该用户已在此模板中。"
|
||||
|
||||
#: build/lib/core/models.py:1510 core/models.py:1510
|
||||
msgid "This team is already in this template."
|
||||
msgstr "该团队已在此模板中。"
|
||||
|
||||
#: build/lib/core/models.py:1587 core/models.py:1587
|
||||
#: build/lib/core/models.py:1436 core/models.py:1436
|
||||
msgid "email address"
|
||||
msgstr "电子邮件地址"
|
||||
msgstr "電子郵件地址"
|
||||
|
||||
#: build/lib/core/models.py:1606 core/models.py:1606
|
||||
#: build/lib/core/models.py:1455 core/models.py:1455
|
||||
msgid "Document invitation"
|
||||
msgstr "文档邀请"
|
||||
msgstr "文件邀請"
|
||||
|
||||
#: build/lib/core/models.py:1607 core/models.py:1607
|
||||
#: build/lib/core/models.py:1456 core/models.py:1456
|
||||
msgid "Document invitations"
|
||||
msgstr "文档邀请"
|
||||
msgstr "文件邀請"
|
||||
|
||||
#: build/lib/core/models.py:1627 core/models.py:1627
|
||||
#: build/lib/core/models.py:1476 core/models.py:1476
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr "此电子邮件已经与现有注册用户关联。"
|
||||
msgstr "此電子郵件地址已與已註冊使用者關聯。"
|
||||
|
||||
#: core/templates/mail/html/template.html:153
|
||||
#: core/templates/mail/text/template.txt:3
|
||||
msgid "Logo email"
|
||||
msgstr "徽标邮件"
|
||||
msgstr "電子郵件標誌"
|
||||
|
||||
#: core/templates/mail/html/template.html:200
|
||||
#: core/templates/mail/text/template.txt:10
|
||||
msgid "Open"
|
||||
msgstr "打开"
|
||||
|
||||
#: core/templates/mail/html/template.html:217
|
||||
#: core/templates/mail/html/template.html:219
|
||||
#: core/templates/mail/text/template.txt:14
|
||||
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
|
||||
msgstr " Docs——您的全新必备工具,帮助团队组织、共享和协作处理文档。 "
|
||||
msgstr " Docs,您團隊組織、分享及協作文件的全新必備工具。 "
|
||||
|
||||
#: core/templates/mail/html/template.html:224
|
||||
#: core/templates/mail/html/template.html:226
|
||||
#: core/templates/mail/text/template.txt:16
|
||||
#, python-format
|
||||
msgid " Brought to you by %(brandname)s "
|
||||
msgstr " 由 %(brandname)s 倾力打造。 "
|
||||
msgstr " 由 %(brandname)s 提供 "
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "impress"
|
||||
version = "4.2.0"
|
||||
version = "4.5.0"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -19,19 +19,19 @@ classifiers = [
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
]
|
||||
description = "An application to print markdown to pdf from a set of managed templates."
|
||||
description = "Docs is a collaborative text editor designed to address common challenges in knowledge building and sharing."
|
||||
keywords = ["Django", "Contacts", "Templates", "RBAC"]
|
||||
license = { file = "LICENSE" }
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"beautifulsoup4==4.14.2",
|
||||
"boto3==1.40.74",
|
||||
"beautifulsoup4==4.14.3",
|
||||
"boto3==1.42.17",
|
||||
"Brotli==1.2.0",
|
||||
"celery[redis]==5.5.3",
|
||||
"django-configurations==2.5.1",
|
||||
"django-cors-headers==4.9.0",
|
||||
"django-countries==8.1.0",
|
||||
"django-countries==8.2.0",
|
||||
"django-csp==4.0",
|
||||
"django-filter==25.2",
|
||||
"django-lasuite[all]==0.0.22",
|
||||
@@ -39,8 +39,8 @@ dependencies = [
|
||||
"django-redis==6.0.0",
|
||||
"django-storages[s3]==1.14.6",
|
||||
"django-timezone-field>=5.1",
|
||||
"django==5.2.9",
|
||||
"django-treebeard==4.7.1",
|
||||
"django<6.0.0",
|
||||
"django-treebeard==4.8.0",
|
||||
"djangorestframework==3.16.1",
|
||||
"drf_spectacular==0.29.0",
|
||||
"dockerflow==2024.4.2",
|
||||
@@ -48,18 +48,19 @@ dependencies = [
|
||||
"factory_boy==3.3.3",
|
||||
"gunicorn==23.0.0",
|
||||
"jsonschema==4.25.1",
|
||||
"langfuse==3.11.2",
|
||||
"lxml==6.0.2",
|
||||
"markdown==3.10",
|
||||
"mozilla-django-oidc==4.0.1",
|
||||
"mozilla-django-oidc==5.0.2",
|
||||
"nested-multipart-parser==1.6.0",
|
||||
"openai==2.8.0",
|
||||
"psycopg[binary]==3.2.12",
|
||||
"pycrdt==0.12.43",
|
||||
"openai==2.14.0",
|
||||
"psycopg[binary]==3.3.2",
|
||||
"pycrdt==0.12.44",
|
||||
"PyJWT==2.10.1",
|
||||
"python-magic==0.4.27",
|
||||
"redis<6.0.0",
|
||||
"requests==2.32.5",
|
||||
"sentry-sdk==2.44.0",
|
||||
"sentry-sdk==2.48.0",
|
||||
"whitenoise==6.11.0",
|
||||
]
|
||||
|
||||
@@ -73,20 +74,20 @@ dependencies = [
|
||||
dev = [
|
||||
"django-extensions==4.1",
|
||||
"django-test-migrations==1.5.0",
|
||||
"drf-spectacular-sidecar==2025.10.1",
|
||||
"drf-spectacular-sidecar==2025.12.1",
|
||||
"freezegun==1.5.5",
|
||||
"ipdb==0.13.13",
|
||||
"ipython==9.7.0",
|
||||
"pyfakefs==5.10.2",
|
||||
"ipython==9.8.0",
|
||||
"pyfakefs==6.0.0",
|
||||
"pylint-django==2.6.1",
|
||||
"pylint<4.0.0",
|
||||
"pytest-cov==7.0.0",
|
||||
"pytest-django==4.11.1",
|
||||
"pytest==9.0.1",
|
||||
"pytest==9.0.2",
|
||||
"pytest-icdiff==0.9",
|
||||
"pytest-xdist==3.8.0",
|
||||
"responses==0.25.8",
|
||||
"ruff==0.14.5",
|
||||
"ruff==0.14.10",
|
||||
"types-requests==2.32.4.20250913",
|
||||
]
|
||||
|
||||
|
||||
2
src/frontend/apps/e2e/.gitignore
vendored
2
src/frontend/apps/e2e/.gitignore
vendored
@@ -1,6 +1,6 @@
|
||||
# e2e
|
||||
test-results/
|
||||
report/
|
||||
report*/
|
||||
blob-report/
|
||||
playwright/.auth/
|
||||
playwright/.cache/
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -0,0 +1,60 @@
|
||||

|
||||
|
||||
# Lorem Ipsum import Document
|
||||
|
||||
## Introduction
|
||||
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam auctor, nisl eget ultricies tincidunt, nisl nisl aliquam nisl, eget ultricies nisl nisl eget nisl.
|
||||
|
||||
### Subsection 1.1
|
||||
|
||||
* **Bold text**: Lorem ipsum dolor sit amet.
|
||||
|
||||
* *Italic text*: Consectetur adipiscing elit.
|
||||
|
||||
* ~~Strikethrough text~~: Nullam auctor, nisl eget ultricies tincidunt.
|
||||
|
||||
1. First item in an ordered list.
|
||||
|
||||
2. Second item in an ordered list.
|
||||
|
||||
* Indented bullet point.
|
||||
|
||||
* Another indented bullet point.
|
||||
|
||||
3. Third item in an ordered list.
|
||||
|
||||
### Subsection 1.2
|
||||
|
||||
**Code block:**
|
||||
|
||||
```js
|
||||
const hello_world = () => {
|
||||
console.log("Hello, world!");
|
||||
}
|
||||
```
|
||||
|
||||
**Blockquote:**
|
||||
|
||||
> Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam auctor, nisl eget ultricies tincidunt.
|
||||
|
||||
**Horizontal rule:**
|
||||
|
||||
***
|
||||
|
||||
**Table:**
|
||||
|
||||
| Syntax | Description |
|
||||
| --------- | ----------- |
|
||||
| Header | Title |
|
||||
| Paragraph | Text |
|
||||
|
||||
**Inline code:**
|
||||
|
||||
Use the `printf()` function.
|
||||
|
||||
**Link:** [Example](http://localhost:3000/)
|
||||
|
||||
## Conclusion
|
||||
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam auctor, nisl eget ultricies tincidunt, nisl nisl aliquam nisl, eget ultricies nisl nisl eget nisl.
|
||||
@@ -58,7 +58,7 @@ test.describe('Doc Comments', () => {
|
||||
await page.getByRole('button', { name: '👍' }).click();
|
||||
|
||||
await expect(
|
||||
thread.getByRole('img', { name: 'E2E Chromium' }).first(),
|
||||
thread.getByRole('img', { name: `E2E ${browserName}` }).first(),
|
||||
).toBeVisible();
|
||||
await expect(thread.getByText('This is a comment').first()).toBeVisible();
|
||||
await expect(thread.getByText(`E2E ${browserName}`).first()).toBeVisible();
|
||||
@@ -394,6 +394,8 @@ test.describe('Doc Comments mobile', () => {
|
||||
await thread.getByRole('paragraph').first().fill('This is a comment');
|
||||
await thread.locator('[data-test="save"]').click();
|
||||
await expect(thread.getByText('This is a comment').first()).toBeHidden();
|
||||
// Check toolbar is closed after adding a comment
|
||||
await expect(page.getByRole('button', { name: 'Paragraph' })).toBeHidden();
|
||||
|
||||
await editor.first().click();
|
||||
await editor.getByText('Hello').click();
|
||||
|
||||
@@ -604,7 +604,7 @@ test.describe('Doc Editor', () => {
|
||||
|
||||
await verifyDocName(page, randomDoc);
|
||||
|
||||
const editor = await openSuggestionMenu({ page });
|
||||
const { editor } = await openSuggestionMenu({ page });
|
||||
await page.getByText('Embedded file').click();
|
||||
await page.getByText('Upload file').click();
|
||||
|
||||
@@ -890,6 +890,9 @@ test.describe('Doc Editor', () => {
|
||||
await expect(interlinkChild.locator('svg').first()).toBeHidden();
|
||||
await interlinkChild.click();
|
||||
|
||||
// wait for navigation to complete
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await verifyDocName(page, docChild2);
|
||||
|
||||
await editor.click();
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { Download, Page, expect, test } from '@playwright/test';
|
||||
import cs from 'convert-stream';
|
||||
import JSZip from 'jszip';
|
||||
import { PDFParse } from 'pdf-parse';
|
||||
|
||||
import {
|
||||
BrowserName,
|
||||
TestLanguage,
|
||||
createDoc,
|
||||
verifyDocName,
|
||||
waitForLanguageSwitch,
|
||||
} from './utils-common';
|
||||
import { openSuggestionMenu, writeInEditor } from './utils-editor';
|
||||
import { createRootSubPage } from './utils-sub-pages';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
@@ -34,9 +35,6 @@ test.describe('Doc Export', () => {
|
||||
await expect(
|
||||
page.getByText(/Download your document in a \.docx, \.odt.*format\./i),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('combobox', { name: 'Template' }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('combobox', { name: 'Format' })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
@@ -46,81 +44,14 @@ test.describe('Doc Export', () => {
|
||||
await expect(page.getByTestId('doc-export-download-button')).toBeVisible();
|
||||
});
|
||||
|
||||
test('it exports the doc with pdf line break', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
const [randomDoc] = await createDoc(
|
||||
page,
|
||||
'doc-editor-line-break',
|
||||
browserName,
|
||||
1,
|
||||
);
|
||||
|
||||
await verifyDocName(page, randomDoc);
|
||||
|
||||
const editor = await writeInEditor({ page, text: 'Hello' });
|
||||
await page.keyboard.press('Enter');
|
||||
await openSuggestionMenu({ page });
|
||||
await page.getByText('Page Break').click();
|
||||
|
||||
await expect(
|
||||
editor.locator('div[data-content-type="pageBreak"]'),
|
||||
).toBeVisible();
|
||||
|
||||
await writeInEditor({ page, text: 'World' });
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Export the document',
|
||||
})
|
||||
.click();
|
||||
|
||||
const downloadPromise = page.waitForEvent('download', (download) => {
|
||||
return download.suggestedFilename().includes(`${randomDoc}.pdf`);
|
||||
});
|
||||
|
||||
void page.getByTestId('doc-export-download-button').click();
|
||||
|
||||
const download = await downloadPromise;
|
||||
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
|
||||
|
||||
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
|
||||
const pdfParse = new PDFParse({ data: pdfBuffer });
|
||||
const pdfInfo = await pdfParse.getInfo();
|
||||
const pdfText = await pdfParse.getText();
|
||||
|
||||
expect(pdfInfo.total).toBe(2);
|
||||
expect(pdfText.pages).toStrictEqual([
|
||||
{ text: 'Hello', num: 1 },
|
||||
{ text: 'World', num: 2 },
|
||||
]);
|
||||
expect(pdfInfo?.info.Title).toBe(randomDoc);
|
||||
});
|
||||
|
||||
/**
|
||||
* We override the document content to ensure that the exported DOCX
|
||||
* contains various elements for testing.
|
||||
* We don't check the content of the DOCX here, just that the export works
|
||||
* and the file is correctly named.
|
||||
*/
|
||||
test('it exports the doc to docx', async ({ page, browserName }) => {
|
||||
const [randomDoc] = await createDoc(page, 'doc-editor', browserName, 1);
|
||||
|
||||
await verifyDocName(page, randomDoc);
|
||||
|
||||
await page.locator('.ProseMirror.bn-editor').click();
|
||||
await page.locator('.ProseMirror.bn-editor').fill('Hello World');
|
||||
|
||||
await page.keyboard.press('Enter');
|
||||
await page.locator('.bn-block-outer').last().fill('/');
|
||||
await page.getByText('Resizable image with caption').click();
|
||||
|
||||
const fileChooserPromise = page.waitForEvent('filechooser');
|
||||
await page.getByText('Upload image').click();
|
||||
|
||||
const fileChooser = await fileChooserPromise;
|
||||
await fileChooser.setFiles(path.join(__dirname, 'assets/test.svg'));
|
||||
|
||||
const image = page
|
||||
.locator('.--docs--editor-container img.bn-visual-media')
|
||||
.first();
|
||||
|
||||
await expect(image).toBeVisible();
|
||||
const randomDoc = await overrideDocContent({ page, browserName });
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
@@ -143,29 +74,14 @@ test.describe('Doc Export', () => {
|
||||
expect(download.suggestedFilename()).toBe(`${randomDoc}.docx`);
|
||||
});
|
||||
|
||||
/**
|
||||
* We override the document content to ensure that the exported ODT
|
||||
* contains various elements for testing.
|
||||
* We don't check the content of the ODT here, just that the export works
|
||||
* and the file is correctly named.
|
||||
*/
|
||||
test('it exports the doc to odt', async ({ page, browserName }) => {
|
||||
const [randomDoc] = await createDoc(page, 'doc-editor-odt', browserName, 1);
|
||||
|
||||
await verifyDocName(page, randomDoc);
|
||||
|
||||
await page.locator('.ProseMirror.bn-editor').click();
|
||||
await page.locator('.ProseMirror.bn-editor').fill('Hello World ODT');
|
||||
|
||||
await page.keyboard.press('Enter');
|
||||
await page.locator('.bn-block-outer').last().fill('/');
|
||||
await page.getByText('Resizable image with caption').click();
|
||||
|
||||
const fileChooserPromise = page.waitForEvent('filechooser');
|
||||
await page.getByText('Upload image').click();
|
||||
|
||||
const fileChooser = await fileChooserPromise;
|
||||
await fileChooser.setFiles(path.join(__dirname, 'assets/test.svg'));
|
||||
|
||||
const image = page
|
||||
.locator('.--docs--editor-container img.bn-visual-media')
|
||||
.first();
|
||||
|
||||
await expect(image).toBeVisible();
|
||||
const randomDoc = await overrideDocContent({ page, browserName });
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
@@ -317,20 +233,6 @@ test.describe('Doc Export', () => {
|
||||
})
|
||||
.click();
|
||||
|
||||
await page
|
||||
.getByRole('combobox', {
|
||||
name: 'Template',
|
||||
})
|
||||
.click();
|
||||
|
||||
await page
|
||||
.getByRole('option', {
|
||||
name: 'Demo Template',
|
||||
})
|
||||
.click({
|
||||
delay: 100,
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
await expect(page.getByTestId('doc-export-download-button')).toBeVisible();
|
||||
@@ -358,108 +260,6 @@ test.describe('Doc Export', () => {
|
||||
expect(pdfText.text).toContain('Hello World');
|
||||
});
|
||||
|
||||
test('it exports the doc with quotes', async ({ page, browserName }) => {
|
||||
const [randomDoc] = await createDoc(page, 'export-quotes', browserName, 1);
|
||||
|
||||
const editor = page.locator('.ProseMirror.bn-editor');
|
||||
// Trigger slash menu to show menu
|
||||
await editor.click();
|
||||
await editor.fill('/');
|
||||
await page.getByText('Quote or excerpt').click();
|
||||
|
||||
await expect(
|
||||
editor.locator('.bn-block-content[data-content-type="quote"]'),
|
||||
).toBeVisible();
|
||||
|
||||
await editor
|
||||
.locator('.bn-block-content[data-content-type="quote"]')
|
||||
.fill('Hello World');
|
||||
|
||||
await expect(editor.getByText('Hello World')).toHaveCSS(
|
||||
'font-style',
|
||||
'italic',
|
||||
);
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Export the document',
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect(page.getByTestId('doc-export-download-button')).toBeVisible();
|
||||
|
||||
const downloadPromise = page.waitForEvent('download', (download) => {
|
||||
return download.suggestedFilename().includes(`${randomDoc}.pdf`);
|
||||
});
|
||||
|
||||
void page.getByTestId('doc-export-download-button').click();
|
||||
|
||||
const download = await downloadPromise;
|
||||
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
|
||||
|
||||
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
|
||||
const pdfParse = new PDFParse({ data: pdfBuffer });
|
||||
const pdfText = await pdfParse.getText();
|
||||
expect(pdfText.text).toContain('Hello World');
|
||||
});
|
||||
|
||||
test('it exports the doc with multi columns', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
const [randomDoc] = await createDoc(
|
||||
page,
|
||||
'doc-multi-columns',
|
||||
browserName,
|
||||
1,
|
||||
);
|
||||
|
||||
await page.locator('.bn-block-outer').last().fill('/');
|
||||
|
||||
await page.getByText('Three Columns', { exact: true }).click();
|
||||
|
||||
await page.locator('.bn-block-column').first().fill('Column 1');
|
||||
await page.locator('.bn-block-column').nth(1).fill('Column 2');
|
||||
await page.locator('.bn-block-column').last().fill('Column 3');
|
||||
|
||||
expect(await page.locator('.bn-block-column').count()).toBe(3);
|
||||
await expect(
|
||||
page.locator('.bn-block-column[data-node-type="column"]').first(),
|
||||
).toHaveText('Column 1');
|
||||
await expect(
|
||||
page.locator('.bn-block-column[data-node-type="column"]').nth(1),
|
||||
).toHaveText('Column 2');
|
||||
await expect(
|
||||
page.locator('.bn-block-column[data-node-type="column"]').last(),
|
||||
).toHaveText('Column 3');
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Export the document',
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByTestId('doc-open-modal-download-button'),
|
||||
).toBeVisible();
|
||||
|
||||
const downloadPromise = page.waitForEvent('download', (download) => {
|
||||
return download.suggestedFilename().includes(`${randomDoc}.pdf`);
|
||||
});
|
||||
|
||||
void page.getByTestId('doc-export-download-button').click();
|
||||
|
||||
const download = await downloadPromise;
|
||||
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
|
||||
|
||||
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
|
||||
const pdfParse = new PDFParse({ data: pdfBuffer });
|
||||
const pdfText = await pdfParse.getText();
|
||||
expect(pdfText.text).toContain('Column 1');
|
||||
expect(pdfText.text).toContain('Column 2');
|
||||
expect(pdfText.text).toContain('Column 3');
|
||||
});
|
||||
|
||||
test('it injects the correct language attribute into PDF export', async ({
|
||||
page,
|
||||
browserName,
|
||||
@@ -506,53 +306,18 @@ test.describe('Doc Export', () => {
|
||||
expect(pdfString).toContain('/Lang (fr)');
|
||||
});
|
||||
|
||||
test('it exports the doc with interlinking', async ({
|
||||
test('it exports the doc to PDF and checks regressions', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
const [randomDoc] = await createDoc(
|
||||
page,
|
||||
'export-interlinking',
|
||||
browserName,
|
||||
1,
|
||||
);
|
||||
// PDF Binary comparison is different depending on the browser used
|
||||
// We only run this test on Chromium to avoid having to maintain
|
||||
// multiple sets of PDF fixtures
|
||||
if (browserName !== 'chromium') {
|
||||
test.skip();
|
||||
}
|
||||
|
||||
await verifyDocName(page, randomDoc);
|
||||
|
||||
const { name: docChild } = await createRootSubPage(
|
||||
page,
|
||||
browserName,
|
||||
'export-interlink-child',
|
||||
);
|
||||
|
||||
await verifyDocName(page, docChild);
|
||||
|
||||
const editor = await openSuggestionMenu({ page });
|
||||
await page.getByText('Link a doc').first().click();
|
||||
|
||||
const input = page.locator(
|
||||
"span[data-inline-content-type='interlinkingSearchInline'] input",
|
||||
);
|
||||
const searchContainer = page.locator('.quick-search-container');
|
||||
|
||||
await input.fill('export-interlink');
|
||||
|
||||
await expect(searchContainer).toBeVisible();
|
||||
await expect(searchContainer.getByText(randomDoc)).toBeVisible();
|
||||
|
||||
// We are in docChild, we want to create a link to randomDoc (parent)
|
||||
await searchContainer.getByText(randomDoc).click();
|
||||
|
||||
// Search the interlinking link in the editor (not in the document tree)
|
||||
const interlink = editor
|
||||
.locator('.--docs--interlinking-link-inline-content')
|
||||
.first();
|
||||
|
||||
await expect(interlink).toContainText(randomDoc);
|
||||
|
||||
const downloadPromise = page.waitForEvent('download', (download) => {
|
||||
return download.suggestedFilename().includes(`${docChild}.pdf`);
|
||||
});
|
||||
const randomDoc = await overrideDocContent({ page, browserName });
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
@@ -560,77 +325,165 @@ test.describe('Doc Export', () => {
|
||||
})
|
||||
.click();
|
||||
|
||||
void page.getByTestId('doc-export-download-button').click();
|
||||
|
||||
const download = await downloadPromise;
|
||||
expect(download.suggestedFilename()).toBe(`${docChild}.pdf`);
|
||||
|
||||
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
|
||||
const pdfParse = new PDFParse({ data: pdfBuffer });
|
||||
const pdfText = await pdfParse.getText();
|
||||
expect(pdfText.text).toContain(randomDoc);
|
||||
});
|
||||
|
||||
test('it exports the doc with interlinking to odt', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
const [randomDoc] = await createDoc(
|
||||
page,
|
||||
'export-interlinking-odt',
|
||||
browserName,
|
||||
1,
|
||||
);
|
||||
|
||||
await verifyDocName(page, randomDoc);
|
||||
|
||||
const { name: docChild } = await createRootSubPage(
|
||||
page,
|
||||
browserName,
|
||||
'export-interlink-child-odt',
|
||||
);
|
||||
|
||||
await verifyDocName(page, docChild);
|
||||
|
||||
const editor = await openSuggestionMenu({ page });
|
||||
await page.getByText('Link a doc').first().click();
|
||||
|
||||
const input = page.locator(
|
||||
"span[data-inline-content-type='interlinkingSearchInline'] input",
|
||||
);
|
||||
const searchContainer = page.locator('.quick-search-container');
|
||||
|
||||
await input.fill('export-interlink');
|
||||
|
||||
await expect(searchContainer).toBeVisible();
|
||||
await expect(searchContainer.getByText(randomDoc)).toBeVisible();
|
||||
|
||||
// We are in docChild, we want to create a link to randomDoc (parent)
|
||||
await searchContainer.getByText(randomDoc).click();
|
||||
|
||||
// Search the interlinking link in the editor (not in the document tree)
|
||||
const interlink = editor
|
||||
.locator('.--docs--interlinking-link-inline-content')
|
||||
.first();
|
||||
|
||||
await expect(interlink).toContainText(randomDoc);
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Export the document',
|
||||
})
|
||||
.click();
|
||||
|
||||
await page.getByRole('combobox', { name: 'Format' }).click();
|
||||
await page.getByRole('option', { name: 'Odt' }).click();
|
||||
await expect(
|
||||
page.getByTestId('doc-open-modal-download-button'),
|
||||
).toBeVisible();
|
||||
|
||||
const downloadPromise = page.waitForEvent('download', (download) => {
|
||||
return download.suggestedFilename().includes(`${docChild}.odt`);
|
||||
return download.suggestedFilename().includes(`${randomDoc}.pdf`);
|
||||
});
|
||||
|
||||
void page.getByTestId('doc-export-download-button').click();
|
||||
await page.getByTestId('doc-export-download-button').click();
|
||||
|
||||
const download = await downloadPromise;
|
||||
expect(download.suggestedFilename()).toBe(`${docChild}.odt`);
|
||||
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
|
||||
|
||||
// If we need to update the PDF regression fixture, uncomment the line below
|
||||
//await savePDFToAssetFolder(download);
|
||||
|
||||
// Assert the generated PDF matches "assets/doc-export-regressions.pdf"
|
||||
await comparePDFWithAssetFolder(download);
|
||||
});
|
||||
});
|
||||
|
||||
export const savePDFToAssetFolder = async (download: Download) => {
|
||||
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
|
||||
const pdfPath = path.join(__dirname, 'assets', `doc-export-regressions.pdf`);
|
||||
fs.writeFileSync(pdfPath, pdfBuffer);
|
||||
};
|
||||
|
||||
export const comparePDFWithAssetFolder = async (download: Download) => {
|
||||
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
|
||||
|
||||
// Load reference PDF for comparison
|
||||
const referencePdfPath = path.join(
|
||||
__dirname,
|
||||
'assets',
|
||||
'doc-export-regressions.pdf',
|
||||
);
|
||||
|
||||
const referencePdfBuffer = fs.readFileSync(referencePdfPath);
|
||||
|
||||
// Parse both PDFs
|
||||
const generatedPdf = new PDFParse({ data: pdfBuffer });
|
||||
const referencePdf = new PDFParse({ data: referencePdfBuffer });
|
||||
|
||||
const [generatedInfo, referenceInfo] = await Promise.all([
|
||||
generatedPdf.getInfo(),
|
||||
referencePdf.getInfo(),
|
||||
]);
|
||||
|
||||
const [generatedScreenshot, referenceScreenshot] = await Promise.all([
|
||||
generatedPdf.getScreenshot(),
|
||||
referencePdf.getScreenshot(),
|
||||
]);
|
||||
generatedScreenshot.pages[0].data;
|
||||
|
||||
const [generatedText, referenceText] = await Promise.all([
|
||||
generatedPdf.getText(),
|
||||
referencePdf.getText(),
|
||||
]);
|
||||
|
||||
// Compare page count
|
||||
expect(generatedInfo.total).toBe(referenceInfo.total);
|
||||
|
||||
// Compare text content
|
||||
expect(generatedText.text).toBe(referenceText.text);
|
||||
|
||||
// Compare screenshots page by page
|
||||
for (let i = 0; i < generatedScreenshot.pages.length; i++) {
|
||||
const genPage = generatedScreenshot.pages[i];
|
||||
const refPage = referenceScreenshot.pages[i];
|
||||
|
||||
expect(genPage.width).toBe(refPage.width);
|
||||
expect(genPage.height).toBe(refPage.height);
|
||||
try {
|
||||
expect(genPage.data).toStrictEqual(refPage.data);
|
||||
} catch {
|
||||
throw new Error(`PDF page ${i + 1} screenshot does not match reference.`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Override the document content API response to use a test content
|
||||
* This test content contains many blocks to facilitate testing
|
||||
* @param page
|
||||
*/
|
||||
export const overrideDocContent = async ({
|
||||
page,
|
||||
browserName,
|
||||
}: {
|
||||
page: Page;
|
||||
browserName: BrowserName;
|
||||
}) => {
|
||||
// Override content prop with assets/base-content-test-pdf.txt
|
||||
await page.route(/\**\/documents\/\**/, async (route) => {
|
||||
const request = route.request();
|
||||
if (
|
||||
request.method().includes('GET') &&
|
||||
!request.url().includes('page=') &&
|
||||
!request.url().includes('versions') &&
|
||||
!request.url().includes('accesses') &&
|
||||
!request.url().includes('invitations')
|
||||
) {
|
||||
const response = await route.fetch();
|
||||
const json = await response.json();
|
||||
json.content = fs.readFileSync(
|
||||
path.join(__dirname, 'assets/base-content-test-pdf.txt'),
|
||||
'utf-8',
|
||||
);
|
||||
void route.fulfill({
|
||||
response,
|
||||
body: JSON.stringify(json),
|
||||
});
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
const [randomDoc] = await createDoc(
|
||||
page,
|
||||
'doc-export-override-content',
|
||||
browserName,
|
||||
1,
|
||||
);
|
||||
|
||||
await verifyDocName(page, randomDoc);
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Add Image SVG
|
||||
await page.keyboard.press('Enter');
|
||||
const { suggestionMenu } = await openSuggestionMenu({ page });
|
||||
await suggestionMenu.getByText('Resizable image with caption').click();
|
||||
const fileChooserPromise = page.waitForEvent('filechooser');
|
||||
await page.getByText('Upload image').click();
|
||||
const fileChooser = await fileChooserPromise;
|
||||
await fileChooser.setFiles(path.join(__dirname, 'assets/test.svg'));
|
||||
const image = page
|
||||
.locator('.--docs--editor-container img.bn-visual-media[src$=".svg"]')
|
||||
.first();
|
||||
await expect(image).toBeVisible();
|
||||
await page.keyboard.press('Enter');
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Add Image PNG
|
||||
await openSuggestionMenu({ page });
|
||||
await suggestionMenu.getByText('Resizable image with caption').click();
|
||||
const fileChooserPNGPromise = page.waitForEvent('filechooser');
|
||||
await page.getByText('Upload image').click();
|
||||
const fileChooserPNG = await fileChooserPNGPromise;
|
||||
await fileChooserPNG.setFiles(
|
||||
path.join(__dirname, 'assets/logo-suite-numerique.png'),
|
||||
);
|
||||
const imagePng = page
|
||||
.locator('.--docs--editor-container img.bn-visual-media[src$=".png"]')
|
||||
.first();
|
||||
await expect(imagePng).toBeVisible();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
return randomDoc;
|
||||
};
|
||||
|
||||
@@ -232,6 +232,7 @@ const data = [
|
||||
depth: 1,
|
||||
excerpt: null,
|
||||
is_favorite: false,
|
||||
is_encrypted: false,
|
||||
link_role: 'reader',
|
||||
link_reach: 'restricted',
|
||||
nb_accesses_ancestors: 1,
|
||||
@@ -281,6 +282,7 @@ const data = [
|
||||
depth: 1,
|
||||
excerpt: null,
|
||||
is_favorite: false,
|
||||
is_encrypted: false,
|
||||
link_role: 'reader',
|
||||
link_reach: 'restricted',
|
||||
nb_accesses_ancestors: 1,
|
||||
@@ -329,6 +331,7 @@ const data = [
|
||||
depth: 1,
|
||||
excerpt: null,
|
||||
is_favorite: false,
|
||||
is_encrypted: false,
|
||||
link_role: 'reader',
|
||||
link_reach: 'restricted',
|
||||
nb_accesses_ancestors: 14,
|
||||
|
||||
@@ -7,7 +7,12 @@ import {
|
||||
mockedDocument,
|
||||
verifyDocName,
|
||||
} from './utils-common';
|
||||
import { mockedAccesses, mockedInvitations } from './utils-share';
|
||||
import {
|
||||
connectOtherUserToDoc,
|
||||
mockedAccesses,
|
||||
mockedInvitations,
|
||||
updateShareLink,
|
||||
} from './utils-share';
|
||||
import { createRootSubPage, getTreeRow } from './utils-sub-pages';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
@@ -52,13 +57,54 @@ test.describe('Doc Header', () => {
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('it updates the title doc', async ({ page, browserName }) => {
|
||||
await createDoc(page, 'doc-update', browserName, 1);
|
||||
const docTitle = page.getByRole('textbox', { name: 'Document title' });
|
||||
await expect(docTitle).toBeVisible();
|
||||
await docTitle.fill('Hello World');
|
||||
await docTitle.blur();
|
||||
test('it updates the title doc and check the broadcast', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
const [docTitle] = await createDoc(
|
||||
page,
|
||||
'doc-title-update',
|
||||
browserName,
|
||||
1,
|
||||
);
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
await updateShareLink(page, 'Public', 'Editing');
|
||||
|
||||
const docUrl = page.url();
|
||||
|
||||
const { otherPage, cleanup } = await connectOtherUserToDoc({
|
||||
docUrl,
|
||||
browserName,
|
||||
withoutSignIn: true,
|
||||
docTitle,
|
||||
});
|
||||
|
||||
// Wait for other page to sync
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
const elTitle = page.getByRole('textbox', { name: 'Document title' });
|
||||
await expect(elTitle).toBeVisible();
|
||||
await elTitle.fill('Hello World');
|
||||
await elTitle.blur();
|
||||
await verifyDocName(page, 'Hello World');
|
||||
|
||||
// Wait for other page to sync
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Check other user page
|
||||
await verifyDocName(otherPage, 'Hello World');
|
||||
|
||||
const elTitleOther = otherPage.getByRole('textbox', {
|
||||
name: 'Document title',
|
||||
});
|
||||
await elTitleOther.fill('Hello Other World');
|
||||
await elTitleOther.blur();
|
||||
|
||||
// Check first user page
|
||||
await verifyDocName(page, 'Hello Other World');
|
||||
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
test('it updates the title doc adding a leading emoji', async ({
|
||||
|
||||
181
src/frontend/apps/e2e/__tests__/app-impress/doc-import.spec.ts
Normal file
181
src/frontend/apps/e2e/__tests__/app-impress/doc-import.spec.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { Page, expect, test } from '@playwright/test';
|
||||
|
||||
import { getEditor } from './utils-editor';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
test.describe('Doc Import', () => {
|
||||
test('it imports 2 docs with the import icon', async ({ page }) => {
|
||||
const fileChooserPromise = page.waitForEvent('filechooser');
|
||||
await page.getByLabel('Open the upload dialog').click();
|
||||
|
||||
const fileChooser = await fileChooserPromise;
|
||||
await fileChooser.setFiles([
|
||||
path.join(__dirname, 'assets/test_import.docx'),
|
||||
path.join(__dirname, 'assets/test_import.md'),
|
||||
]);
|
||||
|
||||
await expect(
|
||||
page.getByText(
|
||||
'The document "test_import.docx" has been successfully imported',
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(
|
||||
'The document "test_import.md" has been successfully imported',
|
||||
),
|
||||
).toBeVisible();
|
||||
|
||||
const docsGrid = page.getByTestId('docs-grid');
|
||||
await expect(docsGrid.getByText('test_import.docx').first()).toBeVisible();
|
||||
await expect(docsGrid.getByText('test_import.md').first()).toBeVisible();
|
||||
|
||||
// Check content of imported md
|
||||
await docsGrid.getByText('test_import.md').first().click();
|
||||
const editor = await getEditor({ page });
|
||||
|
||||
const contentCheck = async (isMDCheck = false) => {
|
||||
await expect(
|
||||
editor.getByRole('heading', {
|
||||
name: 'Lorem Ipsum import Document',
|
||||
level: 1,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
editor.getByRole('heading', {
|
||||
name: 'Introduction',
|
||||
level: 2,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
editor.getByRole('heading', {
|
||||
name: 'Subsection 1.1',
|
||||
level: 3,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
editor
|
||||
.locator('div[data-content-type="bulletListItem"] strong')
|
||||
.getByText('Bold text'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
editor
|
||||
.locator('div[data-content-type="codeBlock"]')
|
||||
.getByText('hello_world'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
editor
|
||||
.locator('div[data-content-type="table"] td')
|
||||
.getByText('Paragraph'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
editor.locator('a[href="http://localhost:3000/"]').getByText('Example'),
|
||||
).toBeVisible();
|
||||
|
||||
/* eslint-disable playwright/no-conditional-expect */
|
||||
if (isMDCheck) {
|
||||
await expect(
|
||||
editor.locator(
|
||||
'img[src="http://localhost:3000/assets/logo-suite-numerique.png"]',
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
editor.locator(
|
||||
'img[src="http://localhost:3000/assets/icon-docs.svg"]',
|
||||
),
|
||||
).toBeVisible();
|
||||
} else {
|
||||
await expect(editor.locator('img')).toHaveCount(2);
|
||||
}
|
||||
/* eslint-enable playwright/no-conditional-expect */
|
||||
|
||||
/**
|
||||
* Divider are not supported in docx import in DocSpec 2.4.4
|
||||
*/
|
||||
/* eslint-disable playwright/no-conditional-expect */
|
||||
if (isMDCheck) {
|
||||
await expect(
|
||||
editor.locator('div[data-content-type="divider"] hr'),
|
||||
).toBeVisible();
|
||||
}
|
||||
/* eslint-enable playwright/no-conditional-expect */
|
||||
};
|
||||
|
||||
await contentCheck(true);
|
||||
|
||||
// Check content of imported docx
|
||||
await page.getByLabel('Back to homepage').first().click();
|
||||
await docsGrid.getByText('test_import.docx').first().click();
|
||||
|
||||
await contentCheck();
|
||||
});
|
||||
|
||||
test('it imports 2 docs with the drag and drop area', async ({ page }) => {
|
||||
const docsGrid = page.getByTestId('docs-grid');
|
||||
await expect(docsGrid).toBeVisible();
|
||||
|
||||
await dragAndDropFiles(page, "[data-testid='docs-grid']", [
|
||||
{
|
||||
filePath: path.join(__dirname, 'assets/test_import.docx'),
|
||||
fileName: 'test_import.docx',
|
||||
fileType:
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
},
|
||||
{
|
||||
filePath: path.join(__dirname, 'assets/test_import.md'),
|
||||
fileName: 'test_import.md',
|
||||
fileType: 'text/markdown',
|
||||
},
|
||||
]);
|
||||
|
||||
// Wait for success messages
|
||||
await expect(
|
||||
page.getByText(
|
||||
'The document "test_import.docx" has been successfully imported',
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(
|
||||
'The document "test_import.md" has been successfully imported',
|
||||
),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(docsGrid.getByText('test_import.docx').first()).toBeVisible();
|
||||
await expect(docsGrid.getByText('test_import.md').first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
const dragAndDropFiles = async (
|
||||
page: Page,
|
||||
selector: string,
|
||||
files: Array<{ filePath: string; fileName: string; fileType?: string }>,
|
||||
) => {
|
||||
const filesData = files.map((file) => ({
|
||||
bufferData: `data:application/octet-stream;base64,${readFileSync(file.filePath).toString('base64')}`,
|
||||
fileName: file.fileName,
|
||||
fileType: file.fileType || '',
|
||||
}));
|
||||
|
||||
const dataTransfer = await page.evaluateHandle(async (filesInfo) => {
|
||||
const dt = new DataTransfer();
|
||||
|
||||
for (const fileInfo of filesInfo) {
|
||||
const blobData = await fetch(fileInfo.bufferData).then((res) =>
|
||||
res.blob(),
|
||||
);
|
||||
const file = new File([blobData], fileInfo.fileName, {
|
||||
type: fileInfo.fileType,
|
||||
});
|
||||
dt.items.add(file);
|
||||
}
|
||||
|
||||
return dt;
|
||||
}, filesData);
|
||||
|
||||
await page.dispatchEvent(selector, 'drop', { dataTransfer });
|
||||
};
|
||||
@@ -21,7 +21,7 @@ test.describe('Inherited share accesses', () => {
|
||||
`doc-share-member-row-user.test@${browserName}.test`,
|
||||
);
|
||||
await expect(user).toBeVisible();
|
||||
await expect(user.getByText('E2E Chromium')).toBeVisible();
|
||||
await expect(user.getByText(`E2E ${browserName}`)).toBeVisible();
|
||||
await expect(user.getByText('Owner')).toBeVisible();
|
||||
|
||||
await page
|
||||
|
||||
@@ -45,7 +45,7 @@ test.describe('Document search', () => {
|
||||
const listSearch = page.getByRole('listbox').getByRole('group');
|
||||
const rowdoc = listSearch.getByRole('option').first();
|
||||
await expect(rowdoc.getByText('keyboard_return')).toBeVisible();
|
||||
await expect(rowdoc.getByText(/seconds? ago/)).toBeVisible();
|
||||
await expect(rowdoc.getByText(/just now/)).toBeVisible();
|
||||
|
||||
await expect(
|
||||
listSearch.getByRole('option').getByText(doc1Title),
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
createDoc,
|
||||
expectLoginPage,
|
||||
keyCloakSignIn,
|
||||
randomName,
|
||||
updateDocTitle,
|
||||
verifyDocName,
|
||||
} from './utils-common';
|
||||
@@ -20,50 +19,6 @@ test.describe('Doc Tree', () => {
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
test('create new sub pages', async ({ page, browserName }) => {
|
||||
const [titleParent] = await createDoc(
|
||||
page,
|
||||
'doc-tree-content',
|
||||
browserName,
|
||||
1,
|
||||
);
|
||||
await verifyDocName(page, titleParent);
|
||||
const addButton = page.getByTestId('new-doc-button');
|
||||
const docTree = page.getByTestId('doc-tree');
|
||||
|
||||
await expect(addButton).toBeVisible();
|
||||
|
||||
// Wait for and intercept the POST request to create a new page
|
||||
const responsePromise = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/documents/') &&
|
||||
response.url().includes('/children/') &&
|
||||
response.request().method() === 'POST',
|
||||
);
|
||||
|
||||
await clickOnAddRootSubPage(page);
|
||||
const response = await responsePromise;
|
||||
expect(response.ok()).toBeTruthy();
|
||||
const subPageJson = await response.json();
|
||||
|
||||
await expect(docTree).toBeVisible();
|
||||
const subPageItem = docTree
|
||||
.getByTestId(`doc-sub-page-item-${subPageJson.id}`)
|
||||
.first();
|
||||
|
||||
await expect(subPageItem).toBeVisible();
|
||||
await subPageItem.click();
|
||||
await verifyDocName(page, '');
|
||||
const input = page.getByRole('textbox', { name: 'Document title' });
|
||||
await input.click();
|
||||
const [randomDocName] = randomName('doc-tree-test', browserName, 1);
|
||||
await input.fill(randomDocName);
|
||||
await input.press('Enter');
|
||||
await expect(subPageItem.getByText(randomDocName)).toBeVisible();
|
||||
await page.reload();
|
||||
await expect(subPageItem.getByText(randomDocName)).toBeVisible();
|
||||
});
|
||||
|
||||
test('check the reorder of sub pages', async ({ page, browserName }) => {
|
||||
await createDoc(page, 'doc-tree-content', browserName, 1);
|
||||
const addButton = page.getByTestId('new-doc-button');
|
||||
@@ -72,47 +27,32 @@ test.describe('Doc Tree', () => {
|
||||
const docTree = page.getByTestId('doc-tree');
|
||||
|
||||
// Create first sub page
|
||||
const firstResponsePromise = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/documents/') &&
|
||||
response.url().includes('/children/') &&
|
||||
response.request().method() === 'POST',
|
||||
);
|
||||
|
||||
await clickOnAddRootSubPage(page);
|
||||
const firstResponse = await firstResponsePromise;
|
||||
expect(firstResponse.ok()).toBeTruthy();
|
||||
await updateDocTitle(page, 'first');
|
||||
|
||||
const secondResponsePromise = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/documents/') &&
|
||||
response.url().includes('/children/') &&
|
||||
response.request().method() === 'POST',
|
||||
);
|
||||
await updateDocTitle(page, 'first move');
|
||||
|
||||
// Create second sub page
|
||||
await clickOnAddRootSubPage(page);
|
||||
const secondResponse = await secondResponsePromise;
|
||||
expect(secondResponse.ok()).toBeTruthy();
|
||||
await updateDocTitle(page, 'second');
|
||||
await updateDocTitle(page, 'second move');
|
||||
|
||||
const secondSubPageJson = await secondResponse.json();
|
||||
const firstSubPageJson = await firstResponse.json();
|
||||
|
||||
const firstSubPageItem = docTree
|
||||
.getByTestId(`doc-sub-page-item-${firstSubPageJson.id}`)
|
||||
.first();
|
||||
|
||||
const secondSubPageItem = docTree
|
||||
.getByTestId(`doc-sub-page-item-${secondSubPageJson.id}`)
|
||||
.first();
|
||||
const firstSubPageItem = docTree.getByText('first move').first();
|
||||
const secondSubPageItem = docTree.getByText('second move').first();
|
||||
|
||||
// check that the sub pages are visible in the tree
|
||||
await expect(firstSubPageItem).toBeVisible();
|
||||
await expect(secondSubPageItem).toBeVisible();
|
||||
|
||||
// get the bounding boxes of the sub pages
|
||||
// Check the position of the sub pages
|
||||
const allSubPageItems = await docTree
|
||||
.getByTestId(/^doc-sub-page-item/)
|
||||
.all();
|
||||
|
||||
expect(allSubPageItems.length).toBe(2);
|
||||
|
||||
// Check that elements are in the correct order
|
||||
await expect(allSubPageItems[0].getByText('first move')).toBeVisible();
|
||||
await expect(allSubPageItems[1].getByText('second move')).toBeVisible();
|
||||
|
||||
// Will move the first sub page to the second position
|
||||
const firstSubPageBoundingBox = await firstSubPageItem.boundingBox();
|
||||
const secondSubPageBoundingBox = await secondSubPageItem.boundingBox();
|
||||
|
||||
@@ -120,10 +60,9 @@ test.describe('Doc Tree', () => {
|
||||
expect(secondSubPageBoundingBox).toBeDefined();
|
||||
|
||||
if (!firstSubPageBoundingBox || !secondSubPageBoundingBox) {
|
||||
throw new Error('Impossible de déterminer la position des éléments');
|
||||
throw new Error('unable to determine the position of the elements');
|
||||
}
|
||||
|
||||
// move the first sub page to the second position
|
||||
await page.mouse.move(
|
||||
firstSubPageBoundingBox.x + firstSubPageBoundingBox.width / 2,
|
||||
firstSubPageBoundingBox.y + firstSubPageBoundingBox.height / 2,
|
||||
@@ -150,24 +89,19 @@ test.describe('Doc Tree', () => {
|
||||
await expect(firstSubPageItem).toBeVisible();
|
||||
await expect(secondSubPageItem).toBeVisible();
|
||||
|
||||
// Check the position of the sub pages
|
||||
const allSubPageItems = await docTree
|
||||
// Check that elements are in the correct order
|
||||
const allSubPageItemsAfterReload = await docTree
|
||||
.getByTestId(/^doc-sub-page-item/)
|
||||
.all();
|
||||
|
||||
expect(allSubPageItems.length).toBe(2);
|
||||
expect(allSubPageItemsAfterReload.length).toBe(2);
|
||||
|
||||
// Check that the first element has the ID of the second sub page after the drag and drop
|
||||
await expect(allSubPageItems[0]).toHaveAttribute(
|
||||
'data-testid',
|
||||
`doc-sub-page-item-${secondSubPageJson.id}`,
|
||||
);
|
||||
|
||||
// Check that the second element has the ID of the first sub page after the drag and drop
|
||||
await expect(allSubPageItems[1]).toHaveAttribute(
|
||||
'data-testid',
|
||||
`doc-sub-page-item-${firstSubPageJson.id}`,
|
||||
);
|
||||
await expect(
|
||||
allSubPageItemsAfterReload[0].getByText('second move'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
allSubPageItemsAfterReload[1].getByText('first move'),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('it detaches a document', async ({ page, browserName }) => {
|
||||
|
||||
@@ -42,8 +42,8 @@ test.describe('Doc Version', () => {
|
||||
// Write more
|
||||
await writeInEditor({ page, text: 'It will create a version' });
|
||||
|
||||
await openSuggestionMenu({ page });
|
||||
await page.getByText('Add a callout block').click();
|
||||
const { suggestionMenu } = await openSuggestionMenu({ page });
|
||||
await suggestionMenu.getByText('Add a callout block').click();
|
||||
|
||||
const calloutBlock = page
|
||||
.locator('div[data-content-type="callout"]')
|
||||
|
||||
@@ -59,45 +59,90 @@ test.describe('Header', () => {
|
||||
).toBeVisible();
|
||||
|
||||
await expect(header.getByText('English')).toBeVisible();
|
||||
|
||||
await expect(
|
||||
header.getByRole('button', {
|
||||
name: 'Les services de La Suite numérique',
|
||||
}),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('checks La Gauffre interaction', async ({ page }) => {
|
||||
test('checks a custom waffle', async ({ page }) => {
|
||||
await overrideConfig(page, {
|
||||
FRONTEND_THEME: 'dsfr',
|
||||
theme_customization: {
|
||||
waffle: {
|
||||
data: {
|
||||
services: [
|
||||
{
|
||||
name: 'Docs E2E Custom 1',
|
||||
url: 'https://docs.numerique.gouv.fr/',
|
||||
maturity: 'stable',
|
||||
logo: 'https://lasuite.numerique.gouv.fr/assets/products/docs.svg',
|
||||
},
|
||||
{
|
||||
name: 'Docs E2E Custom 2',
|
||||
url: 'https://docs.numerique.gouv.fr/',
|
||||
maturity: 'stable',
|
||||
logo: 'https://lasuite.numerique.gouv.fr/assets/products/docs.svg',
|
||||
},
|
||||
],
|
||||
},
|
||||
showMoreLimit: 9,
|
||||
},
|
||||
},
|
||||
});
|
||||
await page.goto('/');
|
||||
|
||||
const header = page.locator('header').first();
|
||||
|
||||
await expect(
|
||||
header.getByRole('button', {
|
||||
name: 'Les services de La Suite numérique',
|
||||
}),
|
||||
header.getByRole('button', { name: 'Digital LaSuite services' }),
|
||||
).toBeVisible();
|
||||
|
||||
/**
|
||||
* La gaufre load a js file from a remote server,
|
||||
* The Waffle loads a js file from a remote server,
|
||||
* it takes some time to load the file and have the interaction available
|
||||
*/
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
await header
|
||||
.getByRole('button', { name: 'Digital LaSuite services' })
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('link', { name: 'Docs E2E Custom 1' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('link', { name: 'Docs E2E Custom 2' }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('checks the waffle dsfr', async ({ page }) => {
|
||||
await overrideConfig(page, {
|
||||
theme_customization: {
|
||||
waffle: {
|
||||
apiUrl: 'https://lasuite.numerique.gouv.fr/api/services',
|
||||
showMoreLimit: 9,
|
||||
},
|
||||
},
|
||||
});
|
||||
await page.goto('/');
|
||||
|
||||
const header = page.locator('header').first();
|
||||
|
||||
await expect(
|
||||
header.getByRole('button', { name: 'Digital LaSuite services' }),
|
||||
).toBeVisible();
|
||||
|
||||
/**
|
||||
* The Waffle loads a js file from a remote server,
|
||||
* it takes some time to load the file and have the interaction available
|
||||
*/
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
await header
|
||||
.getByRole('button', {
|
||||
name: 'Les services de La Suite numérique',
|
||||
name: 'Digital LaSuite services',
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('link', { name: 'France Transfert' }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Tchap' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Grist' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Visio' })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -124,11 +169,6 @@ test.describe('Header mobile', () => {
|
||||
|
||||
await expect(header.getByLabel('Open the header menu')).toBeVisible();
|
||||
await expect(header.getByTestId('header-icon-docs')).toBeVisible();
|
||||
await expect(
|
||||
header.getByRole('button', {
|
||||
name: 'Les services de La Suite numérique',
|
||||
}),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user