Compare commits

..

1 Commits

Author SHA1 Message Date
Anthony LC
e3d1f180f2 ⬆️(dependencies) bump to blocknote 0.45.0
Bump to BlockNote 0.45.0 to get the latest
features and fixes.
This release includes the fix for the table
deletion that breaks the editor when
deleting tables.
2025-12-22 09:45:02 +01:00
294 changed files with 10994 additions and 18049 deletions

View File

@@ -146,9 +146,8 @@ jobs:
notify-argocd:
needs:
- build-and-push-backend
- build-and-push-frontend
- build-and-push-y-provider
- build-and-push-backend
runs-on: ubuntu-latest
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview')
steps:

View File

@@ -19,8 +19,6 @@ jobs:
test-front:
needs: install-dependencies
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -43,8 +41,6 @@ jobs:
lint-front:
runs-on: ubuntu-latest
needs: install-dependencies
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -197,38 +193,3 @@ 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

View File

@@ -27,7 +27,7 @@ jobs:
- name: Enforce absence of print statements in code
if: always()
run: |
! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- src/backend ':(exclude)**/impress.yml' | grep "print("
! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- . ':(exclude)**/impress.yml' | grep "print("
- name: Check absence of fixup commits
if: always()
run: |
@@ -202,7 +202,7 @@ jobs:
run: |
sudo apt-get update
sudo apt-get install -y gettext pandoc shared-mime-info
sudo wget https://raw.githubusercontent.com/suitenumerique/django-lasuite/refs/heads/main/assets/conf/mime.types -O /etc/mime.types
sudo wget https://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types -O /etc/mime.types
- name: Generate a MO file from strings extracted from the project
run: python manage.py compilemessages

View File

@@ -6,103 +6,11 @@ and this project adheres to
## [Unreleased]
### Added
- ✨(frontend) Can print a doc #1832
- ✨(backend) manage reconciliation requests for user accounts #1878
- ✨(backend) add management command to reset a Document #1882
### Changed
- ♿️(frontend) Focus main container after navigation #1854
- 🚸(backend) sort user search results by proximity with the active user #1802
### Fixed
- 🐛(frontend) fix broadcast store sync #1846
- 🐛(frontend) fix tables deletion #1752
## [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
- 📱(frontend) add comments for smaller device #1737
- ✨(project) add custom js support via config #1759
### Changed
- 🥅(frontend) intercept 401 error on GET threads #1754
- 🦺(frontend) check content type pdf on PdfBlock #1756
- ✈️(frontend) pause Posthog when offline #1755
### Fixed
- 🐛(frontend) fix tables deletion #1739
- 🐛(frontend) fix children not display when first resize #1753
## [v4.2.0] - 2025-12-17
## [4.2.0] - 2025-12-17
### Added
@@ -126,7 +34,8 @@ and this project adheres to
- 🐛(frontend) Select text + Go back one page crash the app #1733
- 🐛(frontend) fix versioning conflict #1742
## [v4.1.0] - 2025-12-09
## [4.1.0] - 2025-12-09
### Added
@@ -145,7 +54,7 @@ and this project adheres to
- 🐛(nginx) fix / location to handle new static pages #1682
- 🐛(frontend) rerendering during resize window #1715
## [v4.0.0] - 2025-12-01
## [4.0.0] - 2025-12-01
### Added
@@ -168,7 +77,7 @@ and this project adheres to
- 🐛(frontend) preserve left panel width on window resize #1588
- 🐛(frontend) prevent duplicate as first character in title #1595
## [v3.10.0] - 2025-11-18
## [3.10.0] - 2025-11-18
### Added
@@ -202,7 +111,7 @@ and this project adheres to
- 🔥(backend) remove api managing templates
## [v3.9.0] - 2025-11-10
## [3.9.0] - 2025-11-10
### Added
@@ -228,13 +137,13 @@ and this project adheres to
- 🐛(frontend) button new doc UI fix #1557
- 🐛(frontend) interlinking UI fix #1557
## [v3.8.2] - 2025-10-17
## [3.8.2] - 2025-10-17
### Fixed
- 🐛(service-worker) fix sw registration and page reload logic #1500
## [v3.8.1] - 2025-10-17
## [3.8.1] - 2025-10-17
### Fixed
@@ -248,7 +157,7 @@ and this project adheres to
- 🔥(backend) remove treebeard form for the document admin #1470
## [v3.8.0] - 2025-10-14
## [3.8.0] - 2025-10-14
### Added
@@ -301,7 +210,7 @@ and this project adheres to
- 🔥(frontend) remove custom DividerBlock ##1375
## [v3.7.0] - 2025-09-12
## [3.7.0] - 2025-09-12
### Added
@@ -333,7 +242,7 @@ and this project adheres to
- 🐛(frontend) fix callout emoji list #1366
## [v3.6.0] - 2025-09-04
## [3.6.0] - 2025-09-04
### Added
@@ -369,7 +278,7 @@ and this project adheres to
- 🐛(frontend) fix display bug on homepage #1332
- 🐛link role update #1287
## [v3.5.0] - 2025-07-31
## [3.5.0] - 2025-07-31
### Added
@@ -397,7 +306,7 @@ and this project adheres to
- 🐛(frontend) 401 redirection overridden #1214
- 🐛(frontend) include root parent in search #1243
## [v3.4.2] - 2025-07-18
## [3.4.2] - 2025-07-18
### Changed
@@ -407,7 +316,7 @@ and this project adheres to
- 🐛(backend) improve prompt to not use code blocks delimiter #1188
## [v3.4.1] - 2025-07-15
## [3.4.1] - 2025-07-15
### Fixed
@@ -418,7 +327,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
## [v3.4.0] - 2025-07-09
## [3.4.0] - 2025-07-09
### Added
@@ -462,7 +371,7 @@ and this project adheres to
- 🔥(frontend) remove Beta from logo #1095
## [v3.3.0] - 2025-05-06
## [3.3.0] - 2025-05-06
### Added
@@ -494,14 +403,14 @@ and this project adheres to
- 🔥(back) remove footer endpoint #948
## [v3.2.1] - 2025-05-06
## [3.2.1] - 2025-05-06
## Fixed
- 🐛(frontend) fix list copy paste #943
- 📝(doc) update contributing policy (commit signatures are now mandatory) #895
## [v3.2.0] - 2025-05-05
## [3.2.0] - 2025-05-05
## Added
@@ -528,7 +437,7 @@ and this project adheres to
- 🐛(backend) race condition create doc #633
- 🐛(frontend) fix breaklines in custom blocks #908
## [v3.1.0] - 2025-04-07
## [3.1.0] - 2025-04-07
## Added
@@ -546,7 +455,7 @@ and this project adheres to
- 🐛(back) validate document content in serializer #822
- 🐛(frontend) fix selection click past end of content #840
## [v3.0.0] - 2025-03-28
## [3.0.0] - 2025-03-28
## Added
@@ -562,7 +471,7 @@ and this project adheres to
- 🐛(backend) compute ancestor_links in get_abilities if needed #725
- 🔒️(back) restrict access to document accesses #801
## [v2.6.0] - 2025-03-21
## [2.6.0] - 2025-03-21
## Added
@@ -580,7 +489,7 @@ and this project adheres to
- 🔒️(back) throttle user list endpoint #636
- 🔒️(back) remove pagination and limit to 5 for user list endpoint #636
## [v2.5.0] - 2025-03-18
## [2.5.0] - 2025-03-18
## Added
@@ -610,7 +519,7 @@ and this project adheres to
- 🚨(helm) fix helmfile lint #736
- 🚚(frontend) redirect to 401 page when 401 error #759
## [v2.4.0] - 2025-03-06
## [2.4.0] - 2025-03-06
## Added
@@ -624,7 +533,7 @@ and this project adheres to
- 🐛(frontend) fix collaboration error #684
## [v2.3.0] - 2025-03-03
## [2.3.0] - 2025-03-03
## Added
@@ -651,7 +560,7 @@ and this project adheres to
- ♻️(frontend) improve table pdf rendering
- 🐛(email) invitation emails in receivers language
## [v2.2.0] - 2025-02-10
## [2.2.0] - 2025-02-10
## Added
@@ -670,7 +579,7 @@ and this project adheres to
- 🐛(frontend) fix cursor breakline #609
- 🐛(frontend) fix style pdf export #609
## [v2.1.0] - 2025-01-29
## [2.1.0] - 2025-01-29
## Added
@@ -699,14 +608,14 @@ and this project adheres to
- 🔥(backend) remove "content" field from list serializer # 516
## [v2.0.1] - 2025-01-17
## [2.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
## [v2.0.0] - 2025-01-13
## [2.0.0] - 2025-01-13
## Added
@@ -737,7 +646,7 @@ and this project adheres to
- 🐛(frontend) hide search and create doc button if not authenticated #555
- 🐛(backend) race condition creation issue #556
## [v1.10.0] - 2024-12-17
## [1.10.0] - 2024-12-17
## Added
@@ -758,7 +667,7 @@ and this project adheres to
- 🐛(frontend) update doc editor height #481
- 💄(frontend) add doc search #485
## [v1.9.0] - 2024-12-11
## [1.9.0] - 2024-12-11
## Added
@@ -779,19 +688,19 @@ and this project adheres to
- 🐛(frontend) Fix hidden menu on Firefox #468
- 🐛(backend) fix sanitize problem IA #490
## [v1.8.2] - 2024-11-28
## [1.8.2] - 2024-11-28
## Changed
- ♻️(SW) change strategy html caching #460
## [v1.8.1] - 2024-11-27
## [1.8.1] - 2024-11-27
## Fixed
- 🐛(frontend) link not clickable and flickering firefox #457
## [v1.8.0] - 2024-11-25
## [1.8.0] - 2024-11-25
## Added
@@ -819,7 +728,7 @@ and this project adheres to
- 🐛(frontend) users have view access when revoked #387
- 🐛(frontend) fix placeholder editable when double clicks #454
## [v1.7.0] - 2024-10-24
## [1.7.0] - 2024-10-24
## Added
@@ -846,7 +755,7 @@ and this project adheres to
- 🔥(helm) remove infra related codes #366
## [v1.6.0] - 2024-10-17
## [1.6.0] - 2024-10-17
## Added
@@ -868,13 +777,13 @@ and this project adheres to
- 🐛(backend) fix nginx docker container #340
- 🐛(frontend) fix copy paste firefox #353
## [v1.5.1] - 2024-10-10
## [1.5.1] - 2024-10-10
## Fixed
- 🐛(db) fix users duplicate #316
## [v1.5.0] - 2024-10-09
## [1.5.0] - 2024-10-09
## Added
@@ -902,7 +811,7 @@ and this project adheres to
- 🔧(backend) fix configuration to avoid different ssl warning #297
- 🐛(frontend) fix editor break line not working #302
## [v1.4.0] - 2024-09-17
## [1.4.0] - 2024-09-17
## Added
@@ -922,7 +831,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
## [v1.3.0] - 2024-09-05
## [1.3.0] - 2024-09-05
## Added
@@ -946,14 +855,14 @@ and this project adheres to
- 🔥(frontend) remove saving modal #213
## [v1.2.1] - 2024-08-23
## [1.2.1] - 2024-08-23
## Changed
- ♻️ Change ordering docs datagrid #195
- 🔥(helm) use scaleway email #194
## [v1.2.0] - 2024-08-22
## [1.2.0] - 2024-08-22
## Added
@@ -979,7 +888,7 @@ and this project adheres to
- 🔥(helm) remove htaccess #181
## [v1.1.0] - 2024-07-15
## [1.1.0] - 2024-07-15
## Added
@@ -994,7 +903,7 @@ and this project adheres to
- ♻️(frontend) create a doc from a modal #132
- ♻️(frontend) manage members from the share modal #140
## [v1.0.0] - 2024-07-02
## [1.0.0] - 2024-07-02
## Added
@@ -1032,17 +941,14 @@ and this project adheres to
- 💚(CI) Remove trigger workflow on push tags on CI (#68)
- 🔥(frontend) Remove coming soon page (#121)
## [v0.1.0] - 2024-05-24
## [0.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.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
[unreleased]: https://github.com/suitenumerique/docs/compare/v4.2.0...main
[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
@@ -1077,12 +983,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
[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
[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

View File

@@ -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
RUN python -m pip install --upgrade pip setuptools
# Upgrade system packages to install security updates
RUN apk update && apk upgrade --no-cache
@@ -36,7 +36,7 @@ COPY ./src/mail /mail/app
WORKDIR /mail/app
RUN yarn install --frozen-lockfile && \
yarn build
yarn build
# ---- static link collector ----
@@ -58,7 +58,7 @@ WORKDIR /app
# collectstatic
RUN DJANGO_CONFIGURATION=Build \
python manage.py collectstatic --noinput
python manage.py collectstatic --noinput
# Replace duplicated file by a symlink to decrease the overall size of the
# final image
@@ -81,7 +81,7 @@ RUN apk add --no-cache \
pango \
shared-mime-info
RUN wget https://raw.githubusercontent.com/suitenumerique/django-lasuite/refs/heads/main/assets/conf/mime.types -O /etc/mime.types
RUN wget https://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types -O /etc/mime.types
# Copy entrypoint
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
@@ -98,9 +98,9 @@ COPY --from=back-builder /install /usr/local
# when python is upgraded and the path to the certificate changes.
# The space between print and the ( is intended otherwise the git lint is failing
RUN mkdir /cert && \
path=`python -c 'import certifi;print (certifi.where())'` && \
mv $path /cert/ && \
ln -s /cert/cacert.pem $path
path=`python -c 'import certifi;print (certifi.where())'` && \
mv $path /cert/ && \
ln -s /cert/cacert.pem $path
# Copy impress application (see .dockerignore)
COPY ./src/backend /app/
@@ -109,7 +109,7 @@ WORKDIR /app
# Generate compiled translation messages
RUN DJANGO_CONFIGURATION=Build \
python manage.py compilemessages
python manage.py compilemessages
# We wrap commands run in this container by the following entrypoint that
@@ -138,7 +138,7 @@ USER ${DOCKER_USER}
# Target database host (e.g. database engine following docker compose services
# name) & port
ENV DB_HOST=postgresql \
DB_PORT=5432
DB_PORT=5432
# Run django development server
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]

View File

@@ -213,7 +213,6 @@ 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

View File

@@ -72,7 +72,7 @@ For some advanced features (ex: Export as PDF) Docs relies on XL packages from B
### Test it
You can test Docs on your browser by visiting this [demo document](https://docs.la-suite.eu/docs/9137bbb5-3e8a-4ff7-8a36-fcc4e8bd57f4/)
You can test Docs on your browser by visiting this [demo document](https://impress-preprod.beta.numerique.gouv.fr/docs/6ee5aac4-4fb9-457d-95bf-bb56c2467713/)
### Run Docs locally

View File

@@ -8,7 +8,6 @@ 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(
@@ -24,7 +23,6 @@ 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'),
]
@@ -36,7 +34,6 @@ 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'),
]

View File

@@ -231,11 +231,6 @@ services:
condition: service_healthy
restart: true
docspec:
image: ghcr.io/docspecio/api:2.6.3
ports:
- "4000:4000"
networks:
lasuite:
name: lasuite-network

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

View File

@@ -1,177 +0,0 @@
# Customization Guide 🛠
## Runtime Theming 🎨
### How to Use
To use this feature, simply set the `FRONTEND_CSS_URL` environment variable to the URL of your custom CSS file. For example:
```javascript
FRONTEND_CSS_URL=http://anything/custom-style.css
```
Once you've set this variable, Docs will load your custom CSS file and apply the styles to our frontend application.
### Benefits
This feature provides several benefits, including:
* **Easy customization** 🔄: With this feature, you can easily customize the look and feel of our application without requiring any code changes.
* **Flexibility** 🌈: You can use any CSS styles you like to create a custom theme that meets your needs.
* **Runtime theming** ⏱️: This feature allows you to change the theme of our application at runtime, without requiring a restart or recompilation.
### Example Use Case
Let's say you want to change the background color of our application to a custom color. You can create a custom CSS file with the following contents:
```css
body {
background-color: #3498db;
}
```
Then, set the `FRONTEND_CSS_URL` environment variable to the URL of your custom CSS file. Once you've done this, our application will load your custom CSS file and apply the styles, changing the background color to the custom color you specified.
----
## Runtime JavaScript Injection 🚀
### How to Use
To use this feature, simply set the `FRONTEND_JS_URL` environment variable to the URL of your custom JavaScript file. For example:
```javascript
FRONTEND_JS_URL=http://anything/custom-script.js
```
Once you've set this variable, Docs will load your custom JavaScript file and execute it in the browser, allowing you to modify the application's behavior at runtime.
### Benefits
This feature provides several benefits, including:
* **Dynamic customization** 🔄: With this feature, you can dynamically modify the behavior and appearance of our application without requiring any code changes.
* **Flexibility** 🌈: You can add custom functionality, modify existing features, or integrate third-party services.
* **Runtime injection** ⏱️: This feature allows you to inject JavaScript into the application at runtime, without requiring a restart or recompilation.
### Example Use Case
Let's say you want to add a custom menu to the application header. You can create a custom JavaScript file with the following contents:
```javascript
(function() {
'use strict';
function initCustomMenu() {
// Wait for the page to be fully loaded
const header = document.querySelector('header');
if (!header) return false;
// Create and inject your custom menu
const customMenu = document.createElement('div');
customMenu.innerHTML = '<button>Custom Menu</button>';
header.appendChild(customMenu);
console.log('Custom menu added successfully');
return true;
}
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initCustomMenu);
} else {
initCustomMenu();
}
})();
```
Then, set the `FRONTEND_JS_URL` environment variable to the URL of your custom JavaScript file. Once you've done this, our application will load your custom JavaScript file and execute it, adding your custom menu to the header.
----
## **Your Docs icon** 📝
You can add your own Docs icon in the header from the theme customization file.
### Settings 🔧
```shellscript
THEME_CUSTOMIZATION_FILE_PATH=<path>
```
### Example of JSON
You can activate it with the `header.icon` configuration: https://github.com/suitenumerique/docs/blob/main/src/helm/env.d/dev/configuration/theme/demo.json
This configuration is optional. If not set, the default icon will be used.
----
## **Footer Configuration** 📝
The footer is configurable from the theme customization file.
### Settings 🔧
```shellscript
THEME_CUSTOMIZATION_FILE_PATH=<path>
```
### Example of JSON
The json must follow some rules: https://github.com/suitenumerique/docs/blob/main/src/helm/env.d/dev/configuration/theme/demo.json
`footer.default` is the fallback if the language is not supported.
---
Below is a visual example of a configured footer ⬇️:
![Footer Configuration Example](./assets/footer-configurable.png)
----
## **Custom Translations** 📝
The translations can be partially overridden from the theme customization file.
### Settings 🔧
```shellscript
THEME_CUSTOMIZATION_FILE_PATH=<path>
```
### Example of JSON
The json must follow some rules: https://github.com/suitenumerique/docs/blob/main/src/helm/env.d/dev/configuration/theme/demo.json
----
## **Waffle Configuration** 🧇
The Waffle (La Gaufre) is a widget that displays a grid of services.
![Waffle Configuration Example](./assets/waffle.png)
### Settings 🔧
```shellscript
THEME_CUSTOMIZATION_FILE_PATH=<path>
```
### Configuration
The Waffle can be configured in the theme customization file with the `waffle` key.
### Available Properties
See: [LaGaufreV2Props](https://github.com/suitenumerique/ui-kit/blob/main/src/components/la-gaufre/LaGaufreV2.tsx#L49)
### Complete Example
From the theme customization file: https://github.com/suitenumerique/docs/blob/main/src/helm/env.d/dev/configuration/theme/demo.json
### Behavior
- If `data.services` is provided, the Waffle will display those services statically
- If no data is provided, services can be fetched dynamically from an API endpoint thanks to the `apiUrl` property

View File

@@ -17,15 +17,13 @@ These are the environment variables you can set for the `impress-backend` contai
| API_USERS_LIST_LIMIT | Limit on API users | 5 |
| API_USERS_LIST_THROTTLE_RATE_BURST | Throttle rate for api on burst | 30/minute |
| API_USERS_LIST_THROTTLE_RATE_SUSTAINED | Throttle rate for api | 180/hour |
| API_USERS_SEARCH_QUERY_MIN_LENGTH | Minimum characters to insert to search a user | 3 |
| AWS_S3_ACCESS_KEY_ID | Access id for s3 endpoint | |
| AWS_S3_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_DEFAULT_KEY_PREFIX | The prefix used to every cache keys. | docs |
| CACHES_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 |
@@ -34,8 +32,6 @@ 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 |
@@ -58,21 +54,15 @@ 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 |
@@ -120,7 +110,6 @@ These are the environment variables you can set for the `impress-backend` contai
| THEME_CUSTOMIZATION_FILE_PATH | Full path to the file customizing the theme. An example is provided in src/backend/impress/configuration/theme/default.json | BASE_DIR/impress/configuration/theme/default.json |
| TRASHBIN_CUTOFF_DAYS | Trashbin cutoff | 30 |
| USER_OIDC_ESSENTIAL_CLAIMS | Essential claims in OIDC token | [] |
| USER_RECONCILIATION_FORM_URL | URL of a third-party form for user reconciliation requests | |
| Y_PROVIDER_API_BASE_URL | Y Provider url | |
| Y_PROVIDER_API_KEY | Y provider API key | |

View File

@@ -27,7 +27,6 @@ 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
@@ -67,7 +66,6 @@ backend:
AWS_S3_SECRET_ACCESS_KEY: password
AWS_STORAGE_BUCKET_NAME: docs-media-storage
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
USER_RECONCILIATION_FORM_URL: https://docs.127.0.0.1.nip.io
Y_PROVIDER_API_BASE_URL: http://impress-y-provider:443/api/
Y_PROVIDER_API_KEY: my-secret
CACHES_KEY_PREFIX: "{{ now | unixEpoch }}"

View File

@@ -127,7 +127,6 @@ DJANGO_EMAIL_FROM=<your email address>
DJANGO_EMAIL_BRAND_NAME=<brand name used in email templates> # e.g. "La Suite Numérique"
DJANGO_EMAIL_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

View File

@@ -1,180 +0,0 @@
# Language Configuration (2025-12)
This document explains how to configure and override the available languages in the Docs application.
## Default Languages
By default, the application supports the following languages (in priority order):
- English (en-us)
- French (fr-fr)
- German (de-de)
- Dutch (nl-nl)
- Spanish (es-es)
The default configuration is defined in `src/backend/impress/settings.py`:
```python
LANGUAGES = values.SingleNestedTupleValue(
(
("en-us", "English"),
("fr-fr", "Français"),
("de-de", "Deutsch"),
("nl-nl", "Nederlands"),
("es-es", "Español"),
)
)
```
## Overriding Languages
### Using Environment Variables
You can override the available languages by setting the `DJANGO_LANGUAGES` environment variable. This is the recommended approach for customizing language support without modifying the source code.
#### Format
The `DJANGO_LANGUAGES` variable expects a semicolon-separated list of language configurations, where each language is defined as `code,Display Name`:
```
DJANGO_LANGUAGES=code1,Name1;code2,Name2;code3,Name3
```
#### Example Configurations
**Example 1: English and French only**
```bash
DJANGO_LANGUAGES=en-us,English;fr-fr,Français
```
**Example 2: Add Italian and Chinese**
```bash
DJANGO_LANGUAGES=en-us,English;fr-fr,Français;de-de,Deutsch;it-it,Italiano;zh-cn,中文
```
**Example 3: Custom subset of languages**
```bash
DJANGO_LANGUAGES=fr-fr,Français;de-de,Deutsch;es-es,Español
```
### Configuration Files
#### Development Environment
For local development, you can set the `DJANGO_LANGUAGES` variable in your environment configuration file:
**File:** `env.d/development/common.local`
```bash
DJANGO_LANGUAGES=en-us,English;fr-fr,Français;de-de,Deutsch;it-it,Italiano;zh-cn,中文;
```
#### Production Environment
For production deployments, add the variable to your production environment configuration:
**File:** `env.d/production.dist/common`
```bash
DJANGO_LANGUAGES=en-us,English;fr-fr,Français
```
#### Docker Compose
When using Docker Compose, you can set the environment variable in your `compose.yml` or `compose.override.yml` file:
```yaml
services:
app:
environment:
- DJANGO_LANGUAGES=en-us,English;fr-fr,Français;de-de,Deutsch
```
## Important Considerations
### Language Codes
- Use standard language codes (ISO 639-1 with optional region codes)
- Format: `language-region` (e.g., `en-us`, `fr-fr`, `de-de`)
- Use lowercase for language codes and region identifiers
### Priority Order
Languages are listed in priority order. The first language in the list is used as the fallback language throughout the application when a specific translation is not available.
### Translation Availability
Before adding a new language, ensure that:
1. Translation files exist for that language in the `src/backend/locale/` directory
2. The frontend application has corresponding translation files
3. All required messages have been translated
#### Available Languages
The following languages have translation files available in `src/backend/locale/`:
- `br_FR` - Breton (France)
- `cn_CN` - Chinese (China) - *Note: Use `zh-cn` in DJANGO_LANGUAGES*
- `de_DE` - German (Germany) - Use `de-de`
- `en_US` - English (United States) - Use `en-us`
- `es_ES` - Spanish (Spain) - Use `es-es`
- `fr_FR` - French (France) - Use `fr-fr`
- `it_IT` - Italian (Italy) - Use `it-it`
- `nl_NL` - Dutch (Netherlands) - Use `nl-nl`
- `pt_PT` - Portuguese (Portugal) - Use `pt-pt`
- `ru_RU` - Russian (Russia) - Use `ru-ru`
- `sl_SI` - Slovenian (Slovenia) - Use `sl-si`
- `sv_SE` - Swedish (Sweden) - Use `sv-se`
- `tr_TR` - Turkish (Turkey) - Use `tr-tr`
- `uk_UA` - Ukrainian (Ukraine) - Use `uk-ua`
- `zh_CN` - Chinese (China) - Use `zh-cn`
**Note:** When configuring `DJANGO_LANGUAGES`, use lowercase with hyphens (e.g., `pt-pt`, `ru-ru`) rather than the directory name format.
### Translation Management
We use [Crowdin](https://crowdin.com/) to manage translations for the Docs application. Crowdin allows our community to contribute translations and helps maintain consistency across all supported languages.
**Want to add a new language or improve existing translations?**
If you would like us to support a new language or want to contribute to translations, please get in touch with the project maintainers. We can add new languages to our Crowdin project and coordinate translation efforts with the community.
### Cookie and Session
The application stores the user's language preference in a cookie named `docs_language`. The cookie path is set to `/` by default.
## Testing Language Configuration
After changing the language configuration:
1. Restart the application services
2. Verify the language selector displays the correct languages
3. Test switching between different languages
4. Confirm that content is displayed in the selected language
## Troubleshooting
### Languages not appearing
- Verify the environment variable is correctly formatted (semicolon-separated, comma between code and name)
- Check that there are no trailing spaces in language codes or names
- Ensure the application was restarted after changing the configuration
### Missing translations
If you add a new language but see untranslated text:
1. Check if translation files exist in `src/backend/locale/<language_code>/LC_MESSAGES/`
2. Run Django's `makemessages` and `compilemessages` commands to generate/update translations
3. Verify frontend translation files are available
## Related Configuration
- `LANGUAGE_CODE`: Default language code (default: `en-us`)
- `LANGUAGE_COOKIE_NAME`: Cookie name for storing user language preference (default: `docs_language`)
- `LANGUAGE_COOKIE_PATH`: Cookie path (default: `/`)

88
docs/theming.md Normal file
View File

@@ -0,0 +1,88 @@
# Runtime Theming 🎨
### How to Use
To use this feature, simply set the `FRONTEND_CSS_URL` environment variable to the URL of your custom CSS file. For example:
```javascript
FRONTEND_CSS_URL=http://anything/custom-style.css
```
Once you've set this variable, our application will load your custom CSS file and apply the styles to our frontend application.
### Benefits
This feature provides several benefits, including:
* **Easy customization** 🔄: With this feature, you can easily customize the look and feel of our application without requiring any code changes.
* **Flexibility** 🌈: You can use any CSS styles you like to create a custom theme that meets your needs.
* **Runtime theming** ⏱️: This feature allows you to change the theme of our application at runtime, without requiring a restart or recompilation.
### Example Use Case
Let's say you want to change the background color of our application to a custom color. You can create a custom CSS file with the following contents:
```css
body {
background-color: #3498db;
}
```
Then, set the `FRONTEND_CSS_URL` environment variable to the URL of your custom CSS file. Once you've done this, our application will load your custom CSS file and apply the styles, changing the background color to the custom color you specified.
----
# **Your Docs icon** 📝
You can add your own Docs icon in the header from the theme customization file.
### Settings 🔧
```shellscript
THEME_CUSTOMIZATION_FILE_PATH=<path>
```
### Example of JSON
You can activate it with the `header.icon` configuration: https://github.com/suitenumerique/docs/blob/main/src/helm/env.d/dev/configuration/theme/demo.json
This configuration is optional. If not set, the default icon will be used.
----
# **Footer Configuration** 📝
The footer is configurable from the theme customization file.
### Settings 🔧
```shellscript
THEME_CUSTOMIZATION_FILE_PATH=<path>
```
### Example of JSON
The json must follow some rules: https://github.com/suitenumerique/docs/blob/main/src/helm/env.d/dev/configuration/theme/demo.json
`footer.default` is the fallback if the language is not supported.
---
Below is a visual example of a configured footer ⬇️:
![Footer Configuration Example](./assets/footer-configurable.png)
----
# **Custom Translations** 📝
The translations can be partially overridden from the theme customization file.
### Settings 🔧
```shellscript
THEME_CUSTOMIZATION_FILE_PATH=<path>
```
### Example of JSON
The json must follow some rules: https://github.com/suitenumerique/docs/blob/main/src/helm/env.d/dev/configuration/theme/demo.json

View File

@@ -1,30 +0,0 @@
# User account reconciliation
It is possible to merge user accounts based on their email addresses.
Docs does not have an internal process to requests, but it allows the import of a CSV from an external form
(e.g. made with Grist) in the Django admin panel (in "Core" > "User reconciliation CSV imports" > "Add user reconciliation")
## CSV file format
The CSV must contain the following mandatory columns:
- `active_email`: the email of the user that will remain active after the process.
- `inactive_email`: the email of the user(s) that will be merged into the active user. It is possible to indicate several emails, so the user only has to make one request even if they have more than two accounts.
- `id`: a unique row id, so that entries already processed in a previous import are ignored.
The following columns are optional: `active_email_checked` and `inactive_email_checked` (both must contain `0` (False) or `1` (True), and both default to False.)
If present, it allows to indicate that the source form has a way to validate that the user making the request actually controls the email addresses, skipping the need to send confirmation emails (cf. below)
Once the CSV file is processed, this will create entries in "Core" > "User reconciliations" and send verification emails to validate that the user making the request actually controls the email addresses (unless `active_email_checked` and `inactive_email_checked` were set to `1` in the CSV)
In "Core" > "User reconciliations", an admin can then select all rows they wish to process and check the action "Process selected user reconciliations". Only rows that have the status `ready` and for which both emails have been validated will be processed.
## Settings
If there is a problem with the reconciliation attempt (e.g., one of the addresses given by the user does not match an existing account), the email signaling the error can give back the link to the reconciliation form. This is configured through the following environment variable:
```env
USER_RECONCILIATION_FORM_URL=<url used in the email for reconciliation with errors to allow a new requests>
# e.g. "https://yourgristinstance.tld/xxxx/UserReconciliationForm"
```

View File

@@ -20,7 +20,6 @@ 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"
@@ -48,7 +47,7 @@ LOGIN_REDIRECT_URL=http://localhost:3000
LOGIN_REDIRECT_URL_FAILURE=http://localhost:3000
LOGOUT_REDIRECT_URL=http://localhost:3000
OIDC_REDIRECT_ALLOWED_HOSTS="localhost:8083,localhost:3000"
OIDC_REDIRECT_ALLOWED_HOSTS=["http://localhost:8083", "http://localhost:3000"]
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
# Store OIDC tokens in the session. Needed by search/ endpoint.
@@ -59,9 +58,6 @@ OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
# To create one, use the bin/fernetkey command.
# OIDC_STORE_REFRESH_TOKEN_KEY="your-32-byte-encryption-key=="
# User reconciliation
USER_RECONCILIATION_FORM_URL=http://localhost:3000
# AI
AI_FEATURE_ENABLED=true
AI_BASE_URL=https://openaiendpoint.com
@@ -80,8 +76,6 @@ 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

View File

@@ -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

View File

@@ -24,8 +24,7 @@ DJANGO_EMAIL_FROM=<your email address>
#DJANGO_EMAIL_USE_SSL=true # A flag to enable or disable SSL for email sending.
DJANGO_EMAIL_BRAND_NAME="La Suite Numérique"
DJANGO_EMAIL_LOGO_IMG="https://${DOCS_HOST}/assets/logo-suite-numerique.png"
DJANGO_EMAIL_URL_APP="https://${DOCS_HOST}"
DJANGO_EMAIL_LOGO_IMG="https://${DOCS_HOST}/assets/logo-suite-numerique.png"
# Media
AWS_S3_ENDPOINT_URL=https://${S3_HOST}
@@ -53,9 +52,6 @@ LOGOUT_REDIRECT_URL=https://${DOCS_HOST}
OIDC_REDIRECT_ALLOWED_HOSTS=["https://${DOCS_HOST}"]
# User reconciliation
#USER_RECONCILIATION_FORM_URL=https://${DOCS_HOST}
# AI
#AI_FEATURE_ENABLED=true # is false by default
#AI_BASE_URL=https://openaiendpoint.com

View File

@@ -25,30 +25,15 @@
"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",
"eslint-config-next",
"docx",
"fetch-mock",
"next",
"node",
"node-fetch",
"react-resizable-panels",
"workbox-webpack-plugin"
]
}

View File

@@ -1,14 +1,20 @@
"""Admin classes and registrations for core app."""
from django.contrib import admin, messages
from django.contrib import admin
from django.contrib.auth import admin as auth_admin
from django.shortcuts import redirect
from django.utils.translation import gettext_lazy as _
from treebeard.admin import TreeAdmin
from core import models
from core.tasks.user_reconciliation import user_reconciliation_csv_import_job
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)
@@ -63,6 +69,7 @@ class UserAdmin(auth_admin.UserAdmin):
},
),
)
inlines = (TemplateAccessInline,)
list_display = (
"id",
"sub",
@@ -97,46 +104,15 @@ class UserAdmin(auth_admin.UserAdmin):
search_fields = ("id", "sub", "admin_email", "email", "full_name")
@admin.register(models.UserReconciliationCsvImport)
class UserReconciliationCsvImportAdmin(admin.ModelAdmin):
"""Admin class for UserReconciliationCsvImport model."""
@admin.register(models.Template)
class TemplateAdmin(admin.ModelAdmin):
"""Template admin interface declaration."""
list_display = ("id", "__str__", "created_at", "status")
def save_model(self, request, obj, form, change):
"""Override save_model to trigger the import task on creation."""
super().save_model(request, obj, form, change)
if not change:
user_reconciliation_csv_import_job.delay(obj.pk)
messages.success(request, _("Import job created and queued."))
return redirect("..")
@admin.action(description=_("Process selected user reconciliations"))
def process_reconciliation(_modeladmin, _request, queryset):
"""
Admin action to process selected user reconciliations.
The action will process only entries that are ready and have both emails checked.
"""
processable_entries = queryset.filter(
status="ready", active_email_checked=True, inactive_email_checked=True
)
for entry in processable_entries:
entry.process_reconciliation_request()
@admin.register(models.UserReconciliation)
class UserReconciliationAdmin(admin.ModelAdmin):
"""Admin class for UserReconciliation model."""
list_display = ["id", "__str__", "created_at", "status"]
actions = [process_reconciliation]
inlines = (TemplateAccessInline,)
class DocumentAccessInline(admin.TabularInline):
"""Inline admin class for document accesses."""
"""Inline admin class for template accesses."""
autocomplete_fields = ["user"]
model = models.DocumentAccess

View File

@@ -2,7 +2,6 @@
import unicodedata
from django.conf import settings
from django.utils.translation import gettext_lazy as _
import django_filters
@@ -136,6 +135,4 @@ class UserSearchFilter(django_filters.FilterSet):
Custom filter for searching users.
"""
q = django_filters.CharFilter(
min_length=settings.API_USERS_SEARCH_QUERY_MIN_LENGTH, max_length=254
)
q = django_filters.CharFilter(min_length=5, max_length=254)

View File

@@ -98,10 +98,10 @@ class CanCreateInvitationPermission(permissions.BasePermission):
class ResourceWithAccessPermission(permissions.BasePermission):
"""A permission class for invitations."""
"""A permission class for templates and invitations."""
def has_permission(self, request, view):
"""check create permission."""
"""check create permission for templates."""
return request.user.is_authenticated or view.action != "create"
def has_object_permission(self, request, view, obj):

View File

@@ -4,7 +4,6 @@
import binascii
import mimetypes
from base64 import b64decode
from os.path import splitext
from django.conf import settings
from django.db.models import Q
@@ -16,11 +15,10 @@ 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,
Converter,
YdocConverter,
)
@@ -61,6 +59,30 @@ class UserLightSerializer(UserSerializer):
read_only_fields = ["full_name", "short_name"]
class TemplateAccessSerializer(serializers.ModelSerializer):
"""Serialize template accesses."""
abilities = serializers.SerializerMethodField(read_only=True)
class Meta:
model = models.TemplateAccess
resource_field_name = "template"
fields = ["id", "user", "team", "role", "abilities"]
read_only_fields = ["id", "abilities"]
def get_abilities(self, instance) -> dict:
"""Return abilities of the logged-in user on the instance."""
request = self.context.get("request")
if request:
return instance.get_abilities(request.user)
return {}
def update(self, instance, validated_data):
"""Make "user" field is readonly but only on update."""
validated_data.pop("user", None)
return super().update(instance, validated_data)
class ListDocumentSerializer(serializers.ModelSerializer):
"""Serialize documents with limited fields for display in lists."""
@@ -166,9 +188,6 @@ class DocumentSerializer(ListDocumentSerializer):
content = serializers.CharField(required=False)
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
@@ -185,7 +204,6 @@ class DocumentSerializer(ListDocumentSerializer):
"deleted_at",
"depth",
"excerpt",
"file",
"is_favorite",
"link_role",
"link_reach",
@@ -255,30 +273,6 @@ 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
@@ -467,9 +461,7 @@ class ServerCreateDocumentSerializer(serializers.Serializer):
language = user.language or language
try:
document_content = Converter().convert(
validated_data["content"], mime_types.MARKDOWN, mime_types.YJS
)
document_content = YdocConverter().convert(validated_data["content"])
except ConversionError as err:
raise serializers.ValidationError(
{"content": ["Could not convert content"]}
@@ -668,6 +660,52 @@ 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."""

View File

@@ -3,10 +3,8 @@
# 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
@@ -37,37 +35,29 @@ from csp.constants import NONE
from csp.decorators import csp_update
from lasuite.malware_detection import malware_detection
from lasuite.oidc_login.decorators import refresh_oidc_access_token
from lasuite.tools.email import get_domain_from_email
from rest_framework import filters, status, viewsets
from rest_framework import response as drf_response
from rest_framework.permissions import AllowAny
from rest_framework.views import APIView
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,
)
from core.tasks.mail import send_ask_for_access_mail
from core.utils import (
extract_attachments,
filter_descendants,
users_sharing_documents_with,
)
from core.utils import extract_attachments, filter_descendants
from . import permissions, serializers, utils
from .filters import DocumentFilter, ListDocumentFilter, UserSearchFilter
@@ -226,80 +216,18 @@ class UserViewSet(
# Use trigram similarity for non-email-like queries
# For performance reasons we filter first by similarity, which relies on an
# index, then only calculate precise similarity scores for sorting purposes.
#
# Additionally results are reordered to prefer users "closer" to the current
# user: users they recently shared documents with, then same email domain.
# To achieve that without complex SQL, we build a proximity score in Python
# and return the top N results.
# For security results, users that match neither of these proximity criteria
# are not returned at all, to prevent email enumeration.
current_user = self.request.user
shared_map = users_sharing_documents_with(current_user)
# index, then only calculate precise similarity scores for sorting purposes
user_email_domain = get_domain_from_email(current_user.email) or ""
candidates = list(
return (
queryset.annotate(
sim_email=TrigramSimilarity("email", query),
sim_name=TrigramSimilarity("full_name", query),
)
.annotate(similarity=Greatest("sim_email", "sim_name"))
.filter(similarity__gt=0.2)
.order_by("-similarity")
.order_by("-similarity")[: settings.API_USERS_LIST_LIMIT]
)
# Keep only users that either share documents with the current user
# or have an email with the same domain as the current user.
filtered_candidates = []
for u in candidates:
candidate_domain = get_domain_from_email(u.email) or ""
if shared_map.get(u.id) or (
user_email_domain and candidate_domain == user_email_domain
):
filtered_candidates.append(u)
candidates = filtered_candidates
# Build ordering key for each candidate
def _sort_key(u):
# shared priority: most recent first
# Use shared_last_at timestamp numeric for secondary ordering when shared.
shared_last_at = shared_map.get(u.id)
if shared_last_at:
is_shared = 1
shared_score = int(shared_last_at.timestamp())
else:
is_shared = 0
shared_score = 0
# domain proximity
candidate_email_domain = get_domain_from_email(u.email) or ""
same_full_domain = (
1
if candidate_email_domain
and candidate_email_domain == user_email_domain
else 0
)
# similarity fallback
sim = getattr(u, "similarity", 0) or 0
return (
is_shared,
shared_score,
same_full_domain,
sim,
)
# Sort candidates by the key descending and return top N as a queryset-like
# list. Keep return type consistent with previous behavior (QuerySet slice
# was returned) by returning a list of model instances.
candidates.sort(key=_sort_key, reverse=True)
return candidates[: settings.API_USERS_LIST_LIMIT]
@drf.decorators.action(
detail=False,
methods=["get"],
@@ -317,59 +245,6 @@ class UserViewSet(
)
class ReconciliationConfirmView(APIView):
"""API endpoint to confirm user reconciliation emails.
GET /user-reconciliations/{user_type}/{confirmation_id}/
Marks `active_email_checked` or `inactive_email_checked` to True.
"""
permission_classes = [AllowAny]
def get(self, request, user_type, confirmation_id):
"""
Check the confirmation ID and mark the corresponding email as checked.
"""
try:
# validate UUID
uuid_obj = uuid.UUID(str(confirmation_id))
except ValueError:
return drf_response.Response(
{"detail": "Badly formatted confirmation id"},
status=status.HTTP_400_BAD_REQUEST,
)
if user_type not in ("active", "inactive"):
return drf_response.Response(
{"detail": "Invalid user_type"}, status=status.HTTP_400_BAD_REQUEST
)
lookup = (
{"active_email_confirmation_id": uuid_obj}
if user_type == "active"
else {"inactive_email_confirmation_id": uuid_obj}
)
try:
rec = models.UserReconciliation.objects.get(**lookup)
except models.UserReconciliation.DoesNotExist:
return drf_response.Response(
{"detail": "Reconciliation entry not found"},
status=status.HTTP_404_NOT_FOUND,
)
field_name = (
"active_email_checked"
if user_type == "active"
else "inactive_email_checked"
)
if not getattr(rec, field_name):
setattr(rec, field_name, True)
rec.save()
return drf_response.Response({"detail": "Confirmation received"})
class ResourceAccessViewsetMixin:
"""Mixin with methods common to all access viewsets."""
@@ -513,7 +388,6 @@ 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
@@ -650,28 +524,6 @@ 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,
@@ -773,29 +625,12 @@ 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.queryset.filter(path_list)
queryset = self.filter_queryset(self.get_queryset())
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(
@@ -1023,60 +858,6 @@ 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"],
@@ -1819,101 +1600,6 @@ 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"],
@@ -1947,16 +1633,6 @@ 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,
@@ -1965,15 +1641,13 @@ 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(
{"detail": "Invalid URL used."}, status=status.HTTP_400_BAD_REQUEST
status=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE
)
# Use StreamingHttpResponse with the response's iter_content to properly stream the data
@@ -1991,7 +1665,7 @@ class DocumentViewSet(
except requests.RequestException as e:
logger.exception(e)
return drf.response.Response(
{"detail": "Invalid URL used."},
{"error": f"Failed to fetch resource from {url}"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -2026,14 +1700,14 @@ class DocumentViewSet(
if base64_content is not None:
# Convert using the y-provider service
try:
yprovider = Converter()
yprovider = YdocConverter()
result = yprovider.convert(
base64.b64decode(base64_content),
mime_types.YJS,
"application/vnd.yjs.doc",
{
"markdown": mime_types.MARKDOWN,
"html": mime_types.HTML,
"json": mime_types.JSON,
"markdown": "text/markdown",
"html": "text/html",
"json": "application/json",
}[content_format],
)
content = result
@@ -2254,6 +1928,64 @@ class DocumentAccessViewSet(
)
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"),
)
.values("template")
.annotate(roles_array=ArrayAgg("role"))
.values("roles_array")
)
return queryset.annotate(user_roles=db.Subquery(user_roles_query)).distinct()
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)
)
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)
serializer = self.get_serializer(queryset, many=True)
return drf.response.Response(serializer.data)
class InvitationViewset(
drf.mixins.CreateModelMixin,
drf.mixins.ListModelMixin,
@@ -2459,17 +2191,12 @@ class ConfigView(drf.views.APIView):
"""
array_settings = [
"AI_FEATURE_ENABLED",
"API_USERS_SEARCH_QUERY_MIN_LENGTH",
"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",

View File

@@ -53,6 +53,15 @@ 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."""
@@ -193,6 +202,50 @@ 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"""

View File

@@ -1,150 +0,0 @@
"""Clean a document by resetting it (keeping its title) and deleting all descendants."""
import logging
from django.conf import settings
from django.core.files.storage import default_storage
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from django.db.models import Q
from botocore.exceptions import ClientError
from core.choices import LinkReachChoices, LinkRoleChoices, RoleChoices
from core.models import Document, DocumentAccess, Invitation, Thread
logger = logging.getLogger("impress.commands.clean_document")
class Command(BaseCommand):
"""Reset a document (keeping its title) and delete all its descendants."""
help = __doc__
def add_arguments(self, parser):
"""Define command arguments."""
parser.add_argument(
"document_id",
type=str,
help="UUID of the document to clean",
)
parser.add_argument(
"-f",
"--force",
action="store_true",
default=False,
help="Force command execution despite DEBUG is set to False",
)
parser.add_argument(
"-t",
"--title",
type=str,
default=None,
help="Update the document title to this value",
)
parser.add_argument(
"--link_reach",
type=str,
default=LinkReachChoices.RESTRICTED,
choices=LinkReachChoices,
help="Update the link_reach to this value",
)
parser.add_argument(
"--link_role",
type=str,
default=LinkRoleChoices.READER,
choices=LinkRoleChoices,
help="update the link_role to this value",
)
def handle(self, *args, **options):
"""Execute the clean_document command."""
if not settings.DEBUG and not options["force"]:
raise CommandError(
"This command is not meant to be used in production environment "
"except you know what you are doing, if so use --force parameter"
)
document_id = options["document_id"]
try:
document = Document.objects.get(pk=document_id)
except (Document.DoesNotExist, ValueError) as err:
raise CommandError(f"Document {document_id} does not exist.") from err
descendants = list(document.get_descendants())
descendant_ids = [doc.id for doc in descendants]
all_documents = [document, *descendants]
# Collect all attachment keys before the transaction clears them
all_attachment_keys = []
for doc in all_documents:
all_attachment_keys.extend(doc.attachments)
self.stdout.write(
f"Cleaning document {document_id} and deleting "
f"{len(descendants)} descendant(s)..."
)
with transaction.atomic():
# Clean accesses and invitations on the root document
access_count, _ = DocumentAccess.objects.filter(
Q(document_id=document.id) & ~Q(role=RoleChoices.OWNER)
).delete()
self.stdout.write(f"Deleted {access_count} access(es) on root document.")
invitation_count, _ = Invitation.objects.filter(
document_id=document.id
).delete()
self.stdout.write(
f"Deleted {invitation_count} invitation(s) on root document."
)
thread_count, _ = Thread.objects.filter(document_id=document.id).delete()
self.stdout.write(f"Deleted {thread_count} thread(s) on root document.")
# Reset root document fields
update_fields = {
"excerpt": None,
"link_reach": options["link_reach"],
"link_role": options["link_role"],
"attachments": [],
}
if options["title"] is not None:
update_fields["title"] = options["title"]
Document.objects.filter(id=document.id).update(**update_fields)
if options["title"] is not None:
self.stdout.write(
f'Reset fields on root document (title set to "{options["title"]}").'
)
else:
self.stdout.write("Reset fields on root document (title kept).")
# Delete all descendants (cascades accesses and invitations)
if descendants:
deleted_count, _ = Document.objects.filter(
id__in=descendant_ids
).delete()
self.stdout.write(f"Deleted {deleted_count} descendant(s).")
# Delete S3 content outside the transaction (S3 is not transactional)
s3_client = default_storage.connection.meta.client
bucket = default_storage.bucket_name
for doc in all_documents:
try:
s3_client.delete_object(Bucket=bucket, Key=doc.file_key)
except ClientError:
logger.warning("Failed to delete S3 file for document %s", doc.id)
self.stdout.write(f"Deleted S3 content for {len(all_documents)} document(s).")
for key in all_attachment_keys:
try:
s3_client.delete_object(Bucket=bucket, Key=key)
except ClientError:
logger.warning("Failed to delete S3 attachment %s", key)
self.stdout.write(f"Deleted {len(all_attachment_keys)} attachment(s) from S3.")
self.stdout.write("Done.")

View File

@@ -1,26 +0,0 @@
# Generated by Django 5.2.9 on 2026-01-09 14:18
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("core", "0027_auto_20251120_0956"),
]
operations = [
migrations.RemoveField(
model_name="templateaccess",
name="template",
),
migrations.RemoveField(
model_name="templateaccess",
name="user",
),
migrations.DeleteModel(
name="Template",
),
migrations.DeleteModel(
name="TemplateAccess",
),
]

View File

@@ -1,178 +0,0 @@
# Generated by Django 5.2.11 on 2026-02-10 15:47
import uuid
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0028_remove_templateaccess_template_and_more"),
]
operations = [
migrations.CreateModel(
name="UserReconciliationCsvImport",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
help_text="primary key for the record as UUID",
primary_key=True,
serialize=False,
verbose_name="id",
),
),
(
"created_at",
models.DateTimeField(
auto_now_add=True,
help_text="date and time at which a record was created",
verbose_name="created on",
),
),
(
"updated_at",
models.DateTimeField(
auto_now=True,
help_text="date and time at which a record was last updated",
verbose_name="updated on",
),
),
(
"file",
models.FileField(upload_to="imports/", verbose_name="CSV file"),
),
(
"status",
models.CharField(
choices=[
("pending", "Pending"),
("running", "Running"),
("done", "Done"),
("error", "Error"),
],
default="pending",
max_length=20,
),
),
("logs", models.TextField(blank=True)),
],
options={
"verbose_name": "user reconciliation CSV import",
"verbose_name_plural": "user reconciliation CSV imports",
"db_table": "impress_user_reconciliation_csv_import",
},
),
migrations.CreateModel(
name="UserReconciliation",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
help_text="primary key for the record as UUID",
primary_key=True,
serialize=False,
verbose_name="id",
),
),
(
"created_at",
models.DateTimeField(
auto_now_add=True,
help_text="date and time at which a record was created",
verbose_name="created on",
),
),
(
"updated_at",
models.DateTimeField(
auto_now=True,
help_text="date and time at which a record was last updated",
verbose_name="updated on",
),
),
(
"active_email",
models.EmailField(
max_length=254, verbose_name="Active email address"
),
),
(
"inactive_email",
models.EmailField(
max_length=254, verbose_name="Email address to deactivate"
),
),
("active_email_checked", models.BooleanField(default=False)),
("inactive_email_checked", models.BooleanField(default=False)),
(
"active_email_confirmation_id",
models.UUIDField(
default=uuid.uuid4, editable=False, null=True, unique=True
),
),
(
"inactive_email_confirmation_id",
models.UUIDField(
default=uuid.uuid4, editable=False, null=True, unique=True
),
),
(
"source_unique_id",
models.CharField(
blank=True,
max_length=100,
null=True,
verbose_name="Unique ID in the source file",
),
),
(
"status",
models.CharField(
choices=[
("pending", "Pending"),
("ready", "Ready"),
("done", "Done"),
("error", "Error"),
],
default="pending",
max_length=20,
),
),
("logs", models.TextField(blank=True)),
(
"active_user",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="active_user",
to=settings.AUTH_USER_MODEL,
),
),
(
"inactive_user",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="inactive_user",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "user reconciliation",
"verbose_name_plural": "user reconciliations",
"db_table": "impress_user_reconciliation",
"ordering": ["-created_at"],
},
),
]

View File

@@ -1,7 +1,6 @@
"""
Declare and configure the models for the impress core application
"""
# pylint: disable=too-many-lines
import hashlib
@@ -15,6 +14,7 @@ from django.contrib.auth import models as auth_models
from django.contrib.auth.base_user import AbstractBaseUser
from django.contrib.postgres.fields import ArrayField
from django.contrib.sites.models import Site
from django.core import mail
from django.core.cache import cache
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
@@ -32,14 +32,14 @@ from rest_framework.exceptions import ValidationError
from timezone_field import TimeZoneField
from treebeard.mp_tree import MP_Node, MP_NodeManager, MP_NodeQuerySet
from core.choices import (
from .choices import (
PRIVILEGED_ROLES,
LinkReachChoices,
LinkRoleChoices,
RoleChoices,
get_equivalent_link_definition,
)
from core.validators import sub_validator
from .validators import sub_validator
logger = getLogger(__name__)
@@ -250,37 +250,11 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
valid_invitations.delete()
def send_email(self, subject, context=None, language=None):
"""Generate and send email to the user from a template."""
emails = [self.email]
context = context or {}
domain = settings.EMAIL_URL_APP or Site.objects.get_current().domain
language = language or get_language()
context.update(
{
"brandname": settings.EMAIL_BRAND_NAME,
"domain": domain,
"logo_img": settings.EMAIL_LOGO_IMG,
}
)
with override(language):
msg_html = render_to_string("mail/html/template.html", context)
msg_plain = render_to_string("mail/text/template.txt", context)
subject = str(subject) # Force translation
try:
send_mail(
subject.capitalize(),
msg_plain,
settings.EMAIL_FROM,
emails,
html_message=msg_html,
fail_silently=False,
)
except smtplib.SMTPException as exception:
logger.error("invitation to %s was not sent: %s", emails, exception)
def email_user(self, subject, message, from_email=None, **kwargs):
"""Email this user."""
if not self.email:
raise ValueError("User has no email address.")
mail.send_mail(subject, message, from_email, [self.email], **kwargs)
@cached_property
def teams(self):
@@ -291,417 +265,6 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
return []
class UserReconciliation(BaseModel):
"""Model to run batch jobs to replace an active user by another one"""
active_email = models.EmailField(_("Active email address"))
inactive_email = models.EmailField(_("Email address to deactivate"))
active_email_checked = models.BooleanField(default=False)
inactive_email_checked = models.BooleanField(default=False)
active_user = models.ForeignKey(
User,
on_delete=models.CASCADE,
null=True,
blank=True,
related_name="active_user",
)
inactive_user = models.ForeignKey(
User,
on_delete=models.CASCADE,
null=True,
blank=True,
related_name="inactive_user",
)
active_email_confirmation_id = models.UUIDField(
default=uuid.uuid4, unique=True, editable=False, null=True
)
inactive_email_confirmation_id = models.UUIDField(
default=uuid.uuid4, unique=True, editable=False, null=True
)
source_unique_id = models.CharField(
max_length=100,
blank=True,
null=True,
verbose_name=_("Unique ID in the source file"),
)
status = models.CharField(
max_length=20,
choices=[
("pending", _("Pending")),
("ready", _("Ready")),
("done", _("Done")),
("error", _("Error")),
],
default="pending",
)
logs = models.TextField(blank=True)
class Meta:
db_table = "impress_user_reconciliation"
verbose_name = _("user reconciliation")
verbose_name_plural = _("user reconciliations")
ordering = ["-created_at"]
def __str__(self):
return f"Reconciliation from {self.inactive_email} to {self.active_email}"
def save(self, *args, **kwargs):
"""
For pending queries, identify the actual users and send validation emails
"""
if self.status == "pending":
self.active_user = User.objects.filter(email=self.active_email).first()
self.inactive_user = User.objects.filter(email=self.inactive_email).first()
if self.active_user and self.inactive_user:
if not self.active_email_checked:
self.send_reconciliation_confirm_email(
self.active_user, "active", self.active_email_confirmation_id
)
if not self.inactive_email_checked:
self.send_reconciliation_confirm_email(
self.inactive_user,
"inactive",
self.inactive_email_confirmation_id,
)
self.status = "ready"
else:
self.status = "error"
self.logs = "Error: Both active and inactive users need to exist."
super().save(*args, **kwargs)
@transaction.atomic
def process_reconciliation_request(self):
"""
Process the reconciliation request as a transaction.
- Transfer document accesses from inactive to active user, updating roles as needed.
- Transfer document favorites from inactive to active user.
- Transfer link traces from inactive to active user.
- Transfer comment-related content from inactive to active user
(threads, comments and reactions)
- Activate the active user and deactivate the inactive user.
- Update the reconciliation entry itself.
"""
# Prepare the data to perform the reconciliation on
updated_accesses, removed_accesses = (
self.prepare_documentaccess_reconciliation()
)
updated_linktraces, removed_linktraces = self.prepare_linktrace_reconciliation()
update_favorites, removed_favorites = (
self.prepare_document_favorite_reconciliation()
)
updated_threads = self.prepare_thread_reconciliation()
updated_comments = self.prepare_comment_reconciliation()
updated_reactions, removed_reactions = self.prepare_reaction_reconciliation()
self.active_user.is_active = True
self.inactive_user.is_active = False
# Actually perform the bulk operations
DocumentAccess.objects.bulk_update(updated_accesses, ["user", "role"])
if removed_accesses:
ids_to_delete = [entry.id for entry in removed_accesses]
DocumentAccess.objects.filter(id__in=ids_to_delete).delete()
DocumentFavorite.objects.bulk_update(update_favorites, ["user"])
if removed_favorites:
ids_to_delete = [entry.id for entry in removed_favorites]
DocumentFavorite.objects.filter(id__in=ids_to_delete).delete()
LinkTrace.objects.bulk_update(updated_linktraces, ["user"])
if removed_linktraces:
ids_to_delete = [entry.id for entry in removed_linktraces]
LinkTrace.objects.filter(id__in=ids_to_delete).delete()
Thread.objects.bulk_update(updated_threads, ["creator"])
Comment.objects.bulk_update(updated_comments, ["user"])
# pylint: disable=C0103
ReactionThroughModel = Reaction.users.through
reactions_to_create = []
for updated_reaction in updated_reactions:
reactions_to_create.append(
ReactionThroughModel(
user_id=self.active_user.pk, reaction_id=updated_reaction.pk
)
)
if reactions_to_create:
ReactionThroughModel.objects.bulk_create(reactions_to_create)
if removed_reactions:
ids_to_delete = [entry.id for entry in removed_reactions]
ReactionThroughModel.objects.filter(
reaction_id__in=ids_to_delete, user_id=self.inactive_user.pk
).delete()
User.objects.bulk_update([self.active_user, self.inactive_user], ["is_active"])
# Wrap up the reconciliation entry
self.logs += f"""Requested update for {len(updated_accesses)} DocumentAccess items
and deletion for {len(removed_accesses)} DocumentAccess items.\n"""
self.status = "done"
self.save()
self.send_reconciliation_done_email()
def prepare_documentaccess_reconciliation(self):
"""
Prepare the reconciliation by transferring document accesses from the inactive user
to the active user.
"""
updated_accesses = []
removed_accesses = []
inactive_accesses = DocumentAccess.objects.filter(user=self.inactive_user)
# Check documents where the active user already has access
inactive_accesses_documents = inactive_accesses.values_list(
"document", flat=True
)
existing_accesses = DocumentAccess.objects.filter(user=self.active_user).filter(
document__in=inactive_accesses_documents
)
existing_roles_per_doc = dict(existing_accesses.values_list("document", "role"))
for entry in inactive_accesses:
if entry.document_id in existing_roles_per_doc:
# Update role if needed
existing_role = existing_roles_per_doc[entry.document_id]
max_role = RoleChoices.max(entry.role, existing_role)
if existing_role != max_role:
existing_access = existing_accesses.get(document=entry.document)
existing_access.role = max_role
updated_accesses.append(existing_access)
removed_accesses.append(entry)
else:
entry.user = self.active_user
updated_accesses.append(entry)
return updated_accesses, removed_accesses
def prepare_document_favorite_reconciliation(self):
"""
Prepare the reconciliation by transferring document favorites from the inactive user
to the active user.
"""
updated_favorites = []
removed_favorites = []
existing_favorites = DocumentFavorite.objects.filter(user=self.active_user)
existing_favorite_doc_ids = set(
existing_favorites.values_list("document_id", flat=True)
)
inactive_favorites = DocumentFavorite.objects.filter(user=self.inactive_user)
for entry in inactive_favorites:
if entry.document_id in existing_favorite_doc_ids:
removed_favorites.append(entry)
else:
entry.user = self.active_user
updated_favorites.append(entry)
return updated_favorites, removed_favorites
def prepare_linktrace_reconciliation(self):
"""
Prepare the reconciliation by transferring link traces from the inactive user
to the active user.
"""
updated_linktraces = []
removed_linktraces = []
existing_linktraces = LinkTrace.objects.filter(user=self.active_user)
inactive_linktraces = LinkTrace.objects.filter(user=self.inactive_user)
for entry in inactive_linktraces:
if existing_linktraces.filter(document=entry.document).exists():
removed_linktraces.append(entry)
else:
entry.user = self.active_user
updated_linktraces.append(entry)
return updated_linktraces, removed_linktraces
def prepare_thread_reconciliation(self):
"""
Prepare the reconciliation by transferring threads from the inactive user
to the active user.
"""
updated_threads = []
inactive_threads = Thread.objects.filter(creator=self.inactive_user)
for entry in inactive_threads:
entry.creator = self.active_user
updated_threads.append(entry)
return updated_threads
def prepare_comment_reconciliation(self):
"""
Prepare the reconciliation by transferring comments from the inactive user
to the active user.
"""
updated_comments = []
inactive_comments = Comment.objects.filter(user=self.inactive_user)
for entry in inactive_comments:
entry.user = self.active_user
updated_comments.append(entry)
return updated_comments
def prepare_reaction_reconciliation(self):
"""
Prepare the reconciliation by creating missing reactions for the active user
(ie, the ones that exist for the inactive user but not the active user)
and then deleting all reactions of the inactive user.
"""
inactive_reactions = Reaction.objects.filter(users=self.inactive_user)
updated_reactions = inactive_reactions.exclude(users=self.active_user)
return updated_reactions, inactive_reactions
def send_reconciliation_confirm_email(
self, user, user_type, confirmation_id, language=None
):
"""Method allowing to send confirmation email for reconciliation requests."""
language = language or get_language()
domain = settings.EMAIL_URL_APP or Site.objects.get_current().domain
message = _(
"""You have requested a reconciliation of your user accounts on Docs.
To confirm that you are the one who initiated the request
and that this email belongs to you:"""
)
with override(language):
subject = _("Confirm by clicking the link to start the reconciliation")
context = {
"title": subject,
"message": message,
"link": f"{domain}/user-reconciliations/{user_type}/{confirmation_id}/",
"link_label": str(_("Click here")),
"button_label": str(_("Confirm")),
}
user.send_email(subject, context, language)
def send_reconciliation_done_email(self, language=None):
"""Method allowing to send done email for reconciliation requests."""
language = language or get_language()
domain = settings.EMAIL_URL_APP or Site.objects.get_current().domain
message = _(
"""Your reconciliation request has been processed.
New documents are likely associated with your account:"""
)
with override(language):
subject = _("Your accounts have been merged")
context = {
"title": subject,
"message": message,
"link": f"{domain}/",
"link_label": str(_("Click here to see")),
"button_label": str(_("See my documents")),
}
self.active_user.send_email(subject, context, language)
class UserReconciliationCsvImport(BaseModel):
"""Model to import reconciliations requests from an external source
(eg, )"""
file = models.FileField(upload_to="imports/", verbose_name=_("CSV file"))
status = models.CharField(
max_length=20,
choices=[
("pending", _("Pending")),
("running", _("Running")),
("done", _("Done")),
("error", _("Error")),
],
default="pending",
)
logs = models.TextField(blank=True)
class Meta:
db_table = "impress_user_reconciliation_csv_import"
verbose_name = _("user reconciliation CSV import")
verbose_name_plural = _("user reconciliation CSV imports")
def __str__(self):
return f"User reconciliation CSV import {self.id}"
def send_email(self, subject, emails, context=None, language=None):
"""Generate and send email to the user from a template."""
context = context or {}
domain = settings.EMAIL_URL_APP or Site.objects.get_current().domain
language = language or get_language()
context.update(
{
"brandname": settings.EMAIL_BRAND_NAME,
"domain": domain,
"logo_img": settings.EMAIL_LOGO_IMG,
}
)
with override(language):
msg_html = render_to_string("mail/html/template.html", context)
msg_plain = render_to_string("mail/text/template.txt", context)
subject = str(subject) # Force translation
try:
send_mail(
subject.capitalize(),
msg_plain,
settings.EMAIL_FROM,
emails,
html_message=msg_html,
fail_silently=False,
)
except smtplib.SMTPException as exception:
logger.error("invitation to %s was not sent: %s", emails, exception)
def send_reconciliation_error_email(
self, recipient_email, other_email, language=None
):
"""Method allowing to send email for reconciliation requests with errors."""
language = language or get_language()
emails = [recipient_email]
message = _(
"""Your request for reconciliation was unsuccessful.
Reconciliation failed for the following email addresses:
{recipient_email}, {other_email}.
Please check for typos.
You can submit another request with the valid email addresses."""
).format(recipient_email=recipient_email, other_email=other_email)
with override(language):
subject = _("Reconciliation of your Docs accounts not completed")
context = {
"title": subject,
"message": message,
"link": settings.USER_RECONCILIATION_FORM_URL,
"link_label": str(_("Click here")),
"button_label": str(_("Make a new request")),
}
self.send_email(subject, emails, context, language)
class BaseAccess(BaseModel):
"""Base model for accesses to handle resources."""
@@ -1253,7 +816,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 = settings.EMAIL_URL_APP or Site.objects.get_current().domain
domain = Site.objects.get_current().domain
language = language or get_language()
context.update(
{
@@ -1261,8 +824,7 @@ class Document(MP_Node, BaseModel):
"document": self,
"domain": domain,
"link": f"{domain}/docs/{self.id}/",
"link_label": self.title or str(_("Untitled Document")),
"button_label": _("Open"),
"document_title": self.title or str(_("Untitled Document")),
"logo_img": settings.EMAIL_LOGO_IMG,
}
)
@@ -1866,6 +1428,163 @@ 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."""

View File

@@ -3,14 +3,10 @@
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. "

View File

@@ -1,17 +1,11 @@
"""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."""
@@ -25,81 +19,8 @@ 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 YDoc conversion-related operations."""
"""Service class for conversion-related operations."""
@property
def auth_header(self):
@@ -120,34 +41,32 @@ 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, data, content_type=mime_types.MARKDOWN, accept=mime_types.YJS):
def convert(
self, text, content_type="text/markdown", accept="application/vnd.yjs.doc"
):
"""Convert a Markdown text into our internal format using an external microservice."""
if not data:
raise ValidationError("Input data cannot be empty")
if not text:
raise ValidationError("Input text cannot be empty")
url = f"{settings.Y_PROVIDER_API_BASE_URL}{settings.CONVERSION_API_ENDPOINT}/"
try:
response = self._request(url, data, content_type, accept)
if accept == mime_types.YJS:
response = self._request(
f"{settings.Y_PROVIDER_API_BASE_URL}{settings.CONVERSION_API_ENDPOINT}/",
text,
content_type,
accept,
)
if accept == "application/vnd.yjs.doc":
return b64encode(response.content).decode("utf-8")
if accept in {mime_types.MARKDOWN, "text/html"}:
if accept in {"text/markdown", "text/html"}:
return response.text
if accept == mime_types.JSON:
if accept == "application/json":
return response.json()
raise ValidationError("Unsupported format")
except requests.RequestException as err:
logger.exception("Y-Provider service error: url=%s", url)
raise ServiceUnavailableError(
f"Failed to connect to YDoc conversion service {content_type}, {accept}",
"Failed to connect to conversion service",
) from err

View File

@@ -1,8 +0,0 @@
"""MIME type constants for document conversion."""
BLOCKNOTE = "application/vnd.blocknote+json"
YJS = "application/vnd.yjs.doc"
MARKDOWN = "text/markdown"
JSON = "application/json"
DOCX = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
HTML = "text/html"

View File

@@ -4,14 +4,12 @@ Declare and configure the signals for the impress core application
from functools import partial
from django.core.cache import cache
from django.db import transaction
from django.db.models import signals
from django.dispatch import receiver
from core import models
from core.tasks.search import trigger_batch_document_indexer
from core.utils import get_users_sharing_documents_with_cache_key
from . import models
from .tasks.search import trigger_batch_document_indexer
@receiver(signals.post_save, sender=models.Document)
@@ -28,24 +26,8 @@ def document_post_save(sender, instance, **kwargs): # pylint: disable=unused-ar
def document_access_post_save(sender, instance, created, **kwargs): # pylint: disable=unused-argument
"""
Asynchronous call to the document indexer at the end of the transaction.
Clear cache for the affected user.
"""
if not created:
transaction.on_commit(
partial(trigger_batch_document_indexer, instance.document)
)
# Invalidate cache for the user
if instance.user:
cache_key = get_users_sharing_documents_with_cache_key(instance.user)
cache.delete(cache_key)
@receiver(signals.post_delete, sender=models.DocumentAccess)
def document_access_post_delete(sender, instance, **kwargs): # pylint: disable=unused-argument
"""
Clear cache for the affected user when document access is deleted.
"""
if instance.user:
cache_key = get_users_sharing_documents_with_cache_key(instance.user)
cache.delete(cache_key)

View File

@@ -1,135 +0,0 @@
"""Processing tasks for user reconciliation CSV imports."""
import csv
import traceback
import uuid
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
from django.db import IntegrityError
from botocore.exceptions import ClientError
from core.models import UserReconciliation, UserReconciliationCsvImport
from impress.celery_app import app
def _process_row(row, job, counters):
"""Process a single row from the CSV file."""
source_unique_id = row["id"].strip()
# Skip entries if they already exist with this source_unique_id
if UserReconciliation.objects.filter(source_unique_id=source_unique_id).exists():
counters["already_processed_source_ids"] += 1
return counters
active_email_checked = row.get("active_email_checked", "0") == "1"
inactive_email_checked = row.get("inactive_email_checked", "0") == "1"
active_email = row["active_email"]
inactive_emails = row["inactive_email"].split("|")
try:
validate_email(active_email)
except ValidationError:
job.send_reconciliation_error_email(
recipient_email=inactive_emails[0], other_email=active_email
)
job.logs += f"Invalid active email address on row {source_unique_id}."
counters["rows_with_errors"] += 1
return counters
for inactive_email in inactive_emails:
try:
validate_email(inactive_email)
except (ValidationError, ValueError):
job.send_reconciliation_error_email(
recipient_email=active_email, other_email=inactive_email
)
job.logs += f"Invalid inactive email address on row {source_unique_id}.\n"
counters["rows_with_errors"] += 1
continue
if inactive_email == active_email:
job.send_reconciliation_error_email(
recipient_email=active_email, other_email=inactive_email
)
job.logs += (
f"Error on row {source_unique_id}: "
f"{active_email} set as both active and inactive email.\n"
)
counters["rows_with_errors"] += 1
continue
_rec_entry = UserReconciliation.objects.create(
active_email=active_email,
inactive_email=inactive_email,
active_email_checked=active_email_checked,
inactive_email_checked=inactive_email_checked,
active_email_confirmation_id=uuid.uuid4(),
inactive_email_confirmation_id=uuid.uuid4(),
source_unique_id=source_unique_id,
status="pending",
)
counters["rec_entries_created"] += 1
return counters
@app.task
def user_reconciliation_csv_import_job(job_id):
"""Process a UserReconciliationCsvImport job.
Creates UserReconciliation entries from the CSV file.
Does some sanity checks on the data:
- active_email and inactive_email must be valid email addresses
- active_email and inactive_email cannot be the same
Rows with errors are logged in the job logs and skipped, but do not cause
the entire job to fail or prevent the next rows from being processed.
"""
# Imports the CSV file, breaks it into UserReconciliation items
job = UserReconciliationCsvImport.objects.get(id=job_id)
job.status = "running"
job.save()
counters = {
"rec_entries_created": 0,
"rows_with_errors": 0,
"already_processed_source_ids": 0,
}
try:
with job.file.open(mode="r") as f:
reader = csv.DictReader(f)
if not {"active_email", "inactive_email", "id"}.issubset(reader.fieldnames):
raise KeyError(
"CSV is missing mandatory columns: active_email, inactive_email, id"
)
for row in reader:
counters = _process_row(row, job, counters)
job.status = "done"
job.logs += (
f"Import completed successfully. {reader.line_num} rows processed."
f" {counters['rec_entries_created']} reconciliation entries created."
f" {counters['already_processed_source_ids']} rows were already processed."
f" {counters['rows_with_errors']} rows had errors."
)
except (
csv.Error,
KeyError,
ValidationError,
ValueError,
IntegrityError,
OSError,
ClientError,
) as e:
# Catch expected I/O/CSV/model errors and record traceback in logs for debugging
job.status = "error"
job.logs += f"{e!s}\n{traceback.format_exc()}"
finally:
job.save()

View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html>
<head>
<title>Generate Document</title>
</head>
<body>
<h2>Generate Document</h2>
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_p }}
<button type="submit">Generate PDF</button>
</form>
</body>
</html>

View File

@@ -1,4 +1,4 @@
"""Custom template tags for the core application of Docs."""
"""Custom template tags for the core application of People."""
import base64

View File

@@ -5,6 +5,7 @@ import re
from unittest import mock
from django.core.exceptions import SuspiciousOperation
from django.test.utils import override_settings
import pytest
import responses
@@ -322,6 +323,85 @@ 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
):

View File

@@ -1,313 +0,0 @@
"""Unit tests for the `clean_document` management command."""
import random
from unittest import mock
from uuid import uuid4
from django.core.management import CommandError, call_command
import pytest
from botocore.exceptions import ClientError
from core import choices, factories, models
from core.choices import LinkReachChoices, LinkRoleChoices
pytestmark = pytest.mark.django_db
def test_clean_document_with_descendants(settings):
"""The command should reset the root (keeping title) and delete descendants."""
settings.DEBUG = True
# Create a root document with subdocuments
root = factories.DocumentFactory(
title="Root",
link_reach=LinkReachChoices.PUBLIC,
link_role=LinkRoleChoices.EDITOR,
)
child = factories.DocumentFactory(
parent=root,
title="Child",
link_reach=LinkReachChoices.AUTHENTICATED,
link_role=LinkRoleChoices.EDITOR,
)
grandchild = factories.DocumentFactory(
parent=child,
title="Grandchild",
)
# Create accesses and invitations
factories.UserDocumentAccessFactory.create_batch(
5,
document=root,
role=random.choice(
[
role
for role in choices.RoleChoices
if role not in choices.PRIVILEGED_ROLES
],
),
)
# One owner role
factories.UserDocumentAccessFactory(document=root, role=choices.RoleChoices.OWNER)
factories.UserDocumentAccessFactory(document=child)
factories.InvitationFactory(document=root)
factories.InvitationFactory(document=child)
factories.ThreadFactory.create_batch(5, document=root)
assert models.Invitation.objects.filter(document=root).exists()
assert models.Thread.objects.filter(document=root).exists()
assert models.DocumentAccess.objects.filter(document=root).exists()
with mock.patch(
"core.management.commands.clean_document.default_storage"
) as mock_storage:
call_command("clean_document", str(root.id), "--force")
# Root document should still exist with title kept and other fields reset
root.refresh_from_db()
assert root.title == "Root"
assert root.excerpt is None
assert root.link_reach == LinkReachChoices.RESTRICTED
assert root.link_role == LinkRoleChoices.READER
assert root.attachments == []
# Accesses and invitations on root should be deleted. Only owner should be kept
keeping_accesses = list(models.DocumentAccess.objects.filter(document=root))
assert len(keeping_accesses) == 1
assert keeping_accesses[0].role == models.RoleChoices.OWNER
assert not models.Invitation.objects.filter(document=root).exists()
assert not models.Thread.objects.filter(document=root).exists()
# Descendants should be deleted entirely
assert not models.Document.objects.filter(id__in=[child.id, grandchild.id]).exists()
# Root should have no descendants
root.refresh_from_db()
assert root.get_descendants().count() == 0
# S3 delete should have been called for document files + attachments
delete_calls = mock_storage.connection.meta.client.delete_object.call_args_list
assert len(delete_calls) == 3
def test_clean_document_invalid_uuid(settings):
"""The command should raise an error for a non-existent document."""
settings.DEBUG = True
fake_id = str(uuid4())
with pytest.raises(CommandError, match=f"Document {fake_id} does not exist."):
call_command("clean_document", fake_id, "--force")
def test_clean_document_no_force_in_production(settings):
"""The command should require --force when DEBUG is False."""
settings.DEBUG = False
doc = factories.DocumentFactory()
with pytest.raises(CommandError, match="not meant to be used in production"):
call_command("clean_document", str(doc.id))
def test_clean_document_single_document(settings):
"""The command should work on a single document without children."""
settings.DEBUG = True
doc = factories.DocumentFactory(
title="Single",
link_reach=LinkReachChoices.PUBLIC,
link_role=LinkRoleChoices.EDITOR,
)
factories.UserDocumentAccessFactory.create_batch(
5,
document=doc,
role=random.choice(
[
role
for role in choices.RoleChoices
if role not in choices.PRIVILEGED_ROLES
],
),
)
# One owner role
factories.UserDocumentAccessFactory(document=doc, role=choices.RoleChoices.OWNER)
factories.ThreadFactory.create_batch(5, document=doc)
factories.InvitationFactory(document=doc)
with mock.patch(
"core.management.commands.clean_document.default_storage"
) as mock_storage:
call_command("clean_document", str(doc.id), "--force")
# Accesses and invitations on root should be deleted. Only owner should be kept
keeping_accesses = list(models.DocumentAccess.objects.filter(document=doc))
assert len(keeping_accesses) == 1
assert keeping_accesses[0].role == models.RoleChoices.OWNER
assert not models.Invitation.objects.filter(document=doc).exists()
assert not models.Thread.objects.filter(document=doc).exists()
doc.refresh_from_db()
assert doc.title == "Single"
assert doc.excerpt is None
assert doc.link_reach == LinkReachChoices.RESTRICTED
assert doc.link_role == LinkRoleChoices.READER
assert doc.attachments == []
mock_storage.connection.meta.client.delete_object.assert_called_once()
def test_clean_document_with_title_option(settings):
"""The --title option should update the document title."""
settings.DEBUG = True
doc = factories.DocumentFactory(
title="Old Title",
link_reach=LinkReachChoices.PUBLIC,
link_role=LinkRoleChoices.EDITOR,
)
with mock.patch("core.management.commands.clean_document.default_storage"):
call_command("clean_document", str(doc.id), "--force", "--title", "New Title")
doc.refresh_from_db()
assert doc.title == "New Title"
assert doc.excerpt is None
assert doc.link_reach == LinkReachChoices.RESTRICTED
assert doc.link_role == LinkRoleChoices.READER
assert doc.attachments == []
def test_clean_document_deletes_attachments_from_s3(settings):
"""The command should delete attachment files from S3."""
settings.DEBUG = True
root = factories.DocumentFactory(
attachments=["root-id/attachments/file1.png", "root-id/attachments/file2.pdf"],
)
child = factories.DocumentFactory(
parent=root,
attachments=["child-id/attachments/file3.png"],
)
with mock.patch(
"core.management.commands.clean_document.default_storage"
) as mock_storage:
call_command("clean_document", str(root.id), "--force")
delete_calls = mock_storage.connection.meta.client.delete_object.call_args_list
deleted_keys = [call.kwargs["Key"] for call in delete_calls]
# Document files (root + child)
assert root.file_key in deleted_keys
assert child.file_key in deleted_keys
# Attachment files
assert "root-id/attachments/file1.png" in deleted_keys
assert "root-id/attachments/file2.pdf" in deleted_keys
assert "child-id/attachments/file3.png" in deleted_keys
assert len(delete_calls) == 5
def test_clean_document_s3_errors_do_not_stop_command(settings):
"""S3 deletion errors should be logged but not stop the command."""
settings.DEBUG = True
doc = factories.DocumentFactory(
attachments=["doc-id/attachments/file1.png"],
)
with mock.patch(
"core.management.commands.clean_document.default_storage"
) as mock_storage:
mock_storage.connection.meta.client.delete_object.side_effect = ClientError(
{"Error": {"Code": "500", "Message": "Internal Error"}},
"DeleteObject",
)
# Command should complete without raising
call_command("clean_document", str(doc.id), "--force")
def test_clean_document_with_options(settings):
"""Run the command using optional argument link_reach and link_role."""
settings.DEBUG = True
# Create a root document with subdocuments
root = factories.DocumentFactory(
title="Root",
link_reach=LinkReachChoices.PUBLIC,
link_role=LinkRoleChoices.READER,
)
child = factories.DocumentFactory(
parent=root,
title="Child",
link_reach=LinkReachChoices.AUTHENTICATED,
link_role=LinkRoleChoices.EDITOR,
)
grandchild = factories.DocumentFactory(
parent=child,
title="Grandchild",
)
# Create accesses and invitations
factories.UserDocumentAccessFactory.create_batch(
5,
document=root,
role=random.choice(
[
role
for role in choices.RoleChoices
if role not in choices.PRIVILEGED_ROLES
],
),
)
# One owner role
factories.UserDocumentAccessFactory(document=root, role=choices.RoleChoices.OWNER)
factories.UserDocumentAccessFactory(document=child)
factories.InvitationFactory(document=root)
factories.InvitationFactory(document=child)
factories.ThreadFactory.create_batch(5, document=root)
assert models.Invitation.objects.filter(document=root).exists()
assert models.Thread.objects.filter(document=root).exists()
assert models.DocumentAccess.objects.filter(document=root).exists()
with mock.patch(
"core.management.commands.clean_document.default_storage"
) as mock_storage:
call_command(
"clean_document",
str(root.id),
"--force",
"--link_reach",
"public",
"--link_role",
"editor",
)
# Root document should still exist with title kept and other fields reset
root.refresh_from_db()
assert root.title == "Root"
assert root.excerpt is None
assert root.link_reach == LinkReachChoices.PUBLIC
assert root.link_role == LinkRoleChoices.EDITOR
assert root.attachments == []
# Accesses and invitations on root should be deleted. Only owner should be kept
keeping_accesses = list(models.DocumentAccess.objects.filter(document=root))
assert len(keeping_accesses) == 1
assert keeping_accesses[0].role == models.RoleChoices.OWNER
assert not models.Invitation.objects.filter(document=root).exists()
assert not models.Thread.objects.filter(document=root).exists()
# Descendants should be deleted entirely
assert not models.Document.objects.filter(id__in=[child.id, grandchild.id]).exists()
# Root should have no descendants
root.refresh_from_db()
assert root.get_descendants().count() == 0
# S3 delete should have been called for document files + attachments
delete_calls = mock_storage.connection.meta.client.delete_object.call_args_list
assert len(delete_calls) == 3

View File

@@ -1,6 +0,0 @@
active_email,inactive_email,active_email_checked,inactive_email_checked,status,id
"user.test40@example.com","user.test41@example.com",0,0,pending,1
"user.test42@example.com","user.test43@example.com",0,1,pending,2
"user.test44@example.com","user.test45@example.com",1,0,pending,3
"user.test46@example.com","user.test47@example.com",1,1,pending,4
"user.test48@example.com","user.test49@example.com",1,1,pending,5
1 active_email inactive_email active_email_checked inactive_email_checked status id
2 user.test40@example.com user.test41@example.com 0 0 pending 1
3 user.test42@example.com user.test43@example.com 0 1 pending 2
4 user.test44@example.com user.test45@example.com 1 0 pending 3
5 user.test46@example.com user.test47@example.com 1 1 pending 4
6 user.test48@example.com user.test49@example.com 1 1 pending 5

View File

@@ -1,2 +0,0 @@
active_email,inactive_email,active_email_checked,inactive_email_checked,status,id
"user.test40@example.com",,0,0,pending,40
1 active_email inactive_email active_email_checked inactive_email_checked status id
2 user.test40@example.com 0 0 pending 40

View File

@@ -1,5 +0,0 @@
merge_accept,active_email,inactive_email,status,id
true,user.test10@example.com,user.test11@example.com|user.test12@example.com,pending,10
true,user.test30@example.com,user.test31@example.com|user.test32@example.com|user.test33@example.com|user.test34@example.com|user.test35@example.com,pending,11
true,user.test20@example.com,user.test21@example.com,pending,12
true,user.test22@example.com,user.test23@example.com,pending,13
1 merge_accept active_email inactive_email status id
2 true user.test10@example.com user.test11@example.com|user.test12@example.com pending 10
3 true user.test30@example.com user.test31@example.com|user.test32@example.com|user.test33@example.com|user.test34@example.com|user.test35@example.com pending 11
4 true user.test20@example.com user.test21@example.com pending 12
5 true user.test22@example.com user.test23@example.com pending 13

View File

@@ -1,2 +0,0 @@
merge_accept,active_email,inactive_email,status,id
true,user.test20@example.com,user.test20@example.com,pending,20
1 merge_accept active_email inactive_email status id
2 true user.test20@example.com user.test20@example.com pending 20

View File

@@ -1,6 +0,0 @@
active_email,inactive_email,active_email_checked,inactive_email_checked,status
"user.test40@example.com","user.test41@example.com",0,0,pending
"user.test42@example.com","user.test43@example.com",0,1,pending
"user.test44@example.com","user.test45@example.com",1,0,pending
"user.test46@example.com","user.test47@example.com",1,1,pending
"user.test48@example.com","user.test49@example.com",1,1,pending
1 active_email inactive_email active_email_checked inactive_email_checked status
2 user.test40@example.com user.test41@example.com 0 0 pending
3 user.test42@example.com user.test43@example.com 0 1 pending
4 user.test44@example.com user.test45@example.com 1 0 pending
5 user.test46@example.com user.test47@example.com 1 1 pending
6 user.test48@example.com user.test49@example.com 1 1 pending

View File

@@ -1,427 +0,0 @@
"""
Tests for Documents API endpoint in impress's core app: all
The 'all' endpoint returns ALL documents (including descendants) that the user has access to.
This is different from the 'list' endpoint which only returns top-level documents.
"""
from datetime import timedelta
from unittest import mock
from django.utils import timezone
import pytest
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
@pytest.mark.parametrize("role", models.LinkRoleChoices.values)
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
def test_api_documents_all_anonymous(reach, role):
"""
Anonymous users should not be able to list any documents via the all endpoint
whatever the link reach and link role.
"""
parent = factories.DocumentFactory(link_reach=reach, link_role=role)
factories.DocumentFactory(parent=parent, link_reach=reach, link_role=role)
response = APIClient().get("/api/v1.0/documents/all/")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 0
def test_api_documents_all_authenticated_with_children():
"""
Authenticated users should see all documents including children,
even though children don't have DocumentAccess records.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Create a document tree: parent -> child -> grandchild
parent = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=parent, user=user, role="owner")
child = factories.DocumentFactory(parent=parent)
grandchild = factories.DocumentFactory(parent=child)
# Verify setup
assert models.DocumentAccess.objects.filter(document=parent).count() == 1
assert models.DocumentAccess.objects.filter(document=child).count() == 0
assert models.DocumentAccess.objects.filter(document=grandchild).count() == 0
response = client.get("/api/v1.0/documents/all/")
assert response.status_code == 200
results = response.json()["results"]
# All three documents should be returned (parent + child + grandchild)
assert len(results) == 3
results_ids = {result["id"] for result in results}
assert results_ids == {str(parent.id), str(child.id), str(grandchild.id)}
depths = {result["depth"] for result in results}
assert depths == {1, 2, 3}
def test_api_documents_all_authenticated_multiple_trees():
"""
Users should see all accessible documents from multiple document trees.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Tree 1: User has access
tree1_parent = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=tree1_parent, user=user)
tree1_child = factories.DocumentFactory(parent=tree1_parent)
# Tree 2: User has access
tree2_parent = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=tree2_parent, user=user)
tree2_child1 = factories.DocumentFactory(parent=tree2_parent)
tree2_child2 = factories.DocumentFactory(parent=tree2_parent)
# Tree 3: User does NOT have access
tree3_parent = factories.DocumentFactory()
factories.DocumentFactory(parent=tree3_parent)
response = client.get("/api/v1.0/documents/all/")
assert response.status_code == 200
results = response.json()["results"]
# Should return 5 documents (tree1: 2, tree2: 3, tree3: 0)
assert len(results) == 5
results_ids = {result["id"] for result in results}
expected_ids = {
str(tree1_parent.id),
str(tree1_child.id),
str(tree2_parent.id),
str(tree2_child1.id),
str(tree2_child2.id),
}
assert results_ids == expected_ids
def test_api_documents_all_authenticated_explicit_access_to_parent_and_child():
"""
When a user has explicit DocumentAccess to both parent AND child,
both should appear in the 'all' endpoint results (unlike 'list' which deduplicates).
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Parent with explicit access
parent = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=parent, user=user)
# Child also has explicit access (e.g., shared separately)
child = factories.DocumentFactory(parent=parent)
factories.UserDocumentAccessFactory(document=child, user=user)
# Grandchild has no explicit access
grandchild = factories.DocumentFactory(parent=child)
# Verify setup
assert models.DocumentAccess.objects.filter(document=parent).count() == 1
assert models.DocumentAccess.objects.filter(document=child).count() == 1
assert models.DocumentAccess.objects.filter(document=grandchild).count() == 0
response = client.get("/api/v1.0/documents/all/")
assert response.status_code == 200
results = response.json()["results"]
# All three should appear
assert len(results) == 3
results_ids = {result["id"] for result in results}
assert results_ids == {str(parent.id), str(child.id), str(grandchild.id)}
# Each document should appear exactly once (no duplicates)
results_ids_list = [result["id"] for result in results]
assert len(results_ids_list) == len(set(results_ids_list)) # No duplicates
def test_api_documents_all_authenticated_via_team(mock_user_teams):
"""
Users should see all documents (including descendants) for documents accessed via teams.
"""
mock_user_teams.return_value = ["team1", "team2"]
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Document tree via team1
parent1 = factories.DocumentFactory()
factories.TeamDocumentAccessFactory(document=parent1, team="team1")
child1 = factories.DocumentFactory(parent=parent1)
# Document tree via team2
parent2 = factories.DocumentFactory()
factories.TeamDocumentAccessFactory(document=parent2, team="team2")
child2 = factories.DocumentFactory(parent=parent2)
# Document tree via unknown team
parent3 = factories.DocumentFactory()
factories.TeamDocumentAccessFactory(document=parent3, team="team3")
factories.DocumentFactory(parent=parent3)
response = client.get("/api/v1.0/documents/all/")
assert response.status_code == 200
results = response.json()["results"]
# Should return 4 documents (team1: 2, team2: 2, team3: 0)
assert len(results) == 4
results_ids = {result["id"] for result in results}
expected_ids = {
str(parent1.id),
str(child1.id),
str(parent2.id),
str(child2.id),
}
assert results_ids == expected_ids
def test_api_documents_all_authenticated_soft_deleted():
"""
Soft-deleted documents and their descendants should not be included.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Active tree
active_parent = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=active_parent, user=user)
active_child = factories.DocumentFactory(parent=active_parent)
# Soft-deleted tree
deleted_parent = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=deleted_parent, user=user)
_deleted_child = factories.DocumentFactory(parent=deleted_parent)
deleted_parent.soft_delete()
response = client.get("/api/v1.0/documents/all/")
assert response.status_code == 200
results = response.json()["results"]
# Should only return active documents
assert len(results) == 2
results_ids = {result["id"] for result in results}
assert results_ids == {str(active_parent.id), str(active_child.id)}
def test_api_documents_all_authenticated_permanently_deleted():
"""
Permanently deleted documents should not be included.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Active tree
active_parent = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=active_parent, user=user)
active_child = factories.DocumentFactory(parent=active_parent)
# Permanently deleted tree (deleted > 30 days ago)
deleted_parent = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=deleted_parent, user=user)
_deleted_child = factories.DocumentFactory(parent=deleted_parent)
fourty_days_ago = timezone.now() - timedelta(days=40)
with mock.patch("django.utils.timezone.now", return_value=fourty_days_ago):
deleted_parent.soft_delete()
response = client.get("/api/v1.0/documents/all/")
assert response.status_code == 200
results = response.json()["results"]
# Should only return active documents
assert len(results) == 2
results_ids = {result["id"] for result in results}
assert results_ids == {str(active_parent.id), str(active_child.id)}
def test_api_documents_all_authenticated_link_reach_restricted():
"""
Documents with link_reach=restricted accessed via LinkTrace should not appear
in the all endpoint results.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Document with direct access (should appear)
parent_with_access = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=parent_with_access, user=user)
child_with_access = factories.DocumentFactory(parent=parent_with_access)
# Document with only LinkTrace and restricted reach (should NOT appear)
parent_restricted = factories.DocumentFactory(
link_reach="restricted", link_traces=[user]
)
factories.DocumentFactory(parent=parent_restricted)
response = client.get("/api/v1.0/documents/all/")
assert response.status_code == 200
results = response.json()["results"]
# Only documents with direct access should appear
assert len(results) == 2
results_ids = {result["id"] for result in results}
assert results_ids == {str(parent_with_access.id), str(child_with_access.id)}
@pytest.mark.parametrize("reach", ["public", "authenticated"])
def test_api_documents_all_authenticated_link_reach_public_or_authenticated(reach):
"""
Documents with link_reach=public or authenticated accessed via LinkTrace
should appear with all their descendants.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Document accessed via LinkTrace with non-restricted reach
parent = factories.DocumentFactory(link_reach=reach, link_traces=[user])
child = factories.DocumentFactory(parent=parent)
grandchild = factories.DocumentFactory(parent=child)
response = client.get("/api/v1.0/documents/all/")
assert response.status_code == 200
results = response.json()["results"]
# All descendants should be included
assert len(results) == 3
results_ids = {result["id"] for result in results}
assert results_ids == {str(parent.id), str(child.id), str(grandchild.id)}
def test_api_documents_all_format():
"""Validate the format of documents as returned by the all endpoint."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
access = factories.UserDocumentAccessFactory(document=document, user=user)
child = factories.DocumentFactory(parent=document)
response = client.get("/api/v1.0/documents/all/")
assert response.status_code == 200
content = response.json()
results = content.pop("results")
# Check pagination structure
assert content == {
"count": 2,
"next": None,
"previous": None,
}
# Verify parent document format
parent_result = [r for r in results if r["id"] == str(document.id)][0]
assert parent_result == {
"id": str(document.id),
"abilities": document.get_abilities(user),
"ancestors_link_reach": None,
"ancestors_link_role": None,
"computed_link_reach": document.computed_link_reach,
"computed_link_role": document.computed_link_role,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"deleted_at": None,
"depth": 1,
"excerpt": document.excerpt,
"is_favorite": False,
"link_reach": document.link_reach,
"link_role": document.link_role,
"nb_accesses_ancestors": 1,
"nb_accesses_direct": 1,
"numchild": 1,
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": access.role,
}
# Verify child document format
child_result = [r for r in results if r["id"] == str(child.id)][0]
assert child_result["depth"] == 2
assert child_result["user_role"] == access.role # Inherited from parent
assert child_result["nb_accesses_direct"] == 0 # No direct access on child
def test_api_documents_all_distinct():
"""
A document should only appear once even if the user has multiple access paths to it.
"""
user = factories.UserFactory()
other_user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Document with multiple accesses for the same user
document = factories.DocumentFactory(users=[user, other_user])
child = factories.DocumentFactory(parent=document)
response = client.get("/api/v1.0/documents/all/")
assert response.status_code == 200
results = response.json()["results"]
# Should return 2 documents (parent + child), each appearing once
assert len(results) == 2
results_ids = [result["id"] for result in results]
assert results_ids.count(str(document.id)) == 1
assert results_ids.count(str(child.id)) == 1
def test_api_documents_all_comparison_with_list():
"""
The 'all' endpoint should return more documents than 'list' when there are children.
'list' returns only top-level documents, 'all' returns all descendants.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Create a document tree
parent = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=parent, user=user)
child = factories.DocumentFactory(parent=parent)
grandchild = factories.DocumentFactory(parent=child)
# Call list endpoint
list_response = client.get("/api/v1.0/documents/")
list_results = list_response.json()["results"]
# Call all endpoint
all_response = client.get("/api/v1.0/documents/all/")
all_results = all_response.json()["results"]
# list should return only parent
assert len(list_results) == 1
assert list_results[0]["id"] == str(parent.id)
# all should return parent + child + grandchild
assert len(all_results) == 3
all_ids = {result["id"] for result in all_results}
assert all_ids == {str(parent.id), str(child.id), str(grandchild.id)}

View File

@@ -1,8 +1,5 @@
"""Test on the CORS proxy API for documents."""
import socket
import unittest.mock
import pytest
import responses
from requests.exceptions import RequestException
@@ -13,17 +10,11 @@ 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(mock_getaddrinfo):
def test_api_docs_cors_proxy_valid_url():
"""Test the CORS proxy API for documents with a valid URL."""
document = factories.DocumentFactory(link_reach="public")
# 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")
@@ -65,17 +56,11 @@ 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(mock_getaddrinfo):
def test_api_docs_cors_proxy_anonymous_document_not_public():
"""Test the CORS proxy API for documents with an anonymous user and a non-public document."""
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")
@@ -88,22 +73,14 @@ def test_api_docs_cors_proxy_anonymous_document_not_public(mock_getaddrinfo):
}
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
@responses.activate
def test_api_docs_cors_proxy_authenticated_user_accessing_protected_doc(
mock_getaddrinfo,
):
def test_api_docs_cors_proxy_authenticated_user_accessing_protected_doc():
"""
Test the CORS proxy API for documents with an authenticated user accessing a protected
document.
"""
document = factories.DocumentFactory(link_reach="authenticated")
# 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,22 +115,14 @@ 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(
mock_getaddrinfo,
):
def test_api_docs_cors_proxy_authenticated_not_accessing_restricted_doc():
"""
Test the CORS proxy API for documents with an authenticated user not accessing a restricted
document.
"""
document = factories.DocumentFactory(link_reach="restricted")
# 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()
@@ -169,72 +138,18 @@ 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(mock_getaddrinfo):
def test_api_docs_cors_proxy_unsupported_media_type():
"""Test the CORS proxy API for documents with an unsupported media type."""
document = factories.DocumentFactory(link_reach="public")
# 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 == 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."}
assert response.status_code == 415
@pytest.mark.parametrize(
@@ -258,17 +173,11 @@ 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(mock_getaddrinfo):
def test_api_docs_cors_proxy_request_failed():
"""Test the CORS proxy API for documents with a request failed."""
document = factories.DocumentFactory(link_reach="public")
# 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"))
@@ -276,164 +185,6 @@ def test_api_docs_cors_proxy_request_failed(mock_getaddrinfo):
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
)
assert response.status_code == 400
assert response.json() == {"detail": "Invalid URL used."}
@pytest.mark.parametrize(
"url_to_fetch",
[
"http://localhost/image.png",
"https://localhost/image.png",
"http://127.0.0.1/image.png",
"https://127.0.0.1/image.png",
"http://0.0.0.0/image.png",
"https://0.0.0.0/image.png",
"http://[::1]/image.png",
"https://[::1]/image.png",
"http://[0:0:0:0:0:0:0:1]/image.png",
"https://[0:0:0:0:0:0:0:1]/image.png",
],
)
def test_api_docs_cors_proxy_blocks_localhost(url_to_fetch):
"""Test that the CORS proxy API blocks localhost variations."""
document = factories.DocumentFactory(link_reach="public")
client = APIClient()
response = client.get(
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
)
assert response.status_code == 400
assert response.json()["detail"] == "Invalid URL used."
@pytest.mark.parametrize(
"url_to_fetch",
[
"http://10.0.0.1/image.png",
"https://10.0.0.1/image.png",
"http://172.16.0.1/image.png",
"https://172.16.0.1/image.png",
"http://192.168.1.1/image.png",
"https://192.168.1.1/image.png",
"http://10.255.255.255/image.png",
"https://10.255.255.255/image.png",
"http://172.31.255.255/image.png",
"https://172.31.255.255/image.png",
"http://192.168.255.255/image.png",
"https://192.168.255.255/image.png",
],
)
def test_api_docs_cors_proxy_blocks_private_ips(url_to_fetch):
"""Test that the CORS proxy API blocks private IP addresses."""
document = factories.DocumentFactory(link_reach="public")
client = APIClient()
response = client.get(
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
)
assert response.status_code == 400
assert response.json()["detail"] == "Invalid URL used."
@pytest.mark.parametrize(
"url_to_fetch",
[
"http://169.254.1.1/image.png",
"https://169.254.1.1/image.png",
"http://169.254.255.255/image.png",
"https://169.254.255.255/image.png",
],
)
def test_api_docs_cors_proxy_blocks_link_local(url_to_fetch):
"""Test that the CORS proxy API blocks link-local addresses."""
document = factories.DocumentFactory(link_reach="public")
client = APIClient()
response = client.get(
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
)
assert response.status_code == 400
assert response.json()["detail"] == "Invalid URL used."
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
@responses.activate
def test_api_docs_cors_proxy_blocks_dns_rebinding_to_private_ip(mock_getaddrinfo):
"""Test that the CORS proxy API blocks DNS rebinding attacks to private IPs."""
document = factories.DocumentFactory(link_reach="public")
# Mock DNS resolution to return a private IP address
mock_getaddrinfo.return_value = [
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("192.168.1.1", 0))
]
client = APIClient()
url_to_fetch = "https://malicious-domain.com/image.png"
response = client.get(
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
)
assert response.status_code == 400
assert response.json()["detail"] == "Invalid URL used."
mock_getaddrinfo.assert_called_once()
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
@responses.activate
def test_api_docs_cors_proxy_blocks_dns_rebinding_to_localhost(mock_getaddrinfo):
"""Test that the CORS proxy API blocks DNS rebinding attacks to localhost."""
document = factories.DocumentFactory(link_reach="public")
# Mock DNS resolution to return localhost
mock_getaddrinfo.return_value = [
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("127.0.0.1", 0))
]
client = APIClient()
url_to_fetch = "https://malicious-domain.com/image.png"
response = client.get(
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
)
assert response.status_code == 400
assert response.json()["detail"] == "Invalid URL used."
mock_getaddrinfo.assert_called_once()
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
def test_api_docs_cors_proxy_handles_dns_resolution_failure(mock_getaddrinfo):
"""Test that the CORS proxy API handles DNS resolution failures gracefully."""
document = factories.DocumentFactory(link_reach="public")
# Mock DNS resolution to fail
mock_getaddrinfo.side_effect = socket.gaierror("Name or service not known")
client = APIClient()
url_to_fetch = "https://nonexistent-domain-12345.com/image.png"
response = client.get(
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
)
assert response.status_code == 400
assert response.json()["detail"] == "Invalid URL used."
mock_getaddrinfo.assert_called_once()
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
def test_api_docs_cors_proxy_blocks_multiple_resolved_ips_if_any_private(
mock_getaddrinfo,
):
"""Test that the CORS proxy API blocks if any resolved IP is private."""
document = factories.DocumentFactory(link_reach="public")
# Mock DNS resolution to return both public and private IPs
mock_getaddrinfo.return_value = [
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("8.8.8.8", 0)),
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("192.168.1.1", 0)),
]
client = APIClient()
url_to_fetch = "https://example.com/image.png"
response = client.get(
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
)
assert response.status_code == 400
assert response.json()["detail"] == "Invalid URL used."
mock_getaddrinfo.assert_called_once()
assert response.json() == {
"error": "Failed to fetch resource from https://external-url.com/assets/index.html"
}

View File

@@ -16,7 +16,6 @@ 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
@@ -192,9 +191,7 @@ 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", mime_types.MARKDOWN, mime_types.YJS
)
mock_convert_md.assert_called_once_with("Document content")
document = Document.objects.get()
assert response.json() == {"id": str(document.id)}
@@ -239,9 +236,7 @@ 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", mime_types.MARKDOWN, mime_types.YJS
)
mock_convert_md.assert_called_once_with("Document content")
document = Document.objects.get()
assert response.json() == {"id": str(document.id)}
@@ -302,9 +297,7 @@ 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", mime_types.MARKDOWN, mime_types.YJS
)
mock_convert_md.assert_called_once_with("Document content")
document = Document.objects.get()
assert response.json() == {"id": str(document.id)}
@@ -400,9 +393,7 @@ 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", mime_types.MARKDOWN, mime_types.YJS
)
mock_convert_md.assert_called_once_with("Document content")
document = Document.objects.get()
assert response.json() == {"id": str(document.id)}
@@ -483,9 +474,7 @@ def test_api_documents_create_for_owner_with_default_language(
)
assert response.status_code == 201
mock_convert_md.assert_called_once_with(
"Document content", mime_types.MARKDOWN, mime_types.YJS
)
mock_convert_md.assert_called_once_with("Document content")
assert mock_send.call_args[0][3] == "de-de"
@@ -512,9 +501,7 @@ 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", mime_types.MARKDOWN, mime_types.YJS
)
mock_convert_md.assert_called_once_with("Document content")
assert len(mail.outbox) == 1
email = mail.outbox[0]
@@ -550,9 +537,7 @@ 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", mime_types.MARKDOWN, mime_types.YJS
)
mock_convert_md.assert_called_once_with("Document content")
assert len(mail.outbox) == 1
email = mail.outbox[0]
@@ -586,9 +571,7 @@ 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", mime_types.MARKDOWN, mime_types.YJS
)
mock_convert_md.assert_called_once_with("Document content")
assert response.status_code == 400
assert response.json() == {"content": ["Could not convert content"]}

View File

@@ -1,413 +0,0 @@
"""
Tests for Documents API endpoint in impress's core app: create with file upload
"""
from base64 import b64decode, binascii
from io import BytesIO
from unittest.mock import patch
import pytest
from rest_framework.test import APIClient
from core import factories
from core.models import Document
from core.services import mime_types
from core.services.converter_services import (
ConversionError,
ServiceUnavailableError,
)
pytestmark = pytest.mark.django_db
def test_api_documents_create_with_file_anonymous():
"""Anonymous users should not be allowed to create documents with file upload."""
# Create a fake DOCX file
file_content = b"fake docx content"
file = BytesIO(file_content)
file.name = "test_document.docx"
response = APIClient().post(
"/api/v1.0/documents/",
{
"file": file,
},
format="multipart",
)
assert response.status_code == 401
assert not Document.objects.exists()
@patch("core.services.converter_services.Converter.convert")
def test_api_documents_create_with_docx_file_success(mock_convert):
"""
Authenticated users should be able to create documents by uploading a DOCX file.
The file should be converted to YJS format and the title should be set from filename.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Mock the conversion
converted_yjs = "base64encodedyjscontent"
mock_convert.return_value = converted_yjs
# Create a fake DOCX file
file_content = b"fake docx content"
file = BytesIO(file_content)
file.name = "My Important Document.docx"
response = client.post(
"/api/v1.0/documents/",
{
"file": file,
},
format="multipart",
)
assert response.status_code == 201
document = Document.objects.get()
assert document.title == "My Important Document.docx"
assert document.content == converted_yjs
assert document.accesses.filter(role="owner", user=user).exists()
# Verify the converter was called correctly
mock_convert.assert_called_once_with(
file_content,
content_type=mime_types.DOCX,
accept=mime_types.YJS,
)
@patch("core.services.converter_services.Converter.convert")
def test_api_documents_create_with_markdown_file_success(mock_convert):
"""
Authenticated users should be able to create documents by uploading a Markdown file.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Mock the conversion
converted_yjs = "base64encodedyjscontent"
mock_convert.return_value = converted_yjs
# Create a fake Markdown file
file_content = b"# Test Document\n\nThis is a test."
file = BytesIO(file_content)
file.name = "readme.md"
response = client.post(
"/api/v1.0/documents/",
{
"file": file,
},
format="multipart",
)
assert response.status_code == 201
document = Document.objects.get()
assert document.title == "readme.md"
assert document.content == converted_yjs
assert document.accesses.filter(role="owner", user=user).exists()
# Verify the converter was called correctly
mock_convert.assert_called_once_with(
file_content,
content_type=mime_types.MARKDOWN,
accept=mime_types.YJS,
)
@patch("core.services.converter_services.Converter.convert")
def test_api_documents_create_with_file_and_explicit_title(mock_convert):
"""
When both file and title are provided, the filename should override the title.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Mock the conversion
converted_yjs = "base64encodedyjscontent"
mock_convert.return_value = converted_yjs
# Create a fake DOCX file
file_content = b"fake docx content"
file = BytesIO(file_content)
file.name = "Uploaded Document.docx"
response = client.post(
"/api/v1.0/documents/",
{
"file": file,
"title": "This should be overridden",
},
format="multipart",
)
assert response.status_code == 201
document = Document.objects.get()
# The filename should take precedence
assert document.title == "Uploaded Document.docx"
def test_api_documents_create_with_empty_file():
"""
Creating a document with an empty file should fail with a validation error.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Create an empty file
file = BytesIO(b"")
file.name = "empty.docx"
response = client.post(
"/api/v1.0/documents/",
{
"file": file,
},
format="multipart",
)
assert response.status_code == 400
assert response.json() == {"file": ["The submitted file is empty."]}
assert not Document.objects.exists()
@patch("core.services.converter_services.Converter.convert")
def test_api_documents_create_with_file_conversion_error(mock_convert):
"""
When conversion fails, the API should return a 400 error with appropriate message.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Mock the conversion to raise an error
mock_convert.side_effect = ConversionError("Failed to convert document")
# Create a fake DOCX file
file_content = b"fake invalid docx content"
file = BytesIO(file_content)
file.name = "corrupted.docx"
response = client.post(
"/api/v1.0/documents/",
{
"file": file,
},
format="multipart",
)
assert response.status_code == 400
assert response.json() == {"file": ["Could not convert file content"]}
assert not Document.objects.exists()
@patch("core.services.converter_services.Converter.convert")
def test_api_documents_create_with_file_service_unavailable(mock_convert):
"""
When the conversion service is unavailable, appropriate error should be returned.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Mock the conversion to raise ServiceUnavailableError
mock_convert.side_effect = ServiceUnavailableError(
"Failed to connect to conversion service"
)
# Create a fake DOCX file
file_content = b"fake docx content"
file = BytesIO(file_content)
file.name = "document.docx"
response = client.post(
"/api/v1.0/documents/",
{
"file": file,
},
format="multipart",
)
assert response.status_code == 400
assert response.json() == {"file": ["Could not convert file content"]}
assert not Document.objects.exists()
def test_api_documents_create_without_file_still_works():
"""
Creating a document without a file should still work as before (backward compatibility).
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/documents/",
{
"title": "Regular document without file",
},
format="json",
)
assert response.status_code == 201
document = Document.objects.get()
assert document.title == "Regular document without file"
assert document.content is None
assert document.accesses.filter(role="owner", user=user).exists()
@patch("core.services.converter_services.Converter.convert")
def test_api_documents_create_with_file_null_value(mock_convert):
"""
Passing file=null should be treated as no file upload.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/documents/",
{
"title": "Document with null file",
"file": None,
},
format="json",
)
assert response.status_code == 201
document = Document.objects.get()
assert document.title == "Document with null file"
# Converter should not have been called
mock_convert.assert_not_called()
@patch("core.services.converter_services.Converter.convert")
def test_api_documents_create_with_file_preserves_content_format(mock_convert):
"""
Verify that the converted content is stored correctly in the document.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Mock the conversion with realistic base64-encoded YJS data
converted_yjs = "AQMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICA="
mock_convert.return_value = converted_yjs
# Create a fake DOCX file
file_content = b"fake docx with complex formatting"
file = BytesIO(file_content)
file.name = "complex_document.docx"
response = client.post(
"/api/v1.0/documents/",
{
"file": file,
},
format="multipart",
)
assert response.status_code == 201
document = Document.objects.get()
# Verify the content is stored as returned by the converter
assert document.content == converted_yjs
# Verify it's valid base64 (can be decoded)
try:
b64decode(converted_yjs)
except binascii.Error:
pytest.fail("Content should be valid base64-encoded data")
@patch("core.services.converter_services.Converter.convert")
def test_api_documents_create_with_file_unicode_filename(mock_convert):
"""
Test that Unicode characters in filenames are handled correctly.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Mock the conversion
converted_yjs = "base64encodedyjscontent"
mock_convert.return_value = converted_yjs
# Create a file with Unicode characters in the name
file_content = b"fake docx content"
file = BytesIO(file_content)
file.name = "文档-télécharger-документ.docx"
response = client.post(
"/api/v1.0/documents/",
{
"file": file,
},
format="multipart",
)
assert response.status_code == 201
document = Document.objects.get()
assert document.title == "文档-télécharger-документ.docx"
def test_api_documents_create_with_file_max_size_exceeded(settings):
"""
The uploaded file should not exceed the maximum size in settings.
"""
settings.CONVERSION_FILE_MAX_SIZE = 1 # 1 byte for test
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
file = BytesIO(b"a" * (10))
file.name = "test.docx"
response = client.post(
"/api/v1.0/documents/",
{
"file": file,
},
format="multipart",
)
assert response.status_code == 400
assert response.json() == {"file": ["File size exceeds the maximum limit of 0 MB."]}
def test_api_documents_create_with_file_extension_not_allowed(settings):
"""
The uploaded file should not have an allowed extension.
"""
settings.CONVERSION_FILE_EXTENSIONS_ALLOWED = [".docx"]
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
file = BytesIO(b"fake docx content")
file.name = "test.md"
response = client.post(
"/api/v1.0/documents/",
{
"file": file,
},
format="multipart",
)
assert response.status_code == 400
assert response.json() == {
"file": [
"File extension .md is not allowed. Allowed extensions are: ['.docx']."
]
}

View File

@@ -83,34 +83,3 @@ def test_api_document_favorite_list_authenticated_with_favorite():
}
],
}
def test_api_document_favorite_list_with_favorite_children():
"""Authenticated users should receive their favorite documents, including children."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
root = factories.DocumentFactory(creator=user, users=[user])
children = factories.DocumentFactory.create_batch(
2, parent=root, favorited_by=[user]
)
access = factories.UserDocumentAccessFactory(
user=user, role=models.RoleChoices.READER, document__favorited_by=[user]
)
other_root = factories.DocumentFactory(creator=user, users=[user])
factories.DocumentFactory.create_batch(2, parent=other_root)
response = client.get("/api/v1.0/documents/favorite_list/")
assert response.status_code == 200
assert response.json()["count"] == 3
content = response.json()["results"]
assert content[0]["id"] == str(children[0].id)
assert content[1]["id"] == str(children[1].id)
assert content[2]["id"] == str(access.document.id)

View File

@@ -0,0 +1,46 @@
"""
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()

View File

@@ -0,0 +1,45 @@
"""
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

View File

@@ -0,0 +1,237 @@
"""
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

View File

@@ -0,0 +1,522 @@
"""
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,
}

View File

@@ -0,0 +1,54 @@
"""
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

View File

@@ -20,12 +20,10 @@ pytestmark = pytest.mark.django_db
@override_settings(
AI_FEATURE_ENABLED=False,
API_USERS_SEARCH_QUERY_MIN_LENGTH=6,
COLLABORATION_WS_URL="http://testcollab/",
COLLABORATION_WS_NOT_CONNECTED_READY_ONLY=True,
CRISP_WEBSITE_ID="123",
FRONTEND_CSS_URL="http://testcss/",
FRONTEND_JS_URL="http://testjs/",
FRONTEND_THEME="test-theme",
MEDIA_BASE_URL="http://testserver/",
POSTHOG_KEY={"id": "132456", "host": "https://eu.i.posthog-test.com"},
@@ -45,17 +43,12 @@ def test_api_config(is_authenticated):
assert response.status_code == HTTP_200_OK
assert response.json() == {
"AI_FEATURE_ENABLED": False,
"API_USERS_SEARCH_QUERY_MIN_LENGTH": 6,
"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"],

View File

@@ -1,85 +0,0 @@
"""
Unit tests for the ReconciliationConfirmView API view.
"""
import uuid
from django.conf import settings
import pytest
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
def test_reconciliation_confirm_view_sets_active_checked():
"""GETting the active confirmation endpoint should set active_email_checked."""
user = factories.UserFactory(email="user.confirm1@example.com")
other = factories.UserFactory(email="user.confirm2@example.com")
rec = models.UserReconciliation.objects.create(
active_email=user.email,
inactive_email=other.email,
active_user=user,
inactive_user=other,
active_email_checked=False,
inactive_email_checked=False,
status="ready",
)
client = APIClient()
conf_id = rec.active_email_confirmation_id
url = f"/api/{settings.API_VERSION}/user-reconciliations/active/{conf_id}/"
resp = client.get(url)
assert resp.status_code == 200
assert resp.json() == {"detail": "Confirmation received"}
rec.refresh_from_db()
assert rec.active_email_checked is True
def test_reconciliation_confirm_view_sets_inactive_checked():
"""GETting the inactive confirmation endpoint should set inactive_email_checked."""
user = factories.UserFactory(email="user.confirm3@example.com")
other = factories.UserFactory(email="user.confirm4@example.com")
rec = models.UserReconciliation.objects.create(
active_email=user.email,
inactive_email=other.email,
active_user=user,
inactive_user=other,
active_email_checked=False,
inactive_email_checked=False,
status="ready",
)
client = APIClient()
conf_id = rec.inactive_email_confirmation_id
url = f"/api/{settings.API_VERSION}/user-reconciliations/inactive/{conf_id}/"
resp = client.get(url)
assert resp.status_code == 200
assert resp.json() == {"detail": "Confirmation received"}
rec.refresh_from_db()
assert rec.inactive_email_checked is True
def test_reconciliation_confirm_view_invalid_user_type_returns_400():
"""GETting with an invalid user_type should return 400."""
client = APIClient()
# Use a valid uuid format but invalid user_type
url = f"/api/{settings.API_VERSION}/user-reconciliations/other/{uuid.uuid4()}/"
resp = client.get(url)
assert resp.status_code == 400
assert resp.json() == {"detail": "Invalid user_type"}
def test_reconciliation_confirm_view_not_found_returns_404():
"""GETting with a non-existing confirmation_id should return 404."""
client = APIClient()
url = f"/api/{settings.API_VERSION}/user-reconciliations/active/{uuid.uuid4()}/"
resp = client.get(url)
assert resp.status_code == 404
assert resp.json() == {"detail": "Reconciliation entry not found"}

View File

@@ -2,8 +2,6 @@
Test users API endpoints in the impress core app.
"""
from django.utils import timezone
import pytest
from rest_framework.test import APIClient
@@ -123,12 +121,12 @@ def test_api_users_list_query_full_name():
Authenticated users should be able to list users and filter by full name.
Only results with a Trigram similarity greater than 0.2 with the query should be returned.
"""
user = factories.UserFactory(email="user@example.com")
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
dave = factories.UserFactory(email="contact@example.com", full_name="David Bowman")
dave = factories.UserFactory(email="contact@work.com", full_name="David Bowman")
response = client.get(
"/api/v1.0/users/?q=David",
@@ -168,13 +166,13 @@ def test_api_users_list_query_accented_full_name():
Authenticated users should be able to list users and filter by full name with accents.
Only results with a Trigram similarity greater than 0.2 with the query should be returned.
"""
user = factories.UserFactory(email="user@example.com")
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
fred = factories.UserFactory(
email="contact@example.com", full_name="Frédérique Lefèvre"
email="contact@work.com", full_name="Frédérique Lefèvre"
)
response = client.get("/api/v1.0/users/?q=Frédérique")
@@ -203,82 +201,12 @@ def test_api_users_list_query_accented_full_name():
assert users == []
def test_api_users_list_sorted_by_closest_match():
"""
Authenticated users should be able to list users and the results should be
sorted by closest match to the query.
Sorting criteria are :
- Shared documents with the user (most recent first)
- Same full email domain (example.gouv.fr)
Addresses that match neither criteria should be excluded from the results.
Case in point: the logged-in user has recently shared documents
with pierre.dupont@beta.gouv.fr and less recently with pierre.durand@impots.gouv.fr.
Other users named Pierre also exist:
- pierre.thomas@example.com
- pierre.petit@anct.gouv.fr
- pierre.robert@culture.gouv.fr
The search results should be ordered as follows:
# Shared with first
- pierre.dupond@beta.gouv.fr # Most recent first
- pierre.durand@impots.gouv.fr
# Same full domain second
- pierre.petit@anct.gouv.fr
"""
user = factories.UserFactory(
email="martin.bernard@anct.gouv.fr", full_name="Martin Bernard"
)
client = APIClient()
client.force_login(user)
pierre_1 = factories.UserFactory(email="pierre.dupont@beta.gouv.fr")
pierre_2 = factories.UserFactory(email="pierre.durand@impots.gouv.fr")
_pierre_3 = factories.UserFactory(email="pierre.thomas@example.com")
pierre_4 = factories.UserFactory(email="pierre.petit@anct.gouv.fr")
_pierre_5 = factories.UserFactory(email="pierre.robert@culture.gouv.fr")
document_1 = factories.DocumentFactory(creator=user)
document_2 = factories.DocumentFactory(creator=user)
factories.UserDocumentAccessFactory(user=user, document=document_1)
factories.UserDocumentAccessFactory(user=user, document=document_2)
now = timezone.now()
last_week = now - timezone.timedelta(days=7)
last_month = now - timezone.timedelta(days=30)
# The factory cannot set the created_at directly, so we force it after creation
p1_d1 = factories.UserDocumentAccessFactory(user=pierre_1, document=document_1)
p1_d1.created_at = last_week
p1_d1.save()
p2_d2 = factories.UserDocumentAccessFactory(user=pierre_2, document=document_2)
p2_d2.created_at = last_month
p2_d2.save()
response = client.get("/api/v1.0/users/?q=Pierre")
assert response.status_code == 200
user_ids = [user["email"] for user in response.json()]
assert user_ids == [
str(pierre_1.email),
str(pierre_2.email),
str(pierre_4.email),
]
def test_api_users_list_limit(settings):
"""
Authenticated users should be able to list users and the number of results
should be limited to API_USERS_LIST_LIMIT (by default 5).
should be limited to 10.
"""
user = factories.UserFactory(email="user@example.com")
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -381,16 +309,28 @@ def test_api_users_list_query_email_exclude_doc_user():
def test_api_users_list_query_short_queries():
"""
If API_USERS_SEARCH_QUERY_MIN_LENGTH is not set, the default minimum length should be 3.
Queries shorter than 5 characters should return an empty result set.
"""
user = factories.UserFactory(email="paul@example.com", full_name="Paul")
user = factories.UserFactory(email="paul@example.com")
client = APIClient()
client.force_login(user)
factories.UserFactory(email="john.doe@example.com", full_name="John Doe")
factories.UserFactory(email="john.lennon@example.com", full_name="John Lennon")
factories.UserFactory(email="john.doe@example.com")
factories.UserFactory(email="john.lennon@example.com")
response = client.get("/api/v1.0/users/?q=joh")
response = client.get("/api/v1.0/users/?q=jo")
assert response.status_code == 400
assert response.json() == {
"q": ["Ensure this value has at least 5 characters (it has 2)."]
}
response = client.get("/api/v1.0/users/?q=john")
assert response.status_code == 400
assert response.json() == {
"q": ["Ensure this value has at least 5 characters (it has 4)."]
}
response = client.get("/api/v1.0/users/?q=john.")
assert response.status_code == 200
assert len(response.json()) == 2
@@ -416,7 +356,7 @@ def test_api_users_list_query_long_queries():
def test_api_users_list_query_inactive():
"""Inactive users should not be listed."""
user = factories.UserFactory(email="user@example.com")
user = factories.UserFactory()
client = APIClient()
client.force_login(user)

View File

@@ -1024,39 +1024,6 @@ 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.
@@ -1426,7 +1393,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(14):
with django_assert_num_queries(13):
document.restore()
document.refresh_from_db()
child1.refresh_from_db()

View File

@@ -0,0 +1,419 @@
"""
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": [],
}

View File

@@ -0,0 +1,187 @@
"""
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,
}

View File

@@ -1,669 +0,0 @@
"""
Unit tests for the UserReconciliationCsvImport model
"""
import uuid
from pathlib import Path
from django.core import mail
from django.core.files.base import ContentFile
import pytest
from core import factories, models
from core.admin import process_reconciliation
from core.tasks.user_reconciliation import user_reconciliation_csv_import_job
pytestmark = pytest.mark.django_db
@pytest.fixture(name="import_example_csv_basic")
def fixture_import_example_csv_basic():
"""
Import an example CSV file for user reconciliation
and return the created import object.
"""
# Create users referenced in the CSV
for i in range(40, 50):
factories.UserFactory(email=f"user.test{i}@example.com")
example_csv_path = Path(__file__).parent / "data/example_reconciliation_basic.csv"
with open(example_csv_path, "rb") as f:
csv_file = ContentFile(f.read(), name="example_reconciliation_basic.csv")
csv_import = models.UserReconciliationCsvImport(file=csv_file)
csv_import.save()
return csv_import
@pytest.fixture(name="import_example_csv_grist_form")
def fixture_import_example_csv_grist_form():
"""
Import an example CSV file for user reconciliation
and return the created import object.
"""
# Create users referenced in the CSV
for i in range(10, 40):
factories.UserFactory(email=f"user.test{i}@example.com")
example_csv_path = (
Path(__file__).parent / "data/example_reconciliation_grist_form.csv"
)
with open(example_csv_path, "rb") as f:
csv_file = ContentFile(f.read(), name="example_reconciliation_grist_form.csv")
csv_import = models.UserReconciliationCsvImport(file=csv_file)
csv_import.save()
return csv_import
def test_user_reconciliation_csv_import_entry_is_created(import_example_csv_basic):
"""Test that a UserReconciliationCsvImport entry is created correctly."""
assert import_example_csv_basic.status == "pending"
assert import_example_csv_basic.file.name.endswith(
"example_reconciliation_basic.csv"
)
def test_user_reconciliation_csv_import_entry_is_created_grist_form(
import_example_csv_grist_form,
):
"""Test that a UserReconciliationCsvImport entry is created correctly."""
assert import_example_csv_grist_form.status == "pending"
assert import_example_csv_grist_form.file.name.endswith(
"example_reconciliation_grist_form.csv"
)
def test_incorrect_csv_format_handling():
"""Test that an incorrectly formatted CSV file is handled gracefully."""
example_csv_path = (
Path(__file__).parent / "data/example_reconciliation_missing_column.csv"
)
with open(example_csv_path, "rb") as f:
csv_file = ContentFile(
f.read(), name="example_reconciliation_missing_column.csv"
)
csv_import = models.UserReconciliationCsvImport(file=csv_file)
csv_import.save()
assert csv_import.status == "pending"
user_reconciliation_csv_import_job(csv_import.id)
csv_import.refresh_from_db()
assert (
"CSV is missing mandatory columns: active_email, inactive_email, id"
in csv_import.logs
)
assert csv_import.status == "error"
def test_incorrect_email_format_handling():
"""Test that an incorrectly formatted CSV file is handled gracefully."""
example_csv_path = Path(__file__).parent / "data/example_reconciliation_error.csv"
with open(example_csv_path, "rb") as f:
csv_file = ContentFile(f.read(), name="example_reconciliation_error.csv")
csv_import = models.UserReconciliationCsvImport(file=csv_file)
csv_import.save()
assert csv_import.status == "pending"
user_reconciliation_csv_import_job(csv_import.id)
csv_import.refresh_from_db()
assert "Invalid inactive email address on row 40" in csv_import.logs
assert csv_import.status == "done"
# pylint: disable-next=no-member
assert len(mail.outbox) == 1
# pylint: disable-next=no-member
email = mail.outbox[0]
assert email.to == ["user.test40@example.com"]
email_content = " ".join(email.body.split())
assert "Reconciliation of your Docs accounts not completed" in email_content
def test_incorrect_csv_data_handling_grist_form():
"""Test that a CSV file with incorrect data is handled gracefully."""
example_csv_path = (
Path(__file__).parent / "data/example_reconciliation_grist_form_error.csv"
)
with open(example_csv_path, "rb") as f:
csv_file = ContentFile(
f.read(), name="example_reconciliation_grist_form_error.csv"
)
csv_import = models.UserReconciliationCsvImport(file=csv_file)
csv_import.save()
assert csv_import.status == "pending"
user_reconciliation_csv_import_job(csv_import.id)
csv_import.refresh_from_db()
assert (
"user.test20@example.com set as both active and inactive email"
in csv_import.logs
)
assert csv_import.status == "done"
def test_job_creates_reconciliation_entries(import_example_csv_basic):
"""Test that the CSV import job creates UserReconciliation entries."""
assert import_example_csv_basic.status == "pending"
user_reconciliation_csv_import_job(import_example_csv_basic.id)
# Verify the job status changed
import_example_csv_basic.refresh_from_db()
assert import_example_csv_basic.status == "done"
assert "Import completed successfully." in import_example_csv_basic.logs
assert "6 rows processed." in import_example_csv_basic.logs
assert "5 reconciliation entries created." in import_example_csv_basic.logs
# Verify reconciliation entries were created
reconciliations = models.UserReconciliation.objects.all()
assert reconciliations.count() == 5
def test_job_does_not_create_duplicated_reconciliation_entries(
import_example_csv_basic,
):
"""Test that the CSV import job doesn't create UserReconciliation entries
for source unique IDs that have already been processed."""
_already_created_entry = models.UserReconciliation.objects.create(
active_email="user.test40@example.com",
inactive_email="user.test41@example.com",
active_email_checked=0,
inactive_email_checked=0,
status="pending",
source_unique_id=1,
)
assert import_example_csv_basic.status == "pending"
user_reconciliation_csv_import_job(import_example_csv_basic.id)
# Verify the job status changed
import_example_csv_basic.refresh_from_db()
assert import_example_csv_basic.status == "done"
assert "Import completed successfully." in import_example_csv_basic.logs
assert "6 rows processed." in import_example_csv_basic.logs
assert "4 reconciliation entries created." in import_example_csv_basic.logs
assert "1 rows were already processed." in import_example_csv_basic.logs
# Verify the correct number of reconciliation entries were created
reconciliations = models.UserReconciliation.objects.all()
assert reconciliations.count() == 5
def test_job_creates_reconciliation_entries_grist_form(import_example_csv_grist_form):
"""Test that the CSV import job creates UserReconciliation entries."""
assert import_example_csv_grist_form.status == "pending"
user_reconciliation_csv_import_job(import_example_csv_grist_form.id)
# Verify the job status changed
import_example_csv_grist_form.refresh_from_db()
assert "Import completed successfully" in import_example_csv_grist_form.logs
assert import_example_csv_grist_form.status == "done"
# Verify reconciliation entries were created
reconciliations = models.UserReconciliation.objects.all()
assert reconciliations.count() == 9
def test_csv_import_reconciliation_data_is_correct(import_example_csv_basic):
"""Test that the data in created UserReconciliation entries matches the CSV."""
user_reconciliation_csv_import_job(import_example_csv_basic.id)
reconciliations = models.UserReconciliation.objects.order_by("created_at")
first_entry = reconciliations.first()
assert first_entry.active_email == "user.test40@example.com"
assert first_entry.inactive_email == "user.test41@example.com"
assert first_entry.active_email_checked is False
assert first_entry.inactive_email_checked is False
for rec in reconciliations:
assert rec.status == "ready"
@pytest.fixture(name="user_reconciliation_users_and_docs")
def fixture_user_reconciliation_users_and_docs():
"""Fixture to create two users with overlapping document accesses
for reconciliation tests."""
user_1 = factories.UserFactory(email="user.test1@example.com")
user_2 = factories.UserFactory(email="user.test2@example.com")
# Create 10 distinct document accesses for each user
userdocs_u1 = [
factories.UserDocumentAccessFactory(user=user_1, role="editor")
for _ in range(10)
]
userdocs_u2 = [
factories.UserDocumentAccessFactory(user=user_2, role="editor")
for _ in range(10)
]
# Make the first 3 documents of each list shared with the other user
# with a lower role
for ud in userdocs_u1[0:3]:
factories.UserDocumentAccessFactory(
user=user_2, document=ud.document, role="reader"
)
for ud in userdocs_u2[0:3]:
factories.UserDocumentAccessFactory(
user=user_1, document=ud.document, role="reader"
)
# Make the next 3 documents of each list shared with the other user
# with a higher role
for ud in userdocs_u1[3:6]:
factories.UserDocumentAccessFactory(
user=user_2, document=ud.document, role="owner"
)
for ud in userdocs_u2[3:6]:
factories.UserDocumentAccessFactory(
user=user_1, document=ud.document, role="owner"
)
return (user_1, user_2, userdocs_u1, userdocs_u2)
def test_user_reconciliation_is_created(user_reconciliation_users_and_docs):
"""Test that a UserReconciliation entry can be created and saved."""
user_1, user_2, _userdocs_u1, _userdocs_u2 = user_reconciliation_users_and_docs
rec = models.UserReconciliation.objects.create(
active_email=user_1.email,
inactive_email=user_2.email,
active_email_checked=False,
inactive_email_checked=True,
active_email_confirmation_id=uuid.uuid4(),
inactive_email_confirmation_id=uuid.uuid4(),
status="pending",
)
rec.save()
assert rec.status == "ready"
def test_user_reconciliation_verification_emails_are_sent(
user_reconciliation_users_and_docs,
):
"""Test that both UserReconciliation verification emails are sent."""
user_1, user_2, _userdocs_u1, _userdocs_u2 = user_reconciliation_users_and_docs
rec = models.UserReconciliation.objects.create(
active_email=user_1.email,
inactive_email=user_2.email,
active_email_checked=False,
inactive_email_checked=False,
active_email_confirmation_id=uuid.uuid4(),
inactive_email_confirmation_id=uuid.uuid4(),
status="pending",
)
rec.save()
# pylint: disable-next=no-member
assert len(mail.outbox) == 2
# pylint: disable-next=no-member
email_1 = mail.outbox[0]
assert email_1.to == [user_1.email]
email_1_content = " ".join(email_1.body.split())
assert (
"You have requested a reconciliation of your user accounts on Docs."
in email_1_content
)
active_email_confirmation_id = rec.active_email_confirmation_id
inactive_email_confirmation_id = rec.inactive_email_confirmation_id
assert (
f"user-reconciliations/active/{active_email_confirmation_id}/"
in email_1_content
)
# pylint: disable-next=no-member
email_2 = mail.outbox[1]
assert email_2.to == [user_2.email]
email_2_content = " ".join(email_2.body.split())
assert (
"You have requested a reconciliation of your user accounts on Docs."
in email_2_content
)
assert (
f"user-reconciliations/inactive/{inactive_email_confirmation_id}/"
in email_2_content
)
def test_user_reconciliation_only_starts_if_checks_are_made(
user_reconciliation_users_and_docs,
):
"""Test that the admin action does not process entries
unless both email checks are confirmed.
"""
user_1, user_2, _userdocs_u1, _userdocs_u2 = user_reconciliation_users_and_docs
# Create a reconciliation entry where only one email has been checked
rec = models.UserReconciliation.objects.create(
active_email=user_1.email,
inactive_email=user_2.email,
active_email_checked=True,
inactive_email_checked=False,
status="pending",
)
rec.save()
# Capture counts before running admin action
accesses_before_active = models.DocumentAccess.objects.filter(user=user_1).count()
accesses_before_inactive = models.DocumentAccess.objects.filter(user=user_2).count()
users_active_before = (user_1.is_active, user_2.is_active)
# Call the admin action with the queryset containing our single rec
qs = models.UserReconciliation.objects.filter(id=rec.id)
process_reconciliation(None, None, qs)
# Reload from DB and assert nothing was processed (checks prevent processing)
rec.refresh_from_db()
user_1.refresh_from_db()
user_2.refresh_from_db()
assert rec.status == "ready"
assert (
models.DocumentAccess.objects.filter(user=user_1).count()
== accesses_before_active
)
assert (
models.DocumentAccess.objects.filter(user=user_2).count()
== accesses_before_inactive
)
assert (user_1.is_active, user_2.is_active) == users_active_before
def test_process_reconciliation_updates_accesses(
user_reconciliation_users_and_docs,
):
"""Test that accesses are consolidated on the active user."""
user_1, user_2, userdocs_u1, userdocs_u2 = user_reconciliation_users_and_docs
u1_2 = userdocs_u1[2]
u1_5 = userdocs_u1[5]
u2doc1 = userdocs_u2[1].document
u2doc5 = userdocs_u2[5].document
rec = models.UserReconciliation.objects.create(
active_email=user_1.email,
inactive_email=user_2.email,
active_user=user_1,
inactive_user=user_2,
active_email_checked=True,
inactive_email_checked=True,
status="ready",
)
qs = models.UserReconciliation.objects.filter(id=rec.id)
process_reconciliation(None, None, qs)
rec.refresh_from_db()
user_1.refresh_from_db()
user_2.refresh_from_db()
u1_2.refresh_from_db(
from_queryset=models.DocumentAccess.objects.select_for_update()
)
u1_5.refresh_from_db(
from_queryset=models.DocumentAccess.objects.select_for_update()
)
# After processing, inactive user should have no accesses
# and active user should have one access per union document
# with the highest role
assert rec.status == "done"
assert "Requested update for 10 DocumentAccess items" in rec.logs
assert "and deletion for 12 DocumentAccess items" in rec.logs
assert models.DocumentAccess.objects.filter(user=user_2).count() == 0
assert models.DocumentAccess.objects.filter(user=user_1).count() == 20
assert u1_2.role == "editor"
assert u1_5.role == "owner"
assert (
models.DocumentAccess.objects.filter(user=user_1, document=u2doc1).first().role
== "editor"
)
assert (
models.DocumentAccess.objects.filter(user=user_1, document=u2doc5).first().role
== "owner"
)
assert user_1.is_active is True
assert user_2.is_active is False
# pylint: disable-next=no-member
assert len(mail.outbox) == 1
# pylint: disable-next=no-member
email = mail.outbox[0]
assert email.to == [user_1.email]
email_content = " ".join(email.body.split())
assert "Your accounts have been merged" in email_content
def test_process_reconciliation_updates_linktraces(
user_reconciliation_users_and_docs,
):
"""Test that linktraces are consolidated on the active user."""
user_1, user_2, userdocs_u1, userdocs_u2 = user_reconciliation_users_and_docs
u1_2 = userdocs_u1[2]
u1_5 = userdocs_u1[5]
doc_both = u1_2.document
models.LinkTrace.objects.create(document=doc_both, user=user_1)
models.LinkTrace.objects.create(document=doc_both, user=user_2)
doc_inactive_only = userdocs_u2[4].document
models.LinkTrace.objects.create(
document=doc_inactive_only, user=user_2, is_masked=True
)
doc_active_only = userdocs_u1[4].document
models.LinkTrace.objects.create(document=doc_active_only, user=user_1)
rec = models.UserReconciliation.objects.create(
active_email=user_1.email,
inactive_email=user_2.email,
active_user=user_1,
inactive_user=user_2,
active_email_checked=True,
inactive_email_checked=True,
status="ready",
)
qs = models.UserReconciliation.objects.filter(id=rec.id)
process_reconciliation(None, None, qs)
rec.refresh_from_db()
user_1.refresh_from_db()
user_2.refresh_from_db()
u1_2.refresh_from_db(
from_queryset=models.DocumentAccess.objects.select_for_update()
)
u1_5.refresh_from_db(
from_queryset=models.DocumentAccess.objects.select_for_update()
)
# Inactive user should have no linktraces
assert models.LinkTrace.objects.filter(user=user_2).count() == 0
# doc_both should have a single LinkTrace owned by the active user
assert (
models.LinkTrace.objects.filter(user=user_1, document=doc_both).exists() is True
)
assert models.LinkTrace.objects.filter(user=user_1, document=doc_both).count() == 1
assert (
models.LinkTrace.objects.filter(user=user_2, document=doc_both).exists()
is False
)
# doc_inactive_only should now be linked to active user and preserve is_masked
lt = models.LinkTrace.objects.filter(
user=user_1, document=doc_inactive_only
).first()
assert lt is not None
assert lt.is_masked is True
# doc_active_only should still belong to active user
assert models.LinkTrace.objects.filter(
user=user_1, document=doc_active_only
).exists()
def test_process_reconciliation_updates_threads_comments_reactions(
user_reconciliation_users_and_docs,
):
"""Test that threads, comments and reactions are transferred/deduplicated
on reconciliation."""
user_1, user_2, _userdocs_u1, userdocs_u2 = user_reconciliation_users_and_docs
# Use a document from the inactive user's set
document = userdocs_u2[0].document
# Thread and comment created by inactive user -> should be moved to active
thread = factories.ThreadFactory(document=document, creator=user_2)
comment = factories.CommentFactory(thread=thread, user=user_2)
# Reaction where only inactive user reacted -> should be moved to active user
reaction_inactive_only = factories.ReactionFactory(comment=comment, users=[user_2])
# Reaction where both users reacted -> inactive user's participation should be removed
thread2 = factories.ThreadFactory(document=document, creator=user_1)
comment2 = factories.CommentFactory(thread=thread2, user=user_1)
reaction_both = factories.ReactionFactory(comment=comment2, users=[user_1, user_2])
# Reaction where only active user reacted -> unchanged
thread3 = factories.ThreadFactory(document=document, creator=user_1)
comment3 = factories.CommentFactory(thread=thread3, user=user_1)
reaction_active_only = factories.ReactionFactory(comment=comment3, users=[user_1])
rec = models.UserReconciliation.objects.create(
active_email=user_1.email,
inactive_email=user_2.email,
active_user=user_1,
inactive_user=user_2,
active_email_checked=True,
inactive_email_checked=True,
status="ready",
)
qs = models.UserReconciliation.objects.filter(id=rec.id)
process_reconciliation(None, None, qs)
# Refresh objects
thread.refresh_from_db()
comment.refresh_from_db()
reaction_inactive_only.refresh_from_db()
reaction_both.refresh_from_db()
reaction_active_only.refresh_from_db()
# Thread and comment creator should now be the active user
assert thread.creator == user_1
assert comment.user == user_1
# reaction_inactive_only: inactive user's participation should be removed and
# active user's participation added
reaction_inactive_only.refresh_from_db()
assert not reaction_inactive_only.users.filter(pk=user_2.pk).exists()
assert reaction_inactive_only.users.filter(pk=user_1.pk).exists()
# reaction_both: should end up with only active user's participation
assert reaction_both.users.filter(pk=user_2.pk).exists() is False
assert reaction_both.users.filter(pk=user_1.pk).exists() is True
# reaction_active_only should still have active user's participation
assert reaction_active_only.users.filter(pk=user_1.pk).exists()
def test_process_reconciliation_updates_favorites(
user_reconciliation_users_and_docs,
):
"""Test that favorites are consolidated on the active user."""
user_1, user_2, userdocs_u1, userdocs_u2 = user_reconciliation_users_and_docs
u1_2 = userdocs_u1[2]
u1_5 = userdocs_u1[5]
doc_both = u1_2.document
models.DocumentFavorite.objects.create(document=doc_both, user=user_1)
models.DocumentFavorite.objects.create(document=doc_both, user=user_2)
doc_inactive_only = userdocs_u2[4].document
models.DocumentFavorite.objects.create(document=doc_inactive_only, user=user_2)
doc_active_only = userdocs_u1[4].document
models.DocumentFavorite.objects.create(document=doc_active_only, user=user_1)
rec = models.UserReconciliation.objects.create(
active_email=user_1.email,
inactive_email=user_2.email,
active_user=user_1,
inactive_user=user_2,
active_email_checked=True,
inactive_email_checked=True,
status="ready",
)
qs = models.UserReconciliation.objects.filter(id=rec.id)
process_reconciliation(None, None, qs)
rec.refresh_from_db()
user_1.refresh_from_db()
user_2.refresh_from_db()
u1_2.refresh_from_db(
from_queryset=models.DocumentAccess.objects.select_for_update()
)
u1_5.refresh_from_db(
from_queryset=models.DocumentAccess.objects.select_for_update()
)
# Inactive user should have no document favorites
assert models.DocumentFavorite.objects.filter(user=user_2).count() == 0
# doc_both should have a single DocumentFavorite owned by the active user
assert (
models.DocumentFavorite.objects.filter(user=user_1, document=doc_both).exists()
is True
)
assert (
models.DocumentFavorite.objects.filter(user=user_1, document=doc_both).count()
== 1
)
assert (
models.DocumentFavorite.objects.filter(user=user_2, document=doc_both).exists()
is False
)
# doc_inactive_only should now be linked to active user
assert (
models.DocumentFavorite.objects.filter(
user=user_2, document=doc_inactive_only
).count()
== 0
)
assert models.DocumentFavorite.objects.filter(
user=user_1, document=doc_inactive_only
).exists()
# doc_active_only should still belong to active user
assert models.DocumentFavorite.objects.filter(
user=user_1, document=doc_active_only
).exists()

View File

@@ -2,6 +2,8 @@
Unit tests for the User model
"""
from unittest import mock
from django.core.exceptions import ValidationError
import pytest
@@ -24,6 +26,26 @@ def test_models_users_id_unique():
factories.UserFactory(id=user.id)
def test_models_users_send_mail_main_existing():
"""The "email_user' method should send mail to the user's email address."""
user = factories.UserFactory()
with mock.patch("django.core.mail.send_mail") as mock_send:
user.email_user("my subject", "my message")
mock_send.assert_called_once_with("my subject", "my message", None, [user.email])
def test_models_users_send_mail_main_missing():
"""The "email_user' method should fail if the user has no email address."""
user = factories.UserFactory(email=None)
with pytest.raises(ValueError) as excinfo:
user.email_user("my subject", "my message")
assert str(excinfo.value) == "User has no email address."
@pytest.mark.parametrize(
"sub,is_valid",
[

View File

@@ -1,93 +0,0 @@
"""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

View File

@@ -6,7 +6,6 @@ from unittest.mock import MagicMock, patch
import pytest
import requests
from core.services import mime_types
from core.services.converter_services import (
ServiceUnavailableError,
ValidationError,
@@ -22,9 +21,9 @@ def test_auth_header(settings):
def test_convert_empty_text():
"""Should raise ValidationError when data is empty."""
"""Should raise ValidationError when text is empty."""
converter = YdocConverter()
with pytest.raises(ValidationError, match="Input data cannot be empty"):
with pytest.raises(ValidationError, match="Input text cannot be empty"):
converter.convert("")
@@ -37,7 +36,7 @@ def test_convert_service_unavailable(mock_post):
with pytest.raises(
ServiceUnavailableError,
match="Failed to connect to YDoc conversion service",
match="Failed to connect to conversion service",
):
converter.convert("test text")
@@ -53,7 +52,7 @@ def test_convert_http_error(mock_post):
with pytest.raises(
ServiceUnavailableError,
match="Failed to connect to YDoc conversion service",
match="Failed to connect to conversion service",
):
converter.convert("test text")
@@ -84,8 +83,8 @@ def test_convert_full_integration(mock_post, settings):
data="test markdown",
headers={
"Authorization": "Bearer test-key",
"Content-Type": mime_types.MARKDOWN,
"Accept": mime_types.YJS,
"Content-Type": "text/markdown",
"Accept": "application/vnd.yjs.doc",
},
timeout=5,
verify=False,
@@ -109,7 +108,9 @@ 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", mime_types.YJS, mime_types.MARKDOWN)
result = converter.convert(
b"test_content", "application/vnd.yjs.doc", "text/markdown"
)
assert result == expected_response
mock_post.assert_called_once_with(
@@ -117,8 +118,8 @@ def test_convert_full_integration_with_specific_headers(mock_post, settings):
data=b"test_content",
headers={
"Authorization": "Bearer test-key",
"Content-Type": mime_types.YJS,
"Accept": mime_types.MARKDOWN,
"Content-Type": "application/vnd.yjs.doc",
"Accept": "text/markdown",
},
timeout=5,
verify=False,
@@ -134,7 +135,7 @@ def test_convert_timeout(mock_post):
with pytest.raises(
ServiceUnavailableError,
match="Failed to connect to YDoc conversion service",
match="Failed to connect to conversion service",
):
converter.convert("test text")
@@ -143,5 +144,5 @@ def test_convert_none_input():
"""Should raise ValidationError when input is None."""
converter = YdocConverter()
with pytest.raises(ValidationError, match="Input data cannot be empty"):
with pytest.raises(ValidationError, match="Input text cannot be empty"):
converter.convert(None)

View File

@@ -1,117 +0,0 @@
"""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,
)

View File

@@ -3,14 +3,9 @@
import base64
import uuid
from django.core.cache import cache
import pycrdt
import pytest
from core import factories, utils
pytestmark = pytest.mark.django_db
from core import utils
# This base64 string is an example of what is saved in the database.
# This base64 is generated from the blocknote editor, it contains
@@ -105,103 +100,3 @@ def test_utils_get_ancestor_to_descendants_map_multiple_paths():
"000100020005": {"000100020005"},
"00010003": {"00010003"},
}
def test_utils_users_sharing_documents_with_cache_miss():
"""Test cache miss: should query database and cache result."""
user1 = factories.UserFactory()
user2 = factories.UserFactory()
user3 = factories.UserFactory()
doc1 = factories.DocumentFactory()
doc2 = factories.DocumentFactory()
factories.UserDocumentAccessFactory(user=user1, document=doc1)
factories.UserDocumentAccessFactory(user=user2, document=doc1)
factories.UserDocumentAccessFactory(user=user3, document=doc2)
cache_key = utils.get_users_sharing_documents_with_cache_key(user1)
cache.delete(cache_key)
result = utils.users_sharing_documents_with(user1)
assert user2.id in result
cached_data = cache.get(cache_key)
assert cached_data == result
def test_utils_users_sharing_documents_with_cache_hit():
"""Test cache hit: should return cached data without querying database."""
user1 = factories.UserFactory()
user2 = factories.UserFactory()
doc1 = factories.DocumentFactory()
factories.UserDocumentAccessFactory(user=user1, document=doc1)
factories.UserDocumentAccessFactory(user=user2, document=doc1)
cache_key = utils.get_users_sharing_documents_with_cache_key(user1)
test_cached_data = {user2.id: "2025-02-10"}
cache.set(cache_key, test_cached_data, 86400)
result = utils.users_sharing_documents_with(user1)
assert result == test_cached_data
def test_utils_users_sharing_documents_with_cache_invalidation_on_create():
"""Test that cache is invalidated when a DocumentAccess is created."""
# Create test data
user1 = factories.UserFactory()
user2 = factories.UserFactory()
doc1 = factories.DocumentFactory()
# Pre-populate cache
cache_key = utils.get_users_sharing_documents_with_cache_key(user1)
cache.set(cache_key, {}, 86400)
# Verify cache exists
assert cache.get(cache_key) is not None
# Create new DocumentAccess
factories.UserDocumentAccessFactory(user=user2, document=doc1)
# Cache should still exist (only created for user2 who was added)
# But if we create access for user1 being shared with, cache should be cleared
cache.set(cache_key, {"test": "data"}, 86400)
factories.UserDocumentAccessFactory(user=user1, document=doc1)
# Cache for user1 should be invalidated (cleared)
assert cache.get(cache_key) is None
def test_utils_users_sharing_documents_with_cache_invalidation_on_delete():
"""Test that cache is invalidated when a DocumentAccess is deleted."""
user1 = factories.UserFactory()
user2 = factories.UserFactory()
doc1 = factories.DocumentFactory()
doc_access = factories.UserDocumentAccessFactory(user=user1, document=doc1)
cache_key = utils.get_users_sharing_documents_with_cache_key(user1)
cache.set(cache_key, {user2.id: "2025-02-10"}, 86400)
assert cache.get(cache_key) is not None
doc_access.delete()
assert cache.get(cache_key) is None
def test_utils_users_sharing_documents_with_empty_result():
"""Test when user is not sharing any documents."""
user1 = factories.UserFactory()
cache_key = utils.get_users_sharing_documents_with_cache_key(user1)
cache.delete(cache_key)
result = utils.users_sharing_documents_with(user1)
assert result == {}
cached_data = cache.get(cache_key)
assert cached_data == {}

View File

@@ -1,62 +0,0 @@
"""Tests for utils.users_sharing_documents_with function."""
from django.utils import timezone
import pytest
from core import factories, utils
pytestmark = pytest.mark.django_db
def test_utils_users_sharing_documents_with():
"""Test users_sharing_documents_with function."""
user = factories.UserFactory(
email="martin.bernard@anct.gouv.fr", full_name="Martin Bernard"
)
pierre_1 = factories.UserFactory(
email="pierre.dupont@beta.gouv.fr", full_name="Pierre Dupont"
)
pierre_2 = factories.UserFactory(
email="pierre.durand@impots.gouv.fr", full_name="Pierre Durand"
)
now = timezone.now()
yesterday = now - timezone.timedelta(days=1)
last_week = now - timezone.timedelta(days=7)
last_month = now - timezone.timedelta(days=30)
document_1 = factories.DocumentFactory(creator=user)
document_2 = factories.DocumentFactory(creator=user)
document_3 = factories.DocumentFactory(creator=user)
factories.UserDocumentAccessFactory(user=user, document=document_1)
factories.UserDocumentAccessFactory(user=user, document=document_2)
factories.UserDocumentAccessFactory(user=user, document=document_3)
# The factory cannot set the created_at directly, so we force it after creation
doc_1_pierre_1 = factories.UserDocumentAccessFactory(
user=pierre_1, document=document_1, created_at=last_week
)
doc_1_pierre_1.created_at = last_week
doc_1_pierre_1.save()
doc_2_pierre_2 = factories.UserDocumentAccessFactory(
user=pierre_2, document=document_2
)
doc_2_pierre_2.created_at = last_month
doc_2_pierre_2.save()
doc_3_pierre_2 = factories.UserDocumentAccessFactory(
user=pierre_2, document=document_3
)
doc_3_pierre_2.created_at = yesterday
doc_3_pierre_2.save()
shared_map = utils.users_sharing_documents_with(user)
assert shared_map == {
pierre_1.id: last_week,
pierre_2.id: yesterday,
}

View File

@@ -10,6 +10,7 @@ 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")
@@ -59,10 +60,6 @@ urlpatterns = [
r"^documents/(?P<resource_id>[0-9a-z-]*)/threads/(?P<thread_id>[0-9a-z-]*)/",
include(thread_related_router.urls),
),
path(
"user-reconciliations/<str:user_type>/<uuid:confirmation_id>/",
viewsets.ReconciliationConfirmView.as_view(),
),
]
),
),

View File

@@ -1,21 +1,13 @@
"""Utils for the core app."""
import base64
import logging
import re
import time
from collections import defaultdict
from django.core.cache import cache
from django.db import models as db
from django.db.models import Subquery
import pycrdt
from bs4 import BeautifulSoup
from core import enums, models
logger = logging.getLogger(__name__)
from core import enums
def get_ancestor_to_descendants_map(paths, steplen):
@@ -104,46 +96,3 @@ def extract_attachments(content):
xml_content = base64_yjs_to_xml(content)
return re.findall(enums.MEDIA_STORAGE_URL_EXTRACT, xml_content)
def get_users_sharing_documents_with_cache_key(user):
"""Generate a unique cache key for each user."""
return f"users_sharing_documents_with_{user.id}"
def users_sharing_documents_with(user):
"""
Returns a map of users sharing documents with the given user,
sorted by last shared date.
"""
start_time = time.time()
cache_key = get_users_sharing_documents_with_cache_key(user)
cached_result = cache.get(cache_key)
if cached_result is not None:
elapsed = time.time() - start_time
logger.info(
"users_sharing_documents_with cache hit for user %s (took %.3fs)",
user.id,
elapsed,
)
return cached_result
user_docs_qs = models.DocumentAccess.objects.filter(user=user).values_list(
"document_id", flat=True
)
shared_qs = (
models.DocumentAccess.objects.filter(document_id__in=Subquery(user_docs_qs))
.exclude(user=user)
.values("user")
.annotate(last_shared=db.Max("created_at"))
)
result = {item["user"]: item["last_shared"] for item in shared_qs}
cache.set(cache_key, result, 86400) # Cache for 1 day
elapsed = time.time() - start_time
logger.info(
"users_sharing_documents_with cache miss for user %s (took %.3fs)",
user.id,
elapsed,
)
return result

View File

@@ -0,0 +1,2 @@
<img width="200" src="http://localhost:3000/assets/logo-gouv.png" />
<br/>

View File

View File

@@ -216,6 +216,29 @@ 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."""

View File

@@ -25,6 +25,7 @@ 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

View File

@@ -29,10 +29,6 @@ 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():
"""
@@ -169,15 +165,10 @@ 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 * MB, # 10MB
10 * (2**20), # 10MB
environ_name="DOCUMENT_IMAGE_MAX_SIZE",
environ_prefix=None,
)
@@ -415,6 +406,16 @@ 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",
@@ -452,7 +453,7 @@ class Base(Configuration):
"REDOC_DIST": "SIDECAR",
}
TRASHBIN_CUTOFF_DAYS = values.IntegerValue(
TRASHBIN_CUTOFF_DAYS = values.Value(
30, environ_name="TRASHBIN_CUTOFF_DAYS", environ_prefix=None
)
@@ -464,7 +465,6 @@ 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")
@@ -509,12 +509,7 @@ class Base(Configuration):
FRONTEND_CSS_URL = values.Value(
None, environ_name="FRONTEND_CSS_URL", environ_prefix=None
)
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",
@@ -556,16 +551,6 @@ 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",
@@ -711,16 +696,6 @@ 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",
@@ -731,22 +706,6 @@ 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",
@@ -842,11 +801,6 @@ class Base(Configuration):
environ_name="API_USERS_LIST_LIMIT",
environ_prefix=None,
)
API_USERS_SEARCH_QUERY_MIN_LENGTH = values.PositiveIntegerValue(
default=3,
environ_name="API_USERS_SEARCH_QUERY_MIN_LENGTH",
environ_prefix=None,
)
# Content Security Policy
# See https://content-security-policy.com/ for more information.
@@ -880,11 +834,6 @@ class Base(Configuration):
),
}
# User accounts management
USER_RECONCILIATION_FORM_URL = values.Value(
None, environ_name="USER_RECONCILIATION_FORM_URL", environ_prefix=None
)
# Marketing and communication settings
SIGNUP_NEW_USER_TO_MARKETING_EMAIL = values.BooleanValue(
False,
@@ -1098,20 +1047,15 @@ 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/0",
"redis://redis:6379/1",
environ_name="REDIS_URL",
environ_prefix=None,
),
@@ -1125,26 +1069,10 @@ class Production(Base):
},
"KEY_PREFIX": values.Value(
"docs",
environ_name="CACHES_DEFAULT_KEY_PREFIX",
environ_name="CACHES_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",
},
},
}

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-21 09:53+0000\n"
"PO-Revision-Date: 2026-01-28 20:12\n"
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-12-09 11: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:28 core/admin.py:28
#: build/lib/core/admin.py:36 core/admin.py:36
msgid "Personal info"
msgstr "Titouroù personel"
#: build/lib/core/admin.py:41 build/lib/core/admin.py:121 core/admin.py:41
#: core/admin.py:121
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
msgid "Permissions"
msgstr "Aotreoù"
#: build/lib/core/admin.py:53 core/admin.py:53
#: build/lib/core/admin.py:61 core/admin.py:61
msgid "Important dates"
msgstr "Deiziadoù a-bouez"
#: build/lib/core/admin.py:131 core/admin.py:131
#: build/lib/core/admin.py:147 core/admin.py:147
msgid "Tree structure"
msgstr "Gwezennadur"
@@ -50,32 +50,40 @@ msgstr "Kuzhet"
msgid "Favorite"
msgstr "Sinedoù"
#: build/lib/core/api/serializers.py:505 core/api/serializers.py:505
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
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:509 core/api/serializers.py:509
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
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:545 core/api/serializers.py:545
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
msgid "This field is required."
msgstr "Ar vaezienn-mañ a zo rekis."
#: build/lib/core/api/serializers.py:556 core/api/serializers.py:556
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr ""
#: build/lib/core/api/viewsets.py:1122 core/api/viewsets.py:1122
#: 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
#, 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"
@@ -135,259 +143,301 @@ msgstr "Kleiz"
msgid "Right"
msgstr "Dehoù"
#: build/lib/core/models.py:81 core/models.py:81
#: build/lib/core/models.py:80 core/models.py:80
msgid "id"
msgstr "id"
#: build/lib/core/models.py:82 core/models.py:82
#: build/lib/core/models.py:81 core/models.py:81
msgid "primary key for the record as UUID"
msgstr "alc'hwez kentañ evit an enrollañ evel UIID"
#: build/lib/core/models.py:88 core/models.py:88
#: build/lib/core/models.py:87 core/models.py:87
msgid "created on"
msgstr "krouet d'ar/al"
#: build/lib/core/models.py:89 core/models.py:89
#: build/lib/core/models.py:88 core/models.py:88
msgid "date and time at which a record was created"
msgstr "deiziad hag eurvezh krouidigezh an enrolladenn"
#: build/lib/core/models.py:94 core/models.py:94
#: build/lib/core/models.py:93 core/models.py:93
msgid "updated on"
msgstr "hizivaet d'ar/al"
#: build/lib/core/models.py:95 core/models.py:95
#: build/lib/core/models.py:94 core/models.py:94
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:131 core/models.py:131
#: build/lib/core/models.py:130 core/models.py:130
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:142 core/models.py:142
#: build/lib/core/models.py:141 core/models.py:141
msgid "sub"
msgstr "isstrollad"
#: build/lib/core/models.py:143 core/models.py:143
#: build/lib/core/models.py:142 core/models.py:142
msgid "Required. 255 characters or fewer. ASCII characters only."
msgstr ""
#: build/lib/core/models.py:151 core/models.py:151
#: build/lib/core/models.py:150 core/models.py:150
msgid "full name"
msgstr "anv klok"
#: build/lib/core/models.py:153 core/models.py:153
#: build/lib/core/models.py:152 core/models.py:152
msgid "short name"
msgstr "anv berr"
#: build/lib/core/models.py:156 core/models.py:156
#: build/lib/core/models.py:155 core/models.py:155
msgid "identity email address"
msgstr "postel identelezh"
#: build/lib/core/models.py:161 core/models.py:161
#: build/lib/core/models.py:160 core/models.py:160
msgid "admin email address"
msgstr "postel ar merour"
#: build/lib/core/models.py:168 core/models.py:168
#: build/lib/core/models.py:167 core/models.py:167
msgid "language"
msgstr "yezh"
#: build/lib/core/models.py:169 core/models.py:169
#: build/lib/core/models.py:168 core/models.py:168
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:177 core/models.py:177
#: build/lib/core/models.py:176 core/models.py:176
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:180 core/models.py:180
#: build/lib/core/models.py:179 core/models.py:179
msgid "device"
msgstr "trevnad"
#: build/lib/core/models.py:182 core/models.py:182
#: build/lib/core/models.py:181 core/models.py:181
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:185 core/models.py:185
#: build/lib/core/models.py:184 core/models.py:184
msgid "staff status"
msgstr "statud ar skipailh"
#: build/lib/core/models.py:187 core/models.py:187
#: build/lib/core/models.py:186 core/models.py:186
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:190 core/models.py:190
#: build/lib/core/models.py:189 core/models.py:189
msgid "active"
msgstr "oberiant"
#: build/lib/core/models.py:193 core/models.py:193
#: build/lib/core/models.py:192 core/models.py:192
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:205 core/models.py:205
#: build/lib/core/models.py:204 core/models.py:204
msgid "user"
msgstr "implijer"
#: build/lib/core/models.py:206 core/models.py:206
#: build/lib/core/models.py:205 core/models.py:205
msgid "users"
msgstr "implijerien"
#: build/lib/core/models.py:362 core/models.py:362
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
#: core/models.py:361 core/models.py:1430
msgid "title"
msgstr "titl"
#: build/lib/core/models.py:363 core/models.py:363
#: build/lib/core/models.py:362 core/models.py:362
msgid "excerpt"
msgstr "bomm"
#: build/lib/core/models.py:412 core/models.py:412
#: build/lib/core/models.py:411 core/models.py:411
msgid "Document"
msgstr "Restr"
#: build/lib/core/models.py:413 core/models.py:413
#: build/lib/core/models.py:412 core/models.py:412
msgid "Documents"
msgstr "Restroù"
#: build/lib/core/models.py:425 build/lib/core/models.py:828 core/models.py:425
#: core/models.py:828
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
#: core/models.py:824
msgid "Untitled Document"
msgstr "Restr hep titl"
#: build/lib/core/models.py:829 core/models.py:829
msgid "Open"
msgstr "Digeriñ"
#: build/lib/core/models.py:864 core/models.py:864
#: build/lib/core/models.py:859 core/models.py:859
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} en deus rannet ur restr ganeoc'h!"
#: build/lib/core/models.py:868 core/models.py:868
#: build/lib/core/models.py:863 core/models.py:863
#, 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:874 core/models.py:874
#: build/lib/core/models.py:869 core/models.py:869
#, 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:975 core/models.py:975
#: build/lib/core/models.py:969 core/models.py:969
msgid "Document/user link trace"
msgstr "Roud liamm ar restr/an implijer"
#: build/lib/core/models.py:976 core/models.py:976
#: build/lib/core/models.py:970 core/models.py:970
msgid "Document/user link traces"
msgstr "Roudoù liamm ar restr/an implijer"
#: build/lib/core/models.py:982 core/models.py:982
#: build/lib/core/models.py:976 core/models.py:976
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:1005 core/models.py:1005
#: build/lib/core/models.py:999 core/models.py:999
msgid "Document favorite"
msgstr "Restr muiañ-karet"
#: build/lib/core/models.py:1006 core/models.py:1006
#: build/lib/core/models.py:1000 core/models.py:1000
msgid "Document favorites"
msgstr "Restroù muiañ-karet"
#: build/lib/core/models.py:1012 core/models.py:1012
#: build/lib/core/models.py:1006 core/models.py:1006
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:1034 core/models.py:1034
#: build/lib/core/models.py:1028 core/models.py:1028
msgid "Document/user relation"
msgstr "Liamm restr/implijer"
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:1029 core/models.py:1029
msgid "Document/user relations"
msgstr "Liammoù restr/implijer"
#: build/lib/core/models.py:1041 core/models.py:1041
#: build/lib/core/models.py:1035 core/models.py:1035
msgid "This user is already in this document."
msgstr "An implijer-mañ a zo dija er restr-mañ."
#: build/lib/core/models.py:1047 core/models.py:1047
#: build/lib/core/models.py:1041 core/models.py:1041
msgid "This team is already in this document."
msgstr "Ar skipailh-mañ a zo dija en restr-mañ."
#: build/lib/core/models.py:1053 core/models.py:1053
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
#: core/models.py:1047 core/models.py:1516
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:1204 core/models.py:1204
#: build/lib/core/models.py:1198 core/models.py:1198
msgid "Document ask for access"
msgstr "Goulenn tizhout ar restr"
#: build/lib/core/models.py:1205 core/models.py:1205
#: build/lib/core/models.py:1199 core/models.py:1199
msgid "Document ask for accesses"
msgstr "Goulennoù tizhout ar restr"
#: build/lib/core/models.py:1211 core/models.py:1211
#: build/lib/core/models.py:1205 core/models.py:1205
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:1268 core/models.py:1268
#: build/lib/core/models.py:1262 core/models.py:1262
#, 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:1272 core/models.py:1272
#: build/lib/core/models.py:1266 core/models.py:1266
#, 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:1278 core/models.py:1278
#: build/lib/core/models.py:1272 core/models.py:1272
#, 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:1320 core/models.py:1320
#: build/lib/core/models.py:1314 core/models.py:1314
msgid "Thread"
msgstr ""
#: build/lib/core/models.py:1321 core/models.py:1321
#: build/lib/core/models.py:1315 core/models.py:1315
msgid "Threads"
msgstr ""
#: build/lib/core/models.py:1324 build/lib/core/models.py:1376
#: core/models.py:1324 core/models.py:1376
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
#: core/models.py:1318 core/models.py:1370
msgid "Anonymous"
msgstr ""
#: build/lib/core/models.py:1371 core/models.py:1371
#: build/lib/core/models.py:1365 core/models.py:1365
msgid "Comment"
msgstr ""
#: build/lib/core/models.py:1372 core/models.py:1372
#: build/lib/core/models.py:1366 core/models.py:1366
msgid "Comments"
msgstr ""
#: build/lib/core/models.py:1421 core/models.py:1421
#: build/lib/core/models.py:1415 core/models.py:1415
msgid "This emoji has already been reacted to this comment."
msgstr ""
#: build/lib/core/models.py:1425 core/models.py:1425
#: build/lib/core/models.py:1419 core/models.py:1419
msgid "Reaction"
msgstr ""
#: build/lib/core/models.py:1426 core/models.py:1426
#: build/lib/core/models.py:1420 core/models.py:1420
msgid "Reactions"
msgstr ""
#: build/lib/core/models.py:1436 core/models.py:1436
#: 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
msgid "email address"
msgstr "postel"
#: build/lib/core/models.py:1455 core/models.py:1455
#: build/lib/core/models.py:1606 core/models.py:1606
msgid "Document invitation"
msgstr "Pedadenn d'ur restr"
#: build/lib/core/models.py:1456 core/models.py:1456
#: build/lib/core/models.py:1607 core/models.py:1607
msgid "Document invitations"
msgstr "Pedadennoù d'ur restr"
#: build/lib/core/models.py:1476 core/models.py:1476
#: build/lib/core/models.py:1627 core/models.py:1627
msgid "This email is already associated to a registered user."
msgstr "Ar postel-mañ a zo liammet ouzh un implijer enskrivet."
@@ -396,12 +446,17 @@ msgstr "Ar postel-mañ a zo liammet ouzh un implijer enskrivet."
msgid "Logo email"
msgstr "Logo ar postel"
#: core/templates/mail/html/template.html:219
#: 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/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:226
#: core/templates/mail/html/template.html:224
#: core/templates/mail/text/template.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-21 09:53+0000\n"
"PO-Revision-Date: 2026-01-28 20:12\n"
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-12-09 11: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:28 core/admin.py:28
#: build/lib/core/admin.py:36 core/admin.py:36
msgid "Personal info"
msgstr "Persönliche Daten"
#: build/lib/core/admin.py:41 build/lib/core/admin.py:121 core/admin.py:41
#: core/admin.py:121
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
msgid "Permissions"
msgstr "Berechtigungen"
#: build/lib/core/admin.py:53 core/admin.py:53
#: build/lib/core/admin.py:61 core/admin.py:61
msgid "Important dates"
msgstr "Wichtige Daten"
#: build/lib/core/admin.py:131 core/admin.py:131
#: build/lib/core/admin.py:147 core/admin.py:147
msgid "Tree structure"
msgstr "Baumstruktur"
@@ -50,32 +50,40 @@ msgstr ""
msgid "Favorite"
msgstr "Favorit"
#: build/lib/core/api/serializers.py:505 core/api/serializers.py:505
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
msgid "A new document was created on your behalf!"
msgstr "Ein neues Dokument wurde in Ihrem Namen erstellt!"
#: build/lib/core/api/serializers.py:509 core/api/serializers.py:509
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
msgid "You have been granted ownership of a new document:"
msgstr "Sie sind Besitzer eines neuen Dokuments:"
#: build/lib/core/api/serializers.py:545 core/api/serializers.py:545
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
msgid "This field is required."
msgstr ""
#: build/lib/core/api/serializers.py:556 core/api/serializers.py:556
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr ""
#: build/lib/core/api/viewsets.py:1122 core/api/viewsets.py:1122
#: 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
#, 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"
@@ -135,259 +143,301 @@ msgstr "Links"
msgid "Right"
msgstr "Rechts"
#: build/lib/core/models.py:81 core/models.py:81
#: build/lib/core/models.py:80 core/models.py:80
msgid "id"
msgstr "id"
#: build/lib/core/models.py:82 core/models.py:82
#: build/lib/core/models.py:81 core/models.py:81
msgid "primary key for the record as UUID"
msgstr "primärer Schlüssel für den Datensatz als UUID"
#: build/lib/core/models.py:88 core/models.py:88
#: build/lib/core/models.py:87 core/models.py:87
msgid "created on"
msgstr "Erstellt"
#: build/lib/core/models.py:89 core/models.py:89
#: build/lib/core/models.py:88 core/models.py:88
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:94 core/models.py:94
#: build/lib/core/models.py:93 core/models.py:93
msgid "updated on"
msgstr "Aktualisiert"
#: build/lib/core/models.py:95 core/models.py:95
#: build/lib/core/models.py:94 core/models.py:94
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:131 core/models.py:131
#: build/lib/core/models.py:130 core/models.py:130
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:142 core/models.py:142
#: build/lib/core/models.py:141 core/models.py:141
msgid "sub"
msgstr "unter"
#: build/lib/core/models.py:143 core/models.py:143
#: build/lib/core/models.py:142 core/models.py:142
msgid "Required. 255 characters or fewer. ASCII characters only."
msgstr ""
#: build/lib/core/models.py:151 core/models.py:151
#: build/lib/core/models.py:150 core/models.py:150
msgid "full name"
msgstr "Name"
#: build/lib/core/models.py:153 core/models.py:153
#: build/lib/core/models.py:152 core/models.py:152
msgid "short name"
msgstr "Kurzbezeichnung"
#: build/lib/core/models.py:156 core/models.py:156
#: build/lib/core/models.py:155 core/models.py:155
msgid "identity email address"
msgstr "Identitäts-E-Mail-Adresse"
#: build/lib/core/models.py:161 core/models.py:161
#: build/lib/core/models.py:160 core/models.py:160
msgid "admin email address"
msgstr "Admin E-Mail-Adresse"
#: build/lib/core/models.py:168 core/models.py:168
#: build/lib/core/models.py:167 core/models.py:167
msgid "language"
msgstr "Sprache"
#: build/lib/core/models.py:169 core/models.py:169
#: build/lib/core/models.py:168 core/models.py:168
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:177 core/models.py:177
#: build/lib/core/models.py:176 core/models.py:176
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:180 core/models.py:180
#: build/lib/core/models.py:179 core/models.py:179
msgid "device"
msgstr "Gerät"
#: build/lib/core/models.py:182 core/models.py:182
#: build/lib/core/models.py:181 core/models.py:181
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:185 core/models.py:185
#: build/lib/core/models.py:184 core/models.py:184
msgid "staff status"
msgstr "Status des Teammitgliedes"
#: build/lib/core/models.py:187 core/models.py:187
#: build/lib/core/models.py:186 core/models.py:186
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:190 core/models.py:190
#: build/lib/core/models.py:189 core/models.py:189
msgid "active"
msgstr "aktiviert"
#: build/lib/core/models.py:193 core/models.py:193
#: build/lib/core/models.py:192 core/models.py:192
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:205 core/models.py:205
#: build/lib/core/models.py:204 core/models.py:204
msgid "user"
msgstr "Benutzer"
#: build/lib/core/models.py:206 core/models.py:206
#: build/lib/core/models.py:205 core/models.py:205
msgid "users"
msgstr "Benutzer"
#: build/lib/core/models.py:362 core/models.py:362
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
#: core/models.py:361 core/models.py:1430
msgid "title"
msgstr "Titel"
#: build/lib/core/models.py:363 core/models.py:363
#: build/lib/core/models.py:362 core/models.py:362
msgid "excerpt"
msgstr "Auszug"
#: build/lib/core/models.py:412 core/models.py:412
#: build/lib/core/models.py:411 core/models.py:411
msgid "Document"
msgstr "Dokument"
#: build/lib/core/models.py:413 core/models.py:413
#: build/lib/core/models.py:412 core/models.py:412
msgid "Documents"
msgstr "Dokumente"
#: build/lib/core/models.py:425 build/lib/core/models.py:828 core/models.py:425
#: core/models.py:828
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
#: core/models.py:824
msgid "Untitled Document"
msgstr "Unbenanntes Dokument"
#: build/lib/core/models.py:829 core/models.py:829
msgid "Open"
msgstr "Öffnen"
#: build/lib/core/models.py:864 core/models.py:864
#: build/lib/core/models.py:859 core/models.py:859
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} hat ein Dokument mit Ihnen geteilt!"
#: build/lib/core/models.py:868 core/models.py:868
#: build/lib/core/models.py:863 core/models.py:863
#, 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:874 core/models.py:874
#: build/lib/core/models.py:869 core/models.py:869
#, 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:975 core/models.py:975
#: build/lib/core/models.py:969 core/models.py:969
msgid "Document/user link trace"
msgstr "Dokument/Benutzer Linkverfolgung"
#: build/lib/core/models.py:976 core/models.py:976
#: build/lib/core/models.py:970 core/models.py:970
msgid "Document/user link traces"
msgstr "Dokument/Benutzer Linkverfolgung"
#: build/lib/core/models.py:982 core/models.py:982
#: build/lib/core/models.py:976 core/models.py:976
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:1005 core/models.py:1005
#: build/lib/core/models.py:999 core/models.py:999
msgid "Document favorite"
msgstr "Dokumentenfavorit"
#: build/lib/core/models.py:1006 core/models.py:1006
#: build/lib/core/models.py:1000 core/models.py:1000
msgid "Document favorites"
msgstr "Dokumentfavoriten"
#: build/lib/core/models.py:1012 core/models.py:1012
#: build/lib/core/models.py:1006 core/models.py:1006
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:1034 core/models.py:1034
#: build/lib/core/models.py:1028 core/models.py:1028
msgid "Document/user relation"
msgstr "Dokument/Benutzerbeziehung"
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:1029 core/models.py:1029
msgid "Document/user relations"
msgstr "Dokument/Benutzerbeziehungen"
#: build/lib/core/models.py:1041 core/models.py:1041
#: build/lib/core/models.py:1035 core/models.py:1035
msgid "This user is already in this document."
msgstr "Dieser Benutzer befindet sich bereits in diesem Dokument."
#: build/lib/core/models.py:1047 core/models.py:1047
#: build/lib/core/models.py:1041 core/models.py:1041
msgid "This team is already in this document."
msgstr "Dieses Team befindet sich bereits in diesem Dokument."
#: build/lib/core/models.py:1053 core/models.py:1053
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
#: core/models.py:1047 core/models.py:1516
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:1204 core/models.py:1204
#: build/lib/core/models.py:1198 core/models.py:1198
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1205 core/models.py:1205
#: build/lib/core/models.py:1199 core/models.py:1199
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1211 core/models.py:1211
#: build/lib/core/models.py:1205 core/models.py:1205
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1268 core/models.py:1268
#: build/lib/core/models.py:1262 core/models.py:1262
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1272 core/models.py:1272
#: build/lib/core/models.py:1266 core/models.py:1266
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1278 core/models.py:1278
#: build/lib/core/models.py:1272 core/models.py:1272
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1320 core/models.py:1320
#: build/lib/core/models.py:1314 core/models.py:1314
msgid "Thread"
msgstr ""
#: build/lib/core/models.py:1321 core/models.py:1321
#: build/lib/core/models.py:1315 core/models.py:1315
msgid "Threads"
msgstr ""
#: build/lib/core/models.py:1324 build/lib/core/models.py:1376
#: core/models.py:1324 core/models.py:1376
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
#: core/models.py:1318 core/models.py:1370
msgid "Anonymous"
msgstr ""
#: build/lib/core/models.py:1371 core/models.py:1371
#: build/lib/core/models.py:1365 core/models.py:1365
msgid "Comment"
msgstr ""
#: build/lib/core/models.py:1372 core/models.py:1372
#: build/lib/core/models.py:1366 core/models.py:1366
msgid "Comments"
msgstr ""
#: build/lib/core/models.py:1421 core/models.py:1421
#: build/lib/core/models.py:1415 core/models.py:1415
msgid "This emoji has already been reacted to this comment."
msgstr ""
#: build/lib/core/models.py:1425 core/models.py:1425
#: build/lib/core/models.py:1419 core/models.py:1419
msgid "Reaction"
msgstr ""
#: build/lib/core/models.py:1426 core/models.py:1426
#: build/lib/core/models.py:1420 core/models.py:1420
msgid "Reactions"
msgstr ""
#: build/lib/core/models.py:1436 core/models.py:1436
#: 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
msgid "email address"
msgstr "E-Mail-Adresse"
#: build/lib/core/models.py:1455 core/models.py:1455
#: build/lib/core/models.py:1606 core/models.py:1606
msgid "Document invitation"
msgstr "Einladung zum Dokument"
#: build/lib/core/models.py:1456 core/models.py:1456
#: build/lib/core/models.py:1607 core/models.py:1607
msgid "Document invitations"
msgstr "Dokumenteinladungen"
#: build/lib/core/models.py:1476 core/models.py:1476
#: build/lib/core/models.py:1627 core/models.py:1627
msgid "This email is already associated to a registered user."
msgstr "Diese E-Mail ist bereits einem registrierten Benutzer zugeordnet."
@@ -396,12 +446,17 @@ msgstr "Diese E-Mail ist bereits einem registrierten Benutzer zugeordnet."
msgid "Logo email"
msgstr "Logo-E-Mail"
#: core/templates/mail/html/template.html:219
#: 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/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:226
#: core/templates/mail/html/template.html:224
#: core/templates/mail/text/template.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-21 09:53+0000\n"
"PO-Revision-Date: 2026-01-28 20:12\n"
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-12-09 11: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:28 core/admin.py:28
#: build/lib/core/admin.py:36 core/admin.py:36
msgid "Personal info"
msgstr ""
#: build/lib/core/admin.py:41 build/lib/core/admin.py:121 core/admin.py:41
#: core/admin.py:121
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
msgid "Permissions"
msgstr ""
#: build/lib/core/admin.py:53 core/admin.py:53
#: build/lib/core/admin.py:61 core/admin.py:61
msgid "Important dates"
msgstr ""
#: build/lib/core/admin.py:131 core/admin.py:131
#: build/lib/core/admin.py:147 core/admin.py:147
msgid "Tree structure"
msgstr ""
@@ -50,30 +50,38 @@ msgstr ""
msgid "Favorite"
msgstr ""
#: build/lib/core/api/serializers.py:505 core/api/serializers.py:505
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
msgid "A new document was created on your behalf!"
msgstr ""
#: build/lib/core/api/serializers.py:509 core/api/serializers.py:509
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
msgid "You have been granted ownership of a new document:"
msgstr ""
#: build/lib/core/api/serializers.py:545 core/api/serializers.py:545
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
msgid "This field is required."
msgstr ""
#: build/lib/core/api/serializers.py:556 core/api/serializers.py:556
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr ""
#: build/lib/core/api/viewsets.py:1122 core/api/viewsets.py:1122
#, python-brace-format
msgid "copy of {title}"
#: build/lib/core/api/serializers.py:694 core/api/serializers.py:694
msgid "Body"
msgstr ""
#: build/lib/core/apps.py:12 core/apps.py:12
msgid "Impress core application"
#: 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
#, python-brace-format
msgid "copy of {title}"
msgstr ""
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
@@ -135,259 +143,301 @@ msgstr ""
msgid "Right"
msgstr ""
#: build/lib/core/models.py:81 core/models.py:81
#: build/lib/core/models.py:80 core/models.py:80
msgid "id"
msgstr ""
#: build/lib/core/models.py:82 core/models.py:82
#: build/lib/core/models.py:81 core/models.py:81
msgid "primary key for the record as UUID"
msgstr ""
#: build/lib/core/models.py:88 core/models.py:88
#: build/lib/core/models.py:87 core/models.py:87
msgid "created on"
msgstr ""
#: build/lib/core/models.py:89 core/models.py:89
#: build/lib/core/models.py:88 core/models.py:88
msgid "date and time at which a record was created"
msgstr ""
#: build/lib/core/models.py:94 core/models.py:94
#: build/lib/core/models.py:93 core/models.py:93
msgid "updated on"
msgstr ""
#: build/lib/core/models.py:95 core/models.py:95
#: build/lib/core/models.py:94 core/models.py:94
msgid "date and time at which a record was last updated"
msgstr ""
#: build/lib/core/models.py:131 core/models.py:131
#: build/lib/core/models.py:130 core/models.py:130
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:142 core/models.py:142
#: build/lib/core/models.py:141 core/models.py:141
msgid "sub"
msgstr ""
#: build/lib/core/models.py:143 core/models.py:143
#: build/lib/core/models.py:142 core/models.py:142
msgid "Required. 255 characters or fewer. ASCII characters only."
msgstr ""
#: build/lib/core/models.py:151 core/models.py:151
#: build/lib/core/models.py:150 core/models.py:150
msgid "full name"
msgstr ""
#: build/lib/core/models.py:153 core/models.py:153
#: build/lib/core/models.py:152 core/models.py:152
msgid "short name"
msgstr ""
#: build/lib/core/models.py:156 core/models.py:156
#: build/lib/core/models.py:155 core/models.py:155
msgid "identity email address"
msgstr ""
#: build/lib/core/models.py:161 core/models.py:161
#: build/lib/core/models.py:160 core/models.py:160
msgid "admin email address"
msgstr ""
#: build/lib/core/models.py:168 core/models.py:168
#: build/lib/core/models.py:167 core/models.py:167
msgid "language"
msgstr ""
#: build/lib/core/models.py:169 core/models.py:169
#: build/lib/core/models.py:168 core/models.py:168
msgid "The language in which the user wants to see the interface."
msgstr ""
#: build/lib/core/models.py:177 core/models.py:177
#: build/lib/core/models.py:176 core/models.py:176
msgid "The timezone in which the user wants to see times."
msgstr ""
#: build/lib/core/models.py:180 core/models.py:180
#: build/lib/core/models.py:179 core/models.py:179
msgid "device"
msgstr ""
#: build/lib/core/models.py:182 core/models.py:182
#: build/lib/core/models.py:181 core/models.py:181
msgid "Whether the user is a device or a real user."
msgstr ""
#: build/lib/core/models.py:185 core/models.py:185
#: build/lib/core/models.py:184 core/models.py:184
msgid "staff status"
msgstr ""
#: build/lib/core/models.py:187 core/models.py:187
#: build/lib/core/models.py:186 core/models.py:186
msgid "Whether the user can log into this admin site."
msgstr ""
#: build/lib/core/models.py:190 core/models.py:190
#: build/lib/core/models.py:189 core/models.py:189
msgid "active"
msgstr ""
#: build/lib/core/models.py:193 core/models.py:193
#: build/lib/core/models.py:192 core/models.py:192
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: build/lib/core/models.py:205 core/models.py:205
#: build/lib/core/models.py:204 core/models.py:204
msgid "user"
msgstr ""
#: build/lib/core/models.py:206 core/models.py:206
#: build/lib/core/models.py:205 core/models.py:205
msgid "users"
msgstr ""
#: build/lib/core/models.py:362 core/models.py:362
#: 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:363 core/models.py:363
#: build/lib/core/models.py:362 core/models.py:362
msgid "excerpt"
msgstr ""
#: build/lib/core/models.py:412 core/models.py:412
#: build/lib/core/models.py:411 core/models.py:411
msgid "Document"
msgstr ""
#: build/lib/core/models.py:413 core/models.py:413
#: build/lib/core/models.py:412 core/models.py:412
msgid "Documents"
msgstr ""
#: build/lib/core/models.py:425 build/lib/core/models.py:828 core/models.py:425
#: core/models.py:828
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
#: core/models.py:824
msgid "Untitled Document"
msgstr ""
#: build/lib/core/models.py:829 core/models.py:829
msgid "Open"
msgstr ""
#: build/lib/core/models.py:864 core/models.py:864
#: build/lib/core/models.py:859 core/models.py:859
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
#: build/lib/core/models.py:868 core/models.py:868
#: build/lib/core/models.py:863 core/models.py:863
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr ""
#: build/lib/core/models.py:874 core/models.py:874
#: build/lib/core/models.py:869 core/models.py:869
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr ""
#: build/lib/core/models.py:975 core/models.py:975
#: build/lib/core/models.py:969 core/models.py:969
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:976 core/models.py:976
#: build/lib/core/models.py:970 core/models.py:970
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:982 core/models.py:982
#: build/lib/core/models.py:976 core/models.py:976
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:1005 core/models.py:1005
#: build/lib/core/models.py:999 core/models.py:999
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:1006 core/models.py:1006
#: build/lib/core/models.py:1000 core/models.py:1000
msgid "Document favorites"
msgstr ""
#: build/lib/core/models.py:1012 core/models.py:1012
#: build/lib/core/models.py:1006 core/models.py:1006
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:1034 core/models.py:1034
#: build/lib/core/models.py:1028 core/models.py:1028
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:1029 core/models.py:1029
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:1041 core/models.py:1041
#: build/lib/core/models.py:1035 core/models.py:1035
msgid "This user is already in this document."
msgstr ""
#: build/lib/core/models.py:1047 core/models.py:1047
#: build/lib/core/models.py:1041 core/models.py:1041
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:1053 core/models.py:1053
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
#: core/models.py:1047 core/models.py:1516
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:1204 core/models.py:1204
#: build/lib/core/models.py:1198 core/models.py:1198
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1205 core/models.py:1205
#: build/lib/core/models.py:1199 core/models.py:1199
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1211 core/models.py:1211
#: build/lib/core/models.py:1205 core/models.py:1205
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1268 core/models.py:1268
#: build/lib/core/models.py:1262 core/models.py:1262
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1272 core/models.py:1272
#: build/lib/core/models.py:1266 core/models.py:1266
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1278 core/models.py:1278
#: build/lib/core/models.py:1272 core/models.py:1272
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1320 core/models.py:1320
#: build/lib/core/models.py:1314 core/models.py:1314
msgid "Thread"
msgstr ""
#: build/lib/core/models.py:1321 core/models.py:1321
#: build/lib/core/models.py:1315 core/models.py:1315
msgid "Threads"
msgstr ""
#: build/lib/core/models.py:1324 build/lib/core/models.py:1376
#: core/models.py:1324 core/models.py:1376
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
#: core/models.py:1318 core/models.py:1370
msgid "Anonymous"
msgstr ""
#: build/lib/core/models.py:1371 core/models.py:1371
#: build/lib/core/models.py:1365 core/models.py:1365
msgid "Comment"
msgstr ""
#: build/lib/core/models.py:1372 core/models.py:1372
#: build/lib/core/models.py:1366 core/models.py:1366
msgid "Comments"
msgstr ""
#: build/lib/core/models.py:1421 core/models.py:1421
#: build/lib/core/models.py:1415 core/models.py:1415
msgid "This emoji has already been reacted to this comment."
msgstr ""
#: build/lib/core/models.py:1425 core/models.py:1425
#: build/lib/core/models.py:1419 core/models.py:1419
msgid "Reaction"
msgstr ""
#: build/lib/core/models.py:1426 core/models.py:1426
#: build/lib/core/models.py:1420 core/models.py:1420
msgid "Reactions"
msgstr ""
#: build/lib/core/models.py:1436 core/models.py:1436
#: 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
msgid "email address"
msgstr ""
#: build/lib/core/models.py:1455 core/models.py:1455
#: build/lib/core/models.py:1606 core/models.py:1606
msgid "Document invitation"
msgstr ""
#: build/lib/core/models.py:1456 core/models.py:1456
#: build/lib/core/models.py:1607 core/models.py:1607
msgid "Document invitations"
msgstr ""
#: build/lib/core/models.py:1476 core/models.py:1476
#: build/lib/core/models.py:1627 core/models.py:1627
msgid "This email is already associated to a registered user."
msgstr ""
@@ -396,12 +446,17 @@ msgstr ""
msgid "Logo email"
msgstr ""
#: core/templates/mail/html/template.html:219
#: 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/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:226
#: core/templates/mail/html/template.html:224
#: core/templates/mail/text/template.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-21 09:53+0000\n"
"PO-Revision-Date: 2026-01-28 20:12\n"
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-12-09 11: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:28 core/admin.py:28
#: build/lib/core/admin.py:36 core/admin.py:36
msgid "Personal info"
msgstr "Información Personal"
#: build/lib/core/admin.py:41 build/lib/core/admin.py:121 core/admin.py:41
#: core/admin.py:121
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
msgid "Permissions"
msgstr "Permisos"
#: build/lib/core/admin.py:53 core/admin.py:53
#: build/lib/core/admin.py:61 core/admin.py:61
msgid "Important dates"
msgstr "Fechas importantes"
#: build/lib/core/admin.py:131 core/admin.py:131
#: build/lib/core/admin.py:147 core/admin.py:147
msgid "Tree structure"
msgstr "Estructura en árbol"
@@ -50,32 +50,40 @@ msgstr ""
msgid "Favorite"
msgstr "Favorito"
#: build/lib/core/api/serializers.py:505 core/api/serializers.py:505
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
msgid "A new document was created on your behalf!"
msgstr "¡Un nuevo documento se ha creado por ti!"
#: build/lib/core/api/serializers.py:509 core/api/serializers.py:509
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
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:545 core/api/serializers.py:545
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
msgid "This field is required."
msgstr ""
#: build/lib/core/api/serializers.py:556 core/api/serializers.py:556
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr ""
#: build/lib/core/api/viewsets.py:1122 core/api/viewsets.py:1122
#: 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
#, 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"
@@ -135,259 +143,301 @@ msgstr "Izquierda"
msgid "Right"
msgstr "Derecha"
#: build/lib/core/models.py:81 core/models.py:81
#: build/lib/core/models.py:80 core/models.py:80
msgid "id"
msgstr "id"
#: build/lib/core/models.py:82 core/models.py:82
#: build/lib/core/models.py:81 core/models.py:81
msgid "primary key for the record as UUID"
msgstr "clave primaria para el registro como UUID"
#: build/lib/core/models.py:88 core/models.py:88
#: build/lib/core/models.py:87 core/models.py:87
msgid "created on"
msgstr "creado el"
#: build/lib/core/models.py:89 core/models.py:89
#: build/lib/core/models.py:88 core/models.py:88
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:94 core/models.py:94
#: build/lib/core/models.py:93 core/models.py:93
msgid "updated on"
msgstr "actualizado el"
#: build/lib/core/models.py:95 core/models.py:95
#: build/lib/core/models.py:94 core/models.py:94
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:131 core/models.py:131
#: build/lib/core/models.py:130 core/models.py:130
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:142 core/models.py:142
#: build/lib/core/models.py:141 core/models.py:141
msgid "sub"
msgstr "sub (UUID)"
#: build/lib/core/models.py:143 core/models.py:143
#: build/lib/core/models.py:142 core/models.py:142
msgid "Required. 255 characters or fewer. ASCII characters only."
msgstr "Obligatorio. 255 caracteres o menos. Solo caracteres ASCII."
#: build/lib/core/models.py:151 core/models.py:151
#: build/lib/core/models.py:150 core/models.py:150
msgid "full name"
msgstr "nombre completo"
#: build/lib/core/models.py:153 core/models.py:153
#: build/lib/core/models.py:152 core/models.py:152
msgid "short name"
msgstr "nombre abreviado"
#: build/lib/core/models.py:156 core/models.py:156
#: build/lib/core/models.py:155 core/models.py:155
msgid "identity email address"
msgstr "correo electrónico de identidad"
#: build/lib/core/models.py:161 core/models.py:161
#: build/lib/core/models.py:160 core/models.py:160
msgid "admin email address"
msgstr "correo electrónico del administrador"
#: build/lib/core/models.py:168 core/models.py:168
#: build/lib/core/models.py:167 core/models.py:167
msgid "language"
msgstr "idioma"
#: build/lib/core/models.py:169 core/models.py:169
#: build/lib/core/models.py:168 core/models.py:168
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:177 core/models.py:177
#: build/lib/core/models.py:176 core/models.py:176
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:180 core/models.py:180
#: build/lib/core/models.py:179 core/models.py:179
msgid "device"
msgstr "dispositivo"
#: build/lib/core/models.py:182 core/models.py:182
#: build/lib/core/models.py:181 core/models.py:181
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:185 core/models.py:185
#: build/lib/core/models.py:184 core/models.py:184
msgid "staff status"
msgstr "rol en el equipo"
#: build/lib/core/models.py:187 core/models.py:187
#: build/lib/core/models.py:186 core/models.py:186
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:190 core/models.py:190
#: build/lib/core/models.py:189 core/models.py:189
msgid "active"
msgstr "activo"
#: build/lib/core/models.py:193 core/models.py:193
#: build/lib/core/models.py:192 core/models.py:192
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:205 core/models.py:205
#: build/lib/core/models.py:204 core/models.py:204
msgid "user"
msgstr "usuario"
#: build/lib/core/models.py:206 core/models.py:206
#: build/lib/core/models.py:205 core/models.py:205
msgid "users"
msgstr "usuarios"
#: build/lib/core/models.py:362 core/models.py:362
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
#: core/models.py:361 core/models.py:1430
msgid "title"
msgstr "título"
#: build/lib/core/models.py:363 core/models.py:363
#: build/lib/core/models.py:362 core/models.py:362
msgid "excerpt"
msgstr "resumen"
#: build/lib/core/models.py:412 core/models.py:412
#: build/lib/core/models.py:411 core/models.py:411
msgid "Document"
msgstr "Documento"
#: build/lib/core/models.py:413 core/models.py:413
#: build/lib/core/models.py:412 core/models.py:412
msgid "Documents"
msgstr "Documentos"
#: build/lib/core/models.py:425 build/lib/core/models.py:828 core/models.py:425
#: core/models.py:828
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
#: core/models.py:824
msgid "Untitled Document"
msgstr "Documento sin título"
#: build/lib/core/models.py:829 core/models.py:829
msgid "Open"
msgstr "Abrir"
#: build/lib/core/models.py:864 core/models.py:864
#: build/lib/core/models.py:859 core/models.py:859
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "¡{name} ha compartido un documento contigo!"
#: build/lib/core/models.py:868 core/models.py:868
#: build/lib/core/models.py:863 core/models.py:863
#, 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:874 core/models.py:874
#: build/lib/core/models.py:869 core/models.py:869
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} ha compartido un documento contigo: {title}"
#: build/lib/core/models.py:975 core/models.py:975
#: build/lib/core/models.py:969 core/models.py:969
msgid "Document/user link trace"
msgstr "Traza del enlace de documento/usuario"
#: build/lib/core/models.py:976 core/models.py:976
#: build/lib/core/models.py:970 core/models.py:970
msgid "Document/user link traces"
msgstr "Trazas del enlace de documento/usuario"
#: build/lib/core/models.py:982 core/models.py:982
#: build/lib/core/models.py:976 core/models.py:976
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:1005 core/models.py:1005
#: build/lib/core/models.py:999 core/models.py:999
msgid "Document favorite"
msgstr "Documento favorito"
#: build/lib/core/models.py:1006 core/models.py:1006
#: build/lib/core/models.py:1000 core/models.py:1000
msgid "Document favorites"
msgstr "Documentos favoritos"
#: build/lib/core/models.py:1012 core/models.py:1012
#: build/lib/core/models.py:1006 core/models.py:1006
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:1034 core/models.py:1034
#: build/lib/core/models.py:1028 core/models.py:1028
msgid "Document/user relation"
msgstr "Relación documento/usuario"
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:1029 core/models.py:1029
msgid "Document/user relations"
msgstr "Relaciones documento/usuario"
#: build/lib/core/models.py:1041 core/models.py:1041
#: build/lib/core/models.py:1035 core/models.py:1035
msgid "This user is already in this document."
msgstr "Este usuario ya forma parte del documento."
#: build/lib/core/models.py:1047 core/models.py:1047
#: build/lib/core/models.py:1041 core/models.py:1041
msgid "This team is already in this document."
msgstr "Este equipo ya forma parte del documento."
#: build/lib/core/models.py:1053 core/models.py:1053
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
#: core/models.py:1047 core/models.py:1516
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:1204 core/models.py:1204
#: build/lib/core/models.py:1198 core/models.py:1198
msgid "Document ask for access"
msgstr "Solicitud de acceso"
#: build/lib/core/models.py:1205 core/models.py:1205
#: build/lib/core/models.py:1199 core/models.py:1199
msgid "Document ask for accesses"
msgstr "Solicitud de accesos"
#: build/lib/core/models.py:1211 core/models.py:1211
#: build/lib/core/models.py:1205 core/models.py:1205
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:1268 core/models.py:1268
#: build/lib/core/models.py:1262 core/models.py:1262
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "¡{name} desea acceder a un documento!"
#: build/lib/core/models.py:1272 core/models.py:1272
#: build/lib/core/models.py:1266 core/models.py:1266
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr "{name} desea acceso al siguiente documento:"
#: build/lib/core/models.py:1278 core/models.py:1278
#: build/lib/core/models.py:1272 core/models.py:1272
#, 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:1320 core/models.py:1320
#: build/lib/core/models.py:1314 core/models.py:1314
msgid "Thread"
msgstr ""
#: build/lib/core/models.py:1321 core/models.py:1321
#: build/lib/core/models.py:1315 core/models.py:1315
msgid "Threads"
msgstr ""
#: build/lib/core/models.py:1324 build/lib/core/models.py:1376
#: core/models.py:1324 core/models.py:1376
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
#: core/models.py:1318 core/models.py:1370
msgid "Anonymous"
msgstr ""
#: build/lib/core/models.py:1371 core/models.py:1371
#: build/lib/core/models.py:1365 core/models.py:1365
msgid "Comment"
msgstr ""
#: build/lib/core/models.py:1372 core/models.py:1372
#: build/lib/core/models.py:1366 core/models.py:1366
msgid "Comments"
msgstr ""
#: build/lib/core/models.py:1421 core/models.py:1421
#: build/lib/core/models.py:1415 core/models.py:1415
msgid "This emoji has already been reacted to this comment."
msgstr ""
#: build/lib/core/models.py:1425 core/models.py:1425
#: build/lib/core/models.py:1419 core/models.py:1419
msgid "Reaction"
msgstr ""
#: build/lib/core/models.py:1426 core/models.py:1426
#: build/lib/core/models.py:1420 core/models.py:1420
msgid "Reactions"
msgstr ""
#: build/lib/core/models.py:1436 core/models.py:1436
#: 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
msgid "email address"
msgstr "dirección de correo electrónico"
#: build/lib/core/models.py:1455 core/models.py:1455
#: build/lib/core/models.py:1606 core/models.py:1606
msgid "Document invitation"
msgstr "Invitación al documento"
#: build/lib/core/models.py:1456 core/models.py:1456
#: build/lib/core/models.py:1607 core/models.py:1607
msgid "Document invitations"
msgstr "Invitaciones a documentos"
#: build/lib/core/models.py:1476 core/models.py:1476
#: build/lib/core/models.py:1627 core/models.py:1627
msgid "This email is already associated to a registered user."
msgstr "Este correo electrónico está asociado a un usuario registrado."
@@ -396,12 +446,17 @@ 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:219
#: 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/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:226
#: core/templates/mail/html/template.html:224
#: core/templates/mail/text/template.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-21 09:53+0000\n"
"PO-Revision-Date: 2026-01-28 20:12\n"
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-12-09 11: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:28 core/admin.py:28
#: build/lib/core/admin.py:36 core/admin.py:36
msgid "Personal info"
msgstr "Infos Personnelles"
#: build/lib/core/admin.py:41 build/lib/core/admin.py:121 core/admin.py:41
#: core/admin.py:121
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
msgid "Permissions"
msgstr "Permissions"
#: build/lib/core/admin.py:53 core/admin.py:53
#: build/lib/core/admin.py:61 core/admin.py:61
msgid "Important dates"
msgstr "Dates importantes"
#: build/lib/core/admin.py:131 core/admin.py:131
#: build/lib/core/admin.py:147 core/admin.py:147
msgid "Tree structure"
msgstr "Arborescence"
@@ -50,32 +50,40 @@ msgstr "Masqué"
msgid "Favorite"
msgstr "Favoris"
#: build/lib/core/api/serializers.py:505 core/api/serializers.py:505
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
msgid "A new document was created on your behalf!"
msgstr "Un nouveau document a été créé pour vous !"
#: build/lib/core/api/serializers.py:509 core/api/serializers.py:509
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
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:545 core/api/serializers.py:545
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
msgid "This field is required."
msgstr "Ce champ est obligatoire."
#: build/lib/core/api/serializers.py:556 core/api/serializers.py:556
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
#, 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/viewsets.py:1122 core/api/viewsets.py:1122
#: 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
#, 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"
@@ -135,259 +143,301 @@ msgstr "Gauche"
msgid "Right"
msgstr "Droite"
#: build/lib/core/models.py:81 core/models.py:81
#: build/lib/core/models.py:80 core/models.py:80
msgid "id"
msgstr "identifiant/id"
#: build/lib/core/models.py:82 core/models.py:82
#: build/lib/core/models.py:81 core/models.py:81
msgid "primary key for the record as UUID"
msgstr "clé primaire pour l'enregistrement en tant que UUID"
#: build/lib/core/models.py:88 core/models.py:88
#: build/lib/core/models.py:87 core/models.py:87
msgid "created on"
msgstr "créé le"
#: build/lib/core/models.py:89 core/models.py:89
#: build/lib/core/models.py:88 core/models.py:88
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:94 core/models.py:94
#: build/lib/core/models.py:93 core/models.py:93
msgid "updated on"
msgstr "mis à jour le"
#: build/lib/core/models.py:95 core/models.py:95
#: build/lib/core/models.py:94 core/models.py:94
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:131 core/models.py:131
#: build/lib/core/models.py:130 core/models.py:130
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:142 core/models.py:142
#: build/lib/core/models.py:141 core/models.py:141
msgid "sub"
msgstr "sous-groupe"
#: build/lib/core/models.py:143 core/models.py:143
#: build/lib/core/models.py:142 core/models.py:142
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:151 core/models.py:151
#: build/lib/core/models.py:150 core/models.py:150
msgid "full name"
msgstr "nom complet"
#: build/lib/core/models.py:153 core/models.py:153
#: build/lib/core/models.py:152 core/models.py:152
msgid "short name"
msgstr "nom court"
#: build/lib/core/models.py:156 core/models.py:156
#: build/lib/core/models.py:155 core/models.py:155
msgid "identity email address"
msgstr "adresse e-mail d'identité"
#: build/lib/core/models.py:161 core/models.py:161
#: build/lib/core/models.py:160 core/models.py:160
msgid "admin email address"
msgstr "adresse e-mail de l'administrateur"
#: build/lib/core/models.py:168 core/models.py:168
#: build/lib/core/models.py:167 core/models.py:167
msgid "language"
msgstr "langue"
#: build/lib/core/models.py:169 core/models.py:169
#: build/lib/core/models.py:168 core/models.py:168
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:177 core/models.py:177
#: build/lib/core/models.py:176 core/models.py:176
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:180 core/models.py:180
#: build/lib/core/models.py:179 core/models.py:179
msgid "device"
msgstr "appareil"
#: build/lib/core/models.py:182 core/models.py:182
#: build/lib/core/models.py:181 core/models.py:181
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:185 core/models.py:185
#: build/lib/core/models.py:184 core/models.py:184
msgid "staff status"
msgstr "statut d'équipe"
#: build/lib/core/models.py:187 core/models.py:187
#: build/lib/core/models.py:186 core/models.py:186
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:190 core/models.py:190
#: build/lib/core/models.py:189 core/models.py:189
msgid "active"
msgstr "actif"
#: build/lib/core/models.py:193 core/models.py:193
#: build/lib/core/models.py:192 core/models.py:192
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:205 core/models.py:205
#: build/lib/core/models.py:204 core/models.py:204
msgid "user"
msgstr "utilisateur"
#: build/lib/core/models.py:206 core/models.py:206
#: build/lib/core/models.py:205 core/models.py:205
msgid "users"
msgstr "utilisateurs"
#: build/lib/core/models.py:362 core/models.py:362
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
#: core/models.py:361 core/models.py:1430
msgid "title"
msgstr "titre"
#: build/lib/core/models.py:363 core/models.py:363
#: build/lib/core/models.py:362 core/models.py:362
msgid "excerpt"
msgstr "extrait"
#: build/lib/core/models.py:412 core/models.py:412
#: build/lib/core/models.py:411 core/models.py:411
msgid "Document"
msgstr "Document"
#: build/lib/core/models.py:413 core/models.py:413
#: build/lib/core/models.py:412 core/models.py:412
msgid "Documents"
msgstr "Documents"
#: build/lib/core/models.py:425 build/lib/core/models.py:828 core/models.py:425
#: core/models.py:828
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
#: core/models.py:824
msgid "Untitled Document"
msgstr "Document sans titre"
#: build/lib/core/models.py:829 core/models.py:829
msgid "Open"
msgstr "Ouvrir"
#: build/lib/core/models.py:864 core/models.py:864
#: build/lib/core/models.py:859 core/models.py:859
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} a partagé un document avec vous!"
#: build/lib/core/models.py:868 core/models.py:868
#: build/lib/core/models.py:863 core/models.py:863
#, 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:874 core/models.py:874
#: build/lib/core/models.py:869 core/models.py:869
#, 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:975 core/models.py:975
#: build/lib/core/models.py:969 core/models.py:969
msgid "Document/user link trace"
msgstr "Trace du lien document/utilisateur"
#: build/lib/core/models.py:976 core/models.py:976
#: build/lib/core/models.py:970 core/models.py:970
msgid "Document/user link traces"
msgstr "Traces du lien document/utilisateur"
#: build/lib/core/models.py:982 core/models.py:982
#: build/lib/core/models.py:976 core/models.py:976
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:1005 core/models.py:1005
#: build/lib/core/models.py:999 core/models.py:999
msgid "Document favorite"
msgstr "Document favori"
#: build/lib/core/models.py:1006 core/models.py:1006
#: build/lib/core/models.py:1000 core/models.py:1000
msgid "Document favorites"
msgstr "Documents favoris"
#: build/lib/core/models.py:1012 core/models.py:1012
#: build/lib/core/models.py:1006 core/models.py:1006
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:1034 core/models.py:1034
#: build/lib/core/models.py:1028 core/models.py:1028
msgid "Document/user relation"
msgstr "Relation document/utilisateur"
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:1029 core/models.py:1029
msgid "Document/user relations"
msgstr "Relations document/utilisateur"
#: build/lib/core/models.py:1041 core/models.py:1041
#: build/lib/core/models.py:1035 core/models.py:1035
msgid "This user is already in this document."
msgstr "Cet utilisateur est déjà dans ce document."
#: build/lib/core/models.py:1047 core/models.py:1047
#: build/lib/core/models.py:1041 core/models.py:1041
msgid "This team is already in this document."
msgstr "Cette équipe est déjà dans ce document."
#: build/lib/core/models.py:1053 core/models.py:1053
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
#: core/models.py:1047 core/models.py:1516
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:1204 core/models.py:1204
#: build/lib/core/models.py:1198 core/models.py:1198
msgid "Document ask for access"
msgstr "Demande d'accès au document"
#: build/lib/core/models.py:1205 core/models.py:1205
#: build/lib/core/models.py:1199 core/models.py:1199
msgid "Document ask for accesses"
msgstr "Demande d'accès au document"
#: build/lib/core/models.py:1211 core/models.py:1211
#: build/lib/core/models.py:1205 core/models.py:1205
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:1268 core/models.py:1268
#: build/lib/core/models.py:1262 core/models.py:1262
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "{name} souhaiterait accéder au document suivant !"
#: build/lib/core/models.py:1272 core/models.py:1272
#: build/lib/core/models.py:1266 core/models.py:1266
#, 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:1278 core/models.py:1278
#: build/lib/core/models.py:1272 core/models.py:1272
#, 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:1320 core/models.py:1320
#: build/lib/core/models.py:1314 core/models.py:1314
msgid "Thread"
msgstr "Conversation"
#: build/lib/core/models.py:1321 core/models.py:1321
#: build/lib/core/models.py:1315 core/models.py:1315
msgid "Threads"
msgstr "Conversations"
#: build/lib/core/models.py:1324 build/lib/core/models.py:1376
#: core/models.py:1324 core/models.py:1376
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
#: core/models.py:1318 core/models.py:1370
msgid "Anonymous"
msgstr "Anonyme"
#: build/lib/core/models.py:1371 core/models.py:1371
#: build/lib/core/models.py:1365 core/models.py:1365
msgid "Comment"
msgstr "Commentaire"
#: build/lib/core/models.py:1372 core/models.py:1372
#: build/lib/core/models.py:1366 core/models.py:1366
msgid "Comments"
msgstr "Commentaires"
#: build/lib/core/models.py:1421 core/models.py:1421
#: build/lib/core/models.py:1415 core/models.py:1415
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:1425 core/models.py:1425
#: build/lib/core/models.py:1419 core/models.py:1419
msgid "Reaction"
msgstr "Réaction"
#: build/lib/core/models.py:1426 core/models.py:1426
#: build/lib/core/models.py:1420 core/models.py:1420
msgid "Reactions"
msgstr "Réactions"
#: build/lib/core/models.py:1436 core/models.py:1436
#: 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
msgid "email address"
msgstr "adresse e-mail"
#: build/lib/core/models.py:1455 core/models.py:1455
#: build/lib/core/models.py:1606 core/models.py:1606
msgid "Document invitation"
msgstr "Invitation à un document"
#: build/lib/core/models.py:1456 core/models.py:1456
#: build/lib/core/models.py:1607 core/models.py:1607
msgid "Document invitations"
msgstr "Invitations à un document"
#: build/lib/core/models.py:1476 core/models.py:1476
#: build/lib/core/models.py:1627 core/models.py:1627
msgid "This email is already associated to a registered user."
msgstr "Cette adresse email est déjà associée à un utilisateur inscrit."
@@ -396,12 +446,17 @@ 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:219
#: 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/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:226
#: core/templates/mail/html/template.html:224
#: core/templates/mail/text/template.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-21 09:53+0000\n"
"PO-Revision-Date: 2026-01-28 20:12\n"
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-12-09 11: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:28 core/admin.py:28
#: build/lib/core/admin.py:36 core/admin.py:36
msgid "Personal info"
msgstr "Informazioni personali"
#: build/lib/core/admin.py:41 build/lib/core/admin.py:121 core/admin.py:41
#: core/admin.py:121
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
msgid "Permissions"
msgstr "Permessi"
#: build/lib/core/admin.py:53 core/admin.py:53
#: build/lib/core/admin.py:61 core/admin.py:61
msgid "Important dates"
msgstr "Date importanti"
#: build/lib/core/admin.py:131 core/admin.py:131
#: build/lib/core/admin.py:147 core/admin.py:147
msgid "Tree structure"
msgstr "Struttura ad albero"
@@ -50,32 +50,40 @@ msgstr ""
msgid "Favorite"
msgstr "Preferiti"
#: build/lib/core/api/serializers.py:505 core/api/serializers.py:505
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
msgid "A new document was created on your behalf!"
msgstr "Un nuovo documento è stato creato a tuo nome!"
#: build/lib/core/api/serializers.py:509 core/api/serializers.py:509
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
msgid "You have been granted ownership of a new document:"
msgstr "Sei ora proprietario di un nuovo documento:"
#: build/lib/core/api/serializers.py:545 core/api/serializers.py:545
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
msgid "This field is required."
msgstr ""
#: build/lib/core/api/serializers.py:556 core/api/serializers.py:556
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr ""
#: build/lib/core/api/viewsets.py:1122 core/api/viewsets.py:1122
#: 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
#, 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"
@@ -135,259 +143,301 @@ msgstr "Sinistra"
msgid "Right"
msgstr "Destra"
#: build/lib/core/models.py:81 core/models.py:81
#: build/lib/core/models.py:80 core/models.py:80
msgid "id"
msgstr "Id"
#: build/lib/core/models.py:82 core/models.py:82
#: build/lib/core/models.py:81 core/models.py:81
msgid "primary key for the record as UUID"
msgstr "chiave primaria per il record come UUID"
#: build/lib/core/models.py:88 core/models.py:88
#: build/lib/core/models.py:87 core/models.py:87
msgid "created on"
msgstr "creato il"
#: build/lib/core/models.py:89 core/models.py:89
#: build/lib/core/models.py:88 core/models.py:88
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:94 core/models.py:94
#: build/lib/core/models.py:93 core/models.py:93
msgid "updated on"
msgstr "aggiornato il"
#: build/lib/core/models.py:95 core/models.py:95
#: build/lib/core/models.py:94 core/models.py:94
msgid "date and time at which a record was last updated"
msgstr "data e ora in cui lultimo record è stato aggiornato"
#: build/lib/core/models.py:131 core/models.py:131
#: build/lib/core/models.py:130 core/models.py:130
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:142 core/models.py:142
#: build/lib/core/models.py:141 core/models.py:141
msgid "sub"
msgstr ""
#: build/lib/core/models.py:143 core/models.py:143
#: build/lib/core/models.py:142 core/models.py:142
msgid "Required. 255 characters or fewer. ASCII characters only."
msgstr ""
#: build/lib/core/models.py:151 core/models.py:151
#: build/lib/core/models.py:150 core/models.py:150
msgid "full name"
msgstr "nome completo"
#: build/lib/core/models.py:153 core/models.py:153
#: build/lib/core/models.py:152 core/models.py:152
msgid "short name"
msgstr "nome"
#: build/lib/core/models.py:156 core/models.py:156
#: build/lib/core/models.py:155 core/models.py:155
msgid "identity email address"
msgstr "indirizzo email di identità"
#: build/lib/core/models.py:161 core/models.py:161
#: build/lib/core/models.py:160 core/models.py:160
msgid "admin email address"
msgstr "Indirizzo email dell'amministratore"
#: build/lib/core/models.py:168 core/models.py:168
#: build/lib/core/models.py:167 core/models.py:167
msgid "language"
msgstr "lingua"
#: build/lib/core/models.py:169 core/models.py:169
#: build/lib/core/models.py:168 core/models.py:168
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:177 core/models.py:177
#: build/lib/core/models.py:176 core/models.py:176
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:180 core/models.py:180
#: build/lib/core/models.py:179 core/models.py:179
msgid "device"
msgstr "dispositivo"
#: build/lib/core/models.py:182 core/models.py:182
#: build/lib/core/models.py:181 core/models.py:181
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:185 core/models.py:185
#: build/lib/core/models.py:184 core/models.py:184
msgid "staff status"
msgstr "stato del personale"
#: build/lib/core/models.py:187 core/models.py:187
#: build/lib/core/models.py:186 core/models.py:186
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:190 core/models.py:190
#: build/lib/core/models.py:189 core/models.py:189
msgid "active"
msgstr "attivo"
#: build/lib/core/models.py:193 core/models.py:193
#: build/lib/core/models.py:192 core/models.py:192
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:205 core/models.py:205
#: build/lib/core/models.py:204 core/models.py:204
msgid "user"
msgstr "utente"
#: build/lib/core/models.py:206 core/models.py:206
#: build/lib/core/models.py:205 core/models.py:205
msgid "users"
msgstr "utenti"
#: build/lib/core/models.py:362 core/models.py:362
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
#: core/models.py:361 core/models.py:1430
msgid "title"
msgstr "titolo"
#: build/lib/core/models.py:363 core/models.py:363
#: build/lib/core/models.py:362 core/models.py:362
msgid "excerpt"
msgstr ""
#: build/lib/core/models.py:412 core/models.py:412
#: build/lib/core/models.py:411 core/models.py:411
msgid "Document"
msgstr "Documento"
#: build/lib/core/models.py:413 core/models.py:413
#: build/lib/core/models.py:412 core/models.py:412
msgid "Documents"
msgstr "Documenti"
#: build/lib/core/models.py:425 build/lib/core/models.py:828 core/models.py:425
#: core/models.py:828
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
#: core/models.py:824
msgid "Untitled Document"
msgstr "Documento senza titolo"
#: build/lib/core/models.py:829 core/models.py:829
msgid "Open"
msgstr "Apri"
#: build/lib/core/models.py:864 core/models.py:864
#: build/lib/core/models.py:859 core/models.py:859
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} ha condiviso un documento con te!"
#: build/lib/core/models.py:868 core/models.py:868
#: build/lib/core/models.py:863 core/models.py:863
#, 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:874 core/models.py:874
#: build/lib/core/models.py:869 core/models.py:869
#, 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:975 core/models.py:975
#: build/lib/core/models.py:969 core/models.py:969
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:976 core/models.py:976
#: build/lib/core/models.py:970 core/models.py:970
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:982 core/models.py:982
#: build/lib/core/models.py:976 core/models.py:976
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:1005 core/models.py:1005
#: build/lib/core/models.py:999 core/models.py:999
msgid "Document favorite"
msgstr "Documento preferito"
#: build/lib/core/models.py:1006 core/models.py:1006
#: build/lib/core/models.py:1000 core/models.py:1000
msgid "Document favorites"
msgstr "Documenti preferiti"
#: build/lib/core/models.py:1012 core/models.py:1012
#: build/lib/core/models.py:1006 core/models.py:1006
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:1034 core/models.py:1034
#: build/lib/core/models.py:1028 core/models.py:1028
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:1029 core/models.py:1029
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:1041 core/models.py:1041
#: build/lib/core/models.py:1035 core/models.py:1035
msgid "This user is already in this document."
msgstr "Questo utente è già presente in questo documento."
#: build/lib/core/models.py:1047 core/models.py:1047
#: build/lib/core/models.py:1041 core/models.py:1041
msgid "This team is already in this document."
msgstr "Questo team è già presente in questo documento."
#: build/lib/core/models.py:1053 core/models.py:1053
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
#: core/models.py:1047 core/models.py:1516
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:1204 core/models.py:1204
#: build/lib/core/models.py:1198 core/models.py:1198
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1205 core/models.py:1205
#: build/lib/core/models.py:1199 core/models.py:1199
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1211 core/models.py:1211
#: build/lib/core/models.py:1205 core/models.py:1205
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1268 core/models.py:1268
#: build/lib/core/models.py:1262 core/models.py:1262
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1272 core/models.py:1272
#: build/lib/core/models.py:1266 core/models.py:1266
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1278 core/models.py:1278
#: build/lib/core/models.py:1272 core/models.py:1272
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1320 core/models.py:1320
#: build/lib/core/models.py:1314 core/models.py:1314
msgid "Thread"
msgstr ""
#: build/lib/core/models.py:1321 core/models.py:1321
#: build/lib/core/models.py:1315 core/models.py:1315
msgid "Threads"
msgstr ""
#: build/lib/core/models.py:1324 build/lib/core/models.py:1376
#: core/models.py:1324 core/models.py:1376
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
#: core/models.py:1318 core/models.py:1370
msgid "Anonymous"
msgstr ""
#: build/lib/core/models.py:1371 core/models.py:1371
#: build/lib/core/models.py:1365 core/models.py:1365
msgid "Comment"
msgstr ""
#: build/lib/core/models.py:1372 core/models.py:1372
#: build/lib/core/models.py:1366 core/models.py:1366
msgid "Comments"
msgstr ""
#: build/lib/core/models.py:1421 core/models.py:1421
#: build/lib/core/models.py:1415 core/models.py:1415
msgid "This emoji has already been reacted to this comment."
msgstr ""
#: build/lib/core/models.py:1425 core/models.py:1425
#: build/lib/core/models.py:1419 core/models.py:1419
msgid "Reaction"
msgstr ""
#: build/lib/core/models.py:1426 core/models.py:1426
#: build/lib/core/models.py:1420 core/models.py:1420
msgid "Reactions"
msgstr ""
#: build/lib/core/models.py:1436 core/models.py:1436
#: 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
msgid "email address"
msgstr "indirizzo e-mail"
#: build/lib/core/models.py:1455 core/models.py:1455
#: build/lib/core/models.py:1606 core/models.py:1606
msgid "Document invitation"
msgstr "Invito al documento"
#: build/lib/core/models.py:1456 core/models.py:1456
#: build/lib/core/models.py:1607 core/models.py:1607
msgid "Document invitations"
msgstr "Inviti al documento"
#: build/lib/core/models.py:1476 core/models.py:1476
#: build/lib/core/models.py:1627 core/models.py:1627
msgid "This email is already associated to a registered user."
msgstr "Questa email è già associata a un utente registrato."
@@ -396,12 +446,17 @@ msgstr "Questa email è già associata a un utente registrato."
msgid "Logo email"
msgstr "Logo e-mail"
#: core/templates/mail/html/template.html:219
#: 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/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:226
#: core/templates/mail/html/template.html:224
#: core/templates/mail/text/template.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-21 09:53+0000\n"
"PO-Revision-Date: 2026-01-28 20:12\n"
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-12-09 11: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:28 core/admin.py:28
#: build/lib/core/admin.py:36 core/admin.py:36
msgid "Personal info"
msgstr "Persoonlijke informatie"
#: build/lib/core/admin.py:41 build/lib/core/admin.py:121 core/admin.py:41
#: core/admin.py:121
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
msgid "Permissions"
msgstr "Machtigingen"
#: build/lib/core/admin.py:53 core/admin.py:53
#: build/lib/core/admin.py:61 core/admin.py:61
msgid "Important dates"
msgstr "Belangrijke data"
#: build/lib/core/admin.py:131 core/admin.py:131
#: build/lib/core/admin.py:147 core/admin.py:147
msgid "Tree structure"
msgstr "Boomstructuur"
@@ -50,32 +50,40 @@ msgstr "Gemaskeerd"
msgid "Favorite"
msgstr "Favoriet"
#: build/lib/core/api/serializers.py:505 core/api/serializers.py:505
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
msgid "A new document was created on your behalf!"
msgstr "Een nieuw document is namens u gemaakt!"
#: build/lib/core/api/serializers.py:509 core/api/serializers.py:509
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
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:545 core/api/serializers.py:545
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
msgid "This field is required."
msgstr "Dit veld is verplicht."
#: build/lib/core/api/serializers.py:556 core/api/serializers.py:556
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
#, 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/viewsets.py:1122 core/api/viewsets.py:1122
#: 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
#, 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"
@@ -135,259 +143,301 @@ msgstr "Links"
msgid "Right"
msgstr "Rechts"
#: build/lib/core/models.py:81 core/models.py:81
#: build/lib/core/models.py:80 core/models.py:80
msgid "id"
msgstr "id"
#: build/lib/core/models.py:82 core/models.py:82
#: build/lib/core/models.py:81 core/models.py:81
msgid "primary key for the record as UUID"
msgstr "primaire sleutel voor dossier als UUID"
#: build/lib/core/models.py:88 core/models.py:88
#: build/lib/core/models.py:87 core/models.py:87
msgid "created on"
msgstr "gecreëerd op"
#: build/lib/core/models.py:89 core/models.py:89
#: build/lib/core/models.py:88 core/models.py:88
msgid "date and time at which a record was created"
msgstr "datum en tijd waarop dossier is gecreeërd"
#: build/lib/core/models.py:94 core/models.py:94
#: build/lib/core/models.py:93 core/models.py:93
msgid "updated on"
msgstr "Laatst gewijzigd op"
#: build/lib/core/models.py:95 core/models.py:95
#: build/lib/core/models.py:94 core/models.py:94
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:131 core/models.py:131
#: build/lib/core/models.py:130 core/models.py:130
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:142 core/models.py:142
#: build/lib/core/models.py:141 core/models.py:141
msgid "sub"
msgstr "id"
#: build/lib/core/models.py:143 core/models.py:143
#: build/lib/core/models.py:142 core/models.py:142
msgid "Required. 255 characters or fewer. ASCII characters only."
msgstr "Vereist. 255 tekens of minder. Alleen ASCII tekens."
#: build/lib/core/models.py:151 core/models.py:151
#: build/lib/core/models.py:150 core/models.py:150
msgid "full name"
msgstr "volledige naam"
#: build/lib/core/models.py:153 core/models.py:153
#: build/lib/core/models.py:152 core/models.py:152
msgid "short name"
msgstr "gebruikersnaam"
#: build/lib/core/models.py:156 core/models.py:156
#: build/lib/core/models.py:155 core/models.py:155
msgid "identity email address"
msgstr "identiteit emailadres"
#: build/lib/core/models.py:161 core/models.py:161
#: build/lib/core/models.py:160 core/models.py:160
msgid "admin email address"
msgstr "admin emailadres"
#: build/lib/core/models.py:168 core/models.py:168
#: build/lib/core/models.py:167 core/models.py:167
msgid "language"
msgstr "taal"
#: build/lib/core/models.py:169 core/models.py:169
#: build/lib/core/models.py:168 core/models.py:168
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:177 core/models.py:177
#: build/lib/core/models.py:176 core/models.py:176
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:180 core/models.py:180
#: build/lib/core/models.py:179 core/models.py:179
msgid "device"
msgstr "apparaat"
#: build/lib/core/models.py:182 core/models.py:182
#: build/lib/core/models.py:181 core/models.py:181
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:185 core/models.py:185
#: build/lib/core/models.py:184 core/models.py:184
msgid "staff status"
msgstr "beheerder status"
#: build/lib/core/models.py:187 core/models.py:187
#: build/lib/core/models.py:186 core/models.py:186
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:190 core/models.py:190
#: build/lib/core/models.py:189 core/models.py:189
msgid "active"
msgstr "actief"
#: build/lib/core/models.py:193 core/models.py:193
#: build/lib/core/models.py:192 core/models.py:192
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:205 core/models.py:205
#: build/lib/core/models.py:204 core/models.py:204
msgid "user"
msgstr "gebruiker"
#: build/lib/core/models.py:206 core/models.py:206
#: build/lib/core/models.py:205 core/models.py:205
msgid "users"
msgstr "gebruikers"
#: build/lib/core/models.py:362 core/models.py:362
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
#: core/models.py:361 core/models.py:1430
msgid "title"
msgstr "titel"
#: build/lib/core/models.py:363 core/models.py:363
#: build/lib/core/models.py:362 core/models.py:362
msgid "excerpt"
msgstr "uittreksel"
#: build/lib/core/models.py:412 core/models.py:412
#: build/lib/core/models.py:411 core/models.py:411
msgid "Document"
msgstr "Document"
#: build/lib/core/models.py:413 core/models.py:413
#: build/lib/core/models.py:412 core/models.py:412
msgid "Documents"
msgstr "Documenten"
#: build/lib/core/models.py:425 build/lib/core/models.py:828 core/models.py:425
#: core/models.py:828
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
#: core/models.py:824
msgid "Untitled Document"
msgstr "Naamloos Document"
#: build/lib/core/models.py:829 core/models.py:829
msgid "Open"
msgstr "Open"
#: build/lib/core/models.py:864 core/models.py:864
#: build/lib/core/models.py:859 core/models.py:859
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} heeft een document met u gedeeld!"
#: build/lib/core/models.py:868 core/models.py:868
#: build/lib/core/models.py:863 core/models.py:863
#, 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:874 core/models.py:874
#: build/lib/core/models.py:869 core/models.py:869
#, 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:975 core/models.py:975
#: build/lib/core/models.py:969 core/models.py:969
msgid "Document/user link trace"
msgstr "Document/gebruiker link"
#: build/lib/core/models.py:976 core/models.py:976
#: build/lib/core/models.py:970 core/models.py:970
msgid "Document/user link traces"
msgstr "Document/gebruiker link"
#: build/lib/core/models.py:982 core/models.py:982
#: build/lib/core/models.py:976 core/models.py:976
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:1005 core/models.py:1005
#: build/lib/core/models.py:999 core/models.py:999
msgid "Document favorite"
msgstr "Document favoriet"
#: build/lib/core/models.py:1006 core/models.py:1006
#: build/lib/core/models.py:1000 core/models.py:1000
msgid "Document favorites"
msgstr "Document favorieten"
#: build/lib/core/models.py:1012 core/models.py:1012
#: build/lib/core/models.py:1006 core/models.py:1006
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:1034 core/models.py:1034
#: build/lib/core/models.py:1028 core/models.py:1028
msgid "Document/user relation"
msgstr "Document/gebruiker relatie"
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:1029 core/models.py:1029
msgid "Document/user relations"
msgstr "Document/gebruiker relaties"
#: build/lib/core/models.py:1041 core/models.py:1041
#: build/lib/core/models.py:1035 core/models.py:1035
msgid "This user is already in this document."
msgstr "De gebruiker bestaat al in dit document."
#: build/lib/core/models.py:1047 core/models.py:1047
#: build/lib/core/models.py:1041 core/models.py:1041
msgid "This team is already in this document."
msgstr "Dit team bestaat al in dit document."
#: build/lib/core/models.py:1053 core/models.py:1053
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
#: core/models.py:1047 core/models.py:1516
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:1204 core/models.py:1204
#: build/lib/core/models.py:1198 core/models.py:1198
msgid "Document ask for access"
msgstr "Document verzoekt om toegang"
#: build/lib/core/models.py:1205 core/models.py:1205
#: build/lib/core/models.py:1199 core/models.py:1199
msgid "Document ask for accesses"
msgstr "Document verzoekt om toegangen"
#: build/lib/core/models.py:1211 core/models.py:1211
#: build/lib/core/models.py:1205 core/models.py:1205
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:1268 core/models.py:1268
#: build/lib/core/models.py:1262 core/models.py:1262
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "{name} verzoekt toegang tot een document!"
#: build/lib/core/models.py:1272 core/models.py:1272
#: build/lib/core/models.py:1266 core/models.py:1266
#, 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:1278 core/models.py:1278
#: build/lib/core/models.py:1272 core/models.py:1272
#, 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:1320 core/models.py:1320
#: build/lib/core/models.py:1314 core/models.py:1314
msgid "Thread"
msgstr "Kanaal"
#: build/lib/core/models.py:1321 core/models.py:1321
#: build/lib/core/models.py:1315 core/models.py:1315
msgid "Threads"
msgstr "Kanalen"
#: build/lib/core/models.py:1324 build/lib/core/models.py:1376
#: core/models.py:1324 core/models.py:1376
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
#: core/models.py:1318 core/models.py:1370
msgid "Anonymous"
msgstr "Anoniem"
#: build/lib/core/models.py:1371 core/models.py:1371
#: build/lib/core/models.py:1365 core/models.py:1365
msgid "Comment"
msgstr "Reactie"
#: build/lib/core/models.py:1372 core/models.py:1372
#: build/lib/core/models.py:1366 core/models.py:1366
msgid "Comments"
msgstr "Reacties"
#: build/lib/core/models.py:1421 core/models.py:1421
#: build/lib/core/models.py:1415 core/models.py:1415
msgid "This emoji has already been reacted to this comment."
msgstr "Deze emoji is al op deze opmerking gereageerd."
#: build/lib/core/models.py:1425 core/models.py:1425
#: build/lib/core/models.py:1419 core/models.py:1419
msgid "Reaction"
msgstr "Reactie"
#: build/lib/core/models.py:1426 core/models.py:1426
#: build/lib/core/models.py:1420 core/models.py:1420
msgid "Reactions"
msgstr "Reacties"
#: build/lib/core/models.py:1436 core/models.py:1436
#: 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
msgid "email address"
msgstr "e-mailadres"
#: build/lib/core/models.py:1455 core/models.py:1455
#: build/lib/core/models.py:1606 core/models.py:1606
msgid "Document invitation"
msgstr "Document uitnodiging"
#: build/lib/core/models.py:1456 core/models.py:1456
#: build/lib/core/models.py:1607 core/models.py:1607
msgid "Document invitations"
msgstr "Document uitnodigingen"
#: build/lib/core/models.py:1476 core/models.py:1476
#: build/lib/core/models.py:1627 core/models.py:1627
msgid "This email is already associated to a registered user."
msgstr "Deze email is al geassocieerd met een geregistreerde gebruiker."
@@ -396,12 +446,17 @@ msgstr "Deze email is al geassocieerd met een geregistreerde gebruiker."
msgid "Logo email"
msgstr "Logo email"
#: core/templates/mail/html/template.html:219
#: 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/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:226
#: core/templates/mail/html/template.html:224
#: core/templates/mail/text/template.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-21 09:53+0000\n"
"PO-Revision-Date: 2026-01-28 20:12\n"
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-12-09 11: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:28 core/admin.py:28
#: build/lib/core/admin.py:36 core/admin.py:36
msgid "Personal info"
msgstr "Informações Pessoais"
#: build/lib/core/admin.py:41 build/lib/core/admin.py:121 core/admin.py:41
#: core/admin.py:121
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
msgid "Permissions"
msgstr "Permissões"
#: build/lib/core/admin.py:53 core/admin.py:53
#: build/lib/core/admin.py:61 core/admin.py:61
msgid "Important dates"
msgstr "Datas importantes"
#: build/lib/core/admin.py:131 core/admin.py:131
#: build/lib/core/admin.py:147 core/admin.py:147
msgid "Tree structure"
msgstr "Estrutura de árvore"
@@ -50,32 +50,40 @@ msgstr ""
msgid "Favorite"
msgstr "Favorito"
#: build/lib/core/api/serializers.py:505 core/api/serializers.py:505
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
msgid "A new document was created on your behalf!"
msgstr "Um novo documento foi criado em seu nome!"
#: build/lib/core/api/serializers.py:509 core/api/serializers.py:509
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
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:545 core/api/serializers.py:545
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
msgid "This field is required."
msgstr ""
#: build/lib/core/api/serializers.py:556 core/api/serializers.py:556
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr ""
#: build/lib/core/api/viewsets.py:1122 core/api/viewsets.py:1122
#: 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
#, 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"
@@ -135,259 +143,301 @@ msgstr ""
msgid "Right"
msgstr ""
#: build/lib/core/models.py:81 core/models.py:81
#: build/lib/core/models.py:80 core/models.py:80
msgid "id"
msgstr ""
#: build/lib/core/models.py:82 core/models.py:82
#: build/lib/core/models.py:81 core/models.py:81
msgid "primary key for the record as UUID"
msgstr ""
#: build/lib/core/models.py:88 core/models.py:88
#: build/lib/core/models.py:87 core/models.py:87
msgid "created on"
msgstr ""
#: build/lib/core/models.py:89 core/models.py:89
#: build/lib/core/models.py:88 core/models.py:88
msgid "date and time at which a record was created"
msgstr ""
#: build/lib/core/models.py:94 core/models.py:94
#: build/lib/core/models.py:93 core/models.py:93
msgid "updated on"
msgstr ""
#: build/lib/core/models.py:95 core/models.py:95
#: build/lib/core/models.py:94 core/models.py:94
msgid "date and time at which a record was last updated"
msgstr ""
#: build/lib/core/models.py:131 core/models.py:131
#: build/lib/core/models.py:130 core/models.py:130
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:142 core/models.py:142
#: build/lib/core/models.py:141 core/models.py:141
msgid "sub"
msgstr ""
#: build/lib/core/models.py:143 core/models.py:143
#: build/lib/core/models.py:142 core/models.py:142
msgid "Required. 255 characters or fewer. ASCII characters only."
msgstr ""
#: build/lib/core/models.py:151 core/models.py:151
#: build/lib/core/models.py:150 core/models.py:150
msgid "full name"
msgstr ""
#: build/lib/core/models.py:153 core/models.py:153
#: build/lib/core/models.py:152 core/models.py:152
msgid "short name"
msgstr ""
#: build/lib/core/models.py:156 core/models.py:156
#: build/lib/core/models.py:155 core/models.py:155
msgid "identity email address"
msgstr ""
#: build/lib/core/models.py:161 core/models.py:161
#: build/lib/core/models.py:160 core/models.py:160
msgid "admin email address"
msgstr ""
#: build/lib/core/models.py:168 core/models.py:168
#: build/lib/core/models.py:167 core/models.py:167
msgid "language"
msgstr ""
#: build/lib/core/models.py:169 core/models.py:169
#: build/lib/core/models.py:168 core/models.py:168
msgid "The language in which the user wants to see the interface."
msgstr ""
#: build/lib/core/models.py:177 core/models.py:177
#: build/lib/core/models.py:176 core/models.py:176
msgid "The timezone in which the user wants to see times."
msgstr ""
#: build/lib/core/models.py:180 core/models.py:180
#: build/lib/core/models.py:179 core/models.py:179
msgid "device"
msgstr ""
#: build/lib/core/models.py:182 core/models.py:182
#: build/lib/core/models.py:181 core/models.py:181
msgid "Whether the user is a device or a real user."
msgstr ""
#: build/lib/core/models.py:185 core/models.py:185
#: build/lib/core/models.py:184 core/models.py:184
msgid "staff status"
msgstr ""
#: build/lib/core/models.py:187 core/models.py:187
#: build/lib/core/models.py:186 core/models.py:186
msgid "Whether the user can log into this admin site."
msgstr ""
#: build/lib/core/models.py:190 core/models.py:190
#: build/lib/core/models.py:189 core/models.py:189
msgid "active"
msgstr ""
#: build/lib/core/models.py:193 core/models.py:193
#: build/lib/core/models.py:192 core/models.py:192
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: build/lib/core/models.py:205 core/models.py:205
#: build/lib/core/models.py:204 core/models.py:204
msgid "user"
msgstr ""
#: build/lib/core/models.py:206 core/models.py:206
#: build/lib/core/models.py:205 core/models.py:205
msgid "users"
msgstr ""
#: build/lib/core/models.py:362 core/models.py:362
#: 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:363 core/models.py:363
#: build/lib/core/models.py:362 core/models.py:362
msgid "excerpt"
msgstr ""
#: build/lib/core/models.py:412 core/models.py:412
#: build/lib/core/models.py:411 core/models.py:411
msgid "Document"
msgstr ""
#: build/lib/core/models.py:413 core/models.py:413
#: build/lib/core/models.py:412 core/models.py:412
msgid "Documents"
msgstr ""
#: build/lib/core/models.py:425 build/lib/core/models.py:828 core/models.py:425
#: core/models.py:828
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
#: core/models.py:824
msgid "Untitled Document"
msgstr ""
#: build/lib/core/models.py:829 core/models.py:829
msgid "Open"
msgstr ""
#: build/lib/core/models.py:864 core/models.py:864
#: build/lib/core/models.py:859 core/models.py:859
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
#: build/lib/core/models.py:868 core/models.py:868
#: build/lib/core/models.py:863 core/models.py:863
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr ""
#: build/lib/core/models.py:874 core/models.py:874
#: build/lib/core/models.py:869 core/models.py:869
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr ""
#: build/lib/core/models.py:975 core/models.py:975
#: build/lib/core/models.py:969 core/models.py:969
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:976 core/models.py:976
#: build/lib/core/models.py:970 core/models.py:970
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:982 core/models.py:982
#: build/lib/core/models.py:976 core/models.py:976
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:1005 core/models.py:1005
#: build/lib/core/models.py:999 core/models.py:999
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:1006 core/models.py:1006
#: build/lib/core/models.py:1000 core/models.py:1000
msgid "Document favorites"
msgstr ""
#: build/lib/core/models.py:1012 core/models.py:1012
#: build/lib/core/models.py:1006 core/models.py:1006
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:1034 core/models.py:1034
#: build/lib/core/models.py:1028 core/models.py:1028
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:1029 core/models.py:1029
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:1041 core/models.py:1041
#: build/lib/core/models.py:1035 core/models.py:1035
msgid "This user is already in this document."
msgstr ""
#: build/lib/core/models.py:1047 core/models.py:1047
#: build/lib/core/models.py:1041 core/models.py:1041
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:1053 core/models.py:1053
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
#: core/models.py:1047 core/models.py:1516
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:1204 core/models.py:1204
#: build/lib/core/models.py:1198 core/models.py:1198
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1205 core/models.py:1205
#: build/lib/core/models.py:1199 core/models.py:1199
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1211 core/models.py:1211
#: build/lib/core/models.py:1205 core/models.py:1205
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1268 core/models.py:1268
#: build/lib/core/models.py:1262 core/models.py:1262
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1272 core/models.py:1272
#: build/lib/core/models.py:1266 core/models.py:1266
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1278 core/models.py:1278
#: build/lib/core/models.py:1272 core/models.py:1272
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1320 core/models.py:1320
#: build/lib/core/models.py:1314 core/models.py:1314
msgid "Thread"
msgstr ""
#: build/lib/core/models.py:1321 core/models.py:1321
#: build/lib/core/models.py:1315 core/models.py:1315
msgid "Threads"
msgstr ""
#: build/lib/core/models.py:1324 build/lib/core/models.py:1376
#: core/models.py:1324 core/models.py:1376
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
#: core/models.py:1318 core/models.py:1370
msgid "Anonymous"
msgstr ""
#: build/lib/core/models.py:1371 core/models.py:1371
#: build/lib/core/models.py:1365 core/models.py:1365
msgid "Comment"
msgstr ""
#: build/lib/core/models.py:1372 core/models.py:1372
#: build/lib/core/models.py:1366 core/models.py:1366
msgid "Comments"
msgstr ""
#: build/lib/core/models.py:1421 core/models.py:1421
#: build/lib/core/models.py:1415 core/models.py:1415
msgid "This emoji has already been reacted to this comment."
msgstr ""
#: build/lib/core/models.py:1425 core/models.py:1425
#: build/lib/core/models.py:1419 core/models.py:1419
msgid "Reaction"
msgstr ""
#: build/lib/core/models.py:1426 core/models.py:1426
#: build/lib/core/models.py:1420 core/models.py:1420
msgid "Reactions"
msgstr ""
#: build/lib/core/models.py:1436 core/models.py:1436
#: 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
msgid "email address"
msgstr ""
#: build/lib/core/models.py:1455 core/models.py:1455
#: build/lib/core/models.py:1606 core/models.py:1606
msgid "Document invitation"
msgstr ""
#: build/lib/core/models.py:1456 core/models.py:1456
#: build/lib/core/models.py:1607 core/models.py:1607
msgid "Document invitations"
msgstr ""
#: build/lib/core/models.py:1476 core/models.py:1476
#: build/lib/core/models.py:1627 core/models.py:1627
msgid "This email is already associated to a registered user."
msgstr ""
@@ -396,12 +446,17 @@ msgstr ""
msgid "Logo email"
msgstr ""
#: core/templates/mail/html/template.html:219
#: 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/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:226
#: core/templates/mail/html/template.html:224
#: core/templates/mail/text/template.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-21 09:53+0000\n"
"PO-Revision-Date: 2026-01-28 20:12\n"
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-12-09 11: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:28 core/admin.py:28
#: build/lib/core/admin.py:36 core/admin.py:36
msgid "Personal info"
msgstr "Личная информация"
#: build/lib/core/admin.py:41 build/lib/core/admin.py:121 core/admin.py:41
#: core/admin.py:121
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
msgid "Permissions"
msgstr "Разрешения"
#: build/lib/core/admin.py:53 core/admin.py:53
#: build/lib/core/admin.py:61 core/admin.py:61
msgid "Important dates"
msgstr "Важные даты"
#: build/lib/core/admin.py:131 core/admin.py:131
#: build/lib/core/admin.py:147 core/admin.py:147
msgid "Tree structure"
msgstr "Древовидная структура"
@@ -50,32 +50,40 @@ msgstr "Скрытый"
msgid "Favorite"
msgstr "Избранное"
#: build/lib/core/api/serializers.py:505 core/api/serializers.py:505
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
msgid "A new document was created on your behalf!"
msgstr "Новый документ был создан от вашего имени!"
#: build/lib/core/api/serializers.py:509 core/api/serializers.py:509
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
msgid "You have been granted ownership of a new document:"
msgstr "Вы назначены владельцем для нового документа:"
#: build/lib/core/api/serializers.py:545 core/api/serializers.py:545
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
msgid "This field is required."
msgstr "Это поле обязательное."
#: build/lib/core/api/serializers.py:556 core/api/serializers.py:556
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr "Доступ по ссылке '%(link_reach)s' запрещён в соответствии с настройками родительского документа."
#: build/lib/core/api/viewsets.py:1122 core/api/viewsets.py:1122
#: 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
#, 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"
@@ -135,259 +143,301 @@ msgstr "Слева"
msgid "Right"
msgstr "Справа"
#: build/lib/core/models.py:81 core/models.py:81
#: build/lib/core/models.py:80 core/models.py:80
msgid "id"
msgstr "id"
#: build/lib/core/models.py:82 core/models.py:82
#: build/lib/core/models.py:81 core/models.py:81
msgid "primary key for the record as UUID"
msgstr "первичный ключ для записи как UUID"
#: build/lib/core/models.py:88 core/models.py:88
#: build/lib/core/models.py:87 core/models.py:87
msgid "created on"
msgstr "создано"
#: build/lib/core/models.py:89 core/models.py:89
#: build/lib/core/models.py:88 core/models.py:88
msgid "date and time at which a record was created"
msgstr "дата и время создания записи"
#: build/lib/core/models.py:94 core/models.py:94
#: build/lib/core/models.py:93 core/models.py:93
msgid "updated on"
msgstr "обновлено"
#: build/lib/core/models.py:95 core/models.py:95
#: build/lib/core/models.py:94 core/models.py:94
msgid "date and time at which a record was last updated"
msgstr "дата и время последнего обновления записи"
#: build/lib/core/models.py:131 core/models.py:131
#: build/lib/core/models.py:130 core/models.py:130
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:142 core/models.py:142
#: build/lib/core/models.py:141 core/models.py:141
msgid "sub"
msgstr "вложение"
#: build/lib/core/models.py:143 core/models.py:143
#: build/lib/core/models.py:142 core/models.py:142
msgid "Required. 255 characters or fewer. ASCII characters only."
msgstr "Обязательно. 255 символов или меньше. Только ASCII символы."
#: build/lib/core/models.py:151 core/models.py:151
#: build/lib/core/models.py:150 core/models.py:150
msgid "full name"
msgstr "полное имя"
#: build/lib/core/models.py:153 core/models.py:153
#: build/lib/core/models.py:152 core/models.py:152
msgid "short name"
msgstr "короткое имя"
#: build/lib/core/models.py:156 core/models.py:156
#: build/lib/core/models.py:155 core/models.py:155
msgid "identity email address"
msgstr "личный адрес электронной почты"
#: build/lib/core/models.py:161 core/models.py:161
#: build/lib/core/models.py:160 core/models.py:160
msgid "admin email address"
msgstr "e-mail администратора"
#: build/lib/core/models.py:168 core/models.py:168
#: build/lib/core/models.py:167 core/models.py:167
msgid "language"
msgstr "язык"
#: build/lib/core/models.py:169 core/models.py:169
#: build/lib/core/models.py:168 core/models.py:168
msgid "The language in which the user wants to see the interface."
msgstr "Язык, на котором пользователь хочет видеть интерфейс."
#: build/lib/core/models.py:177 core/models.py:177
#: build/lib/core/models.py:176 core/models.py:176
msgid "The timezone in which the user wants to see times."
msgstr "Часовой пояс, в котором пользователь хочет видеть время."
#: build/lib/core/models.py:180 core/models.py:180
#: build/lib/core/models.py:179 core/models.py:179
msgid "device"
msgstr "устройство"
#: build/lib/core/models.py:182 core/models.py:182
#: build/lib/core/models.py:181 core/models.py:181
msgid "Whether the user is a device or a real user."
msgstr "Пользователь является устройством или человеком."
#: build/lib/core/models.py:185 core/models.py:185
#: build/lib/core/models.py:184 core/models.py:184
msgid "staff status"
msgstr "статус сотрудника"
#: build/lib/core/models.py:187 core/models.py:187
#: build/lib/core/models.py:186 core/models.py:186
msgid "Whether the user can log into this admin site."
msgstr "Может ли пользователь войти на этот административный сайт."
#: build/lib/core/models.py:190 core/models.py:190
#: build/lib/core/models.py:189 core/models.py:189
msgid "active"
msgstr "активный"
#: build/lib/core/models.py:193 core/models.py:193
#: build/lib/core/models.py:192 core/models.py:192
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "Должен ли пользователь рассматриваться как активный. Альтернатива удалению учётных записей."
#: build/lib/core/models.py:205 core/models.py:205
#: build/lib/core/models.py:204 core/models.py:204
msgid "user"
msgstr "пользователь"
#: build/lib/core/models.py:206 core/models.py:206
#: build/lib/core/models.py:205 core/models.py:205
msgid "users"
msgstr "пользователи"
#: build/lib/core/models.py:362 core/models.py:362
#: 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:363 core/models.py:363
#: build/lib/core/models.py:362 core/models.py:362
msgid "excerpt"
msgstr "отрывок"
#: build/lib/core/models.py:412 core/models.py:412
#: build/lib/core/models.py:411 core/models.py:411
msgid "Document"
msgstr "Документ"
#: build/lib/core/models.py:413 core/models.py:413
#: build/lib/core/models.py:412 core/models.py:412
msgid "Documents"
msgstr "Документы"
#: build/lib/core/models.py:425 build/lib/core/models.py:828 core/models.py:425
#: core/models.py:828
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
#: core/models.py:824
msgid "Untitled Document"
msgstr "Безымянный документ"
#: build/lib/core/models.py:829 core/models.py:829
msgid "Open"
msgstr "Открыть"
#: build/lib/core/models.py:864 core/models.py:864
#: build/lib/core/models.py:859 core/models.py:859
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} делится с вами документом!"
#: build/lib/core/models.py:868 core/models.py:868
#: build/lib/core/models.py:863 core/models.py:863
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} приглашает вас присоединиться к следующему документу с ролью \"{role}\":"
#: build/lib/core/models.py:874 core/models.py:874
#: build/lib/core/models.py:869 core/models.py:869
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} делится с вами документом: {title}"
#: build/lib/core/models.py:975 core/models.py:975
#: build/lib/core/models.py:969 core/models.py:969
msgid "Document/user link trace"
msgstr "Трассировка связи документ/пользователь"
#: build/lib/core/models.py:976 core/models.py:976
#: build/lib/core/models.py:970 core/models.py:970
msgid "Document/user link traces"
msgstr "Трассировка связей документ/пользователь"
#: build/lib/core/models.py:982 core/models.py:982
#: build/lib/core/models.py:976 core/models.py:976
msgid "A link trace already exists for this document/user."
msgstr "Для этого документа/пользователя уже существует трассировка ссылки."
#: build/lib/core/models.py:1005 core/models.py:1005
#: build/lib/core/models.py:999 core/models.py:999
msgid "Document favorite"
msgstr "Избранный документ"
#: build/lib/core/models.py:1006 core/models.py:1006
#: build/lib/core/models.py:1000 core/models.py:1000
msgid "Document favorites"
msgstr "Избранные документы"
#: build/lib/core/models.py:1012 core/models.py:1012
#: build/lib/core/models.py:1006 core/models.py:1006
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "Этот документ уже помечен как избранный для этого пользователя."
#: build/lib/core/models.py:1034 core/models.py:1034
#: build/lib/core/models.py:1028 core/models.py:1028
msgid "Document/user relation"
msgstr "Отношение документ/пользователь"
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:1029 core/models.py:1029
msgid "Document/user relations"
msgstr "Отношения документ/пользователь"
#: build/lib/core/models.py:1041 core/models.py:1041
#: build/lib/core/models.py:1035 core/models.py:1035
msgid "This user is already in this document."
msgstr "Этот пользователь уже имеет доступ к этому документу."
#: build/lib/core/models.py:1047 core/models.py:1047
#: build/lib/core/models.py:1041 core/models.py:1041
msgid "This team is already in this document."
msgstr "Эта команда уже имеет доступ к этому документу."
#: build/lib/core/models.py:1053 core/models.py:1053
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
#: core/models.py:1047 core/models.py:1516
msgid "Either user or team must be set, not both."
msgstr "Может быть выбран либо пользователь, либо команда, но не оба варианта сразу."
#: build/lib/core/models.py:1204 core/models.py:1204
#: build/lib/core/models.py:1198 core/models.py:1198
msgid "Document ask for access"
msgstr "Документ запрашивает доступ"
#: build/lib/core/models.py:1205 core/models.py:1205
#: build/lib/core/models.py:1199 core/models.py:1199
msgid "Document ask for accesses"
msgstr "Документ запрашивает доступы"
#: build/lib/core/models.py:1211 core/models.py:1211
#: build/lib/core/models.py:1205 core/models.py:1205
msgid "This user has already asked for access to this document."
msgstr "Этот пользователь уже запросил доступ к этому документу."
#: build/lib/core/models.py:1268 core/models.py:1268
#: build/lib/core/models.py:1262 core/models.py:1262
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "{name} хочет получить доступ к документу!"
#: build/lib/core/models.py:1272 core/models.py:1272
#: build/lib/core/models.py:1266 core/models.py:1266
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr "{name} хочет получить доступ к следующему документу:"
#: build/lib/core/models.py:1278 core/models.py:1278
#: build/lib/core/models.py:1272 core/models.py:1272
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr "{name} запрашивает доступ к документу: {title}"
#: build/lib/core/models.py:1320 core/models.py:1320
#: build/lib/core/models.py:1314 core/models.py:1314
msgid "Thread"
msgstr "Обсуждение"
#: build/lib/core/models.py:1321 core/models.py:1321
#: build/lib/core/models.py:1315 core/models.py:1315
msgid "Threads"
msgstr "Обсуждения"
#: build/lib/core/models.py:1324 build/lib/core/models.py:1376
#: core/models.py:1324 core/models.py:1376
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
#: core/models.py:1318 core/models.py:1370
msgid "Anonymous"
msgstr "Аноним"
#: build/lib/core/models.py:1371 core/models.py:1371
#: build/lib/core/models.py:1365 core/models.py:1365
msgid "Comment"
msgstr "Комментарий"
#: build/lib/core/models.py:1372 core/models.py:1372
#: build/lib/core/models.py:1366 core/models.py:1366
msgid "Comments"
msgstr "Комментарии"
#: build/lib/core/models.py:1421 core/models.py:1421
#: build/lib/core/models.py:1415 core/models.py:1415
msgid "This emoji has already been reacted to this comment."
msgstr "Этот эмодзи уже использован в этом комментарии."
#: build/lib/core/models.py:1425 core/models.py:1425
#: build/lib/core/models.py:1419 core/models.py:1419
msgid "Reaction"
msgstr "Реакция"
#: build/lib/core/models.py:1426 core/models.py:1426
#: build/lib/core/models.py:1420 core/models.py:1420
msgid "Reactions"
msgstr "Реакции"
#: build/lib/core/models.py:1436 core/models.py:1436
#: 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
msgid "email address"
msgstr "адрес электронной почты"
#: build/lib/core/models.py:1455 core/models.py:1455
#: build/lib/core/models.py:1606 core/models.py:1606
msgid "Document invitation"
msgstr "Приглашение для документа"
#: build/lib/core/models.py:1456 core/models.py:1456
#: build/lib/core/models.py:1607 core/models.py:1607
msgid "Document invitations"
msgstr "Приглашения для документов"
#: build/lib/core/models.py:1476 core/models.py:1476
#: build/lib/core/models.py:1627 core/models.py:1627
msgid "This email is already associated to a registered user."
msgstr "Этот адрес уже связан с зарегистрированным пользователем."
@@ -396,12 +446,17 @@ msgstr "Этот адрес уже связан с зарегистрирова
msgid "Logo email"
msgstr "Логотип email"
#: core/templates/mail/html/template.html:219
#: 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/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:226
#: core/templates/mail/html/template.html:224
#: core/templates/mail/text/template.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-21 09:53+0000\n"
"PO-Revision-Date: 2026-01-28 20:12\n"
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-12-09 11: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:28 core/admin.py:28
#: build/lib/core/admin.py:36 core/admin.py:36
msgid "Personal info"
msgstr "Osebni podatki"
#: build/lib/core/admin.py:41 build/lib/core/admin.py:121 core/admin.py:41
#: core/admin.py:121
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
msgid "Permissions"
msgstr "Dovoljenja"
#: build/lib/core/admin.py:53 core/admin.py:53
#: build/lib/core/admin.py:61 core/admin.py:61
msgid "Important dates"
msgstr "Pomembni datumi"
#: build/lib/core/admin.py:131 core/admin.py:131
#: build/lib/core/admin.py:147 core/admin.py:147
msgid "Tree structure"
msgstr "Drevesna struktura"
@@ -50,32 +50,40 @@ msgstr ""
msgid "Favorite"
msgstr "Priljubljena"
#: build/lib/core/api/serializers.py:505 core/api/serializers.py:505
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
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:509 core/api/serializers.py:509
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
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:545 core/api/serializers.py:545
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
msgid "This field is required."
msgstr ""
#: build/lib/core/api/serializers.py:556 core/api/serializers.py:556
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr ""
#: build/lib/core/api/viewsets.py:1122 core/api/viewsets.py:1122
#: 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
#, 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"
@@ -135,259 +143,301 @@ msgstr "Levo"
msgid "Right"
msgstr "Desno"
#: build/lib/core/models.py:81 core/models.py:81
#: build/lib/core/models.py:80 core/models.py:80
msgid "id"
msgstr ""
#: build/lib/core/models.py:82 core/models.py:82
#: build/lib/core/models.py:81 core/models.py:81
msgid "primary key for the record as UUID"
msgstr "primarni ključ za zapis kot UUID"
#: build/lib/core/models.py:88 core/models.py:88
#: build/lib/core/models.py:87 core/models.py:87
msgid "created on"
msgstr "ustvarjen na"
#: build/lib/core/models.py:89 core/models.py:89
#: build/lib/core/models.py:88 core/models.py:88
msgid "date and time at which a record was created"
msgstr "datum in čas, ko je bil zapis ustvarjen"
#: build/lib/core/models.py:94 core/models.py:94
#: build/lib/core/models.py:93 core/models.py:93
msgid "updated on"
msgstr "posodobljeno dne"
#: build/lib/core/models.py:95 core/models.py:95
#: build/lib/core/models.py:94 core/models.py:94
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:131 core/models.py:131
#: build/lib/core/models.py:130 core/models.py:130
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:142 core/models.py:142
#: build/lib/core/models.py:141 core/models.py:141
msgid "sub"
msgstr ""
#: build/lib/core/models.py:143 core/models.py:143
#: build/lib/core/models.py:142 core/models.py:142
msgid "Required. 255 characters or fewer. ASCII characters only."
msgstr ""
#: build/lib/core/models.py:151 core/models.py:151
#: build/lib/core/models.py:150 core/models.py:150
msgid "full name"
msgstr "polno ime"
#: build/lib/core/models.py:153 core/models.py:153
#: build/lib/core/models.py:152 core/models.py:152
msgid "short name"
msgstr "kratko ime"
#: build/lib/core/models.py:156 core/models.py:156
#: build/lib/core/models.py:155 core/models.py:155
msgid "identity email address"
msgstr "elektronski naslov identitete"
#: build/lib/core/models.py:161 core/models.py:161
#: build/lib/core/models.py:160 core/models.py:160
msgid "admin email address"
msgstr "elektronski naslov skrbnika"
#: build/lib/core/models.py:168 core/models.py:168
#: build/lib/core/models.py:167 core/models.py:167
msgid "language"
msgstr "jezik"
#: build/lib/core/models.py:169 core/models.py:169
#: build/lib/core/models.py:168 core/models.py:168
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:177 core/models.py:177
#: build/lib/core/models.py:176 core/models.py:176
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:180 core/models.py:180
#: build/lib/core/models.py:179 core/models.py:179
msgid "device"
msgstr "naprava"
#: build/lib/core/models.py:182 core/models.py:182
#: build/lib/core/models.py:181 core/models.py:181
msgid "Whether the user is a device or a real user."
msgstr "Ali je uporabnik naprava ali pravi uporabnik."
#: build/lib/core/models.py:185 core/models.py:185
#: build/lib/core/models.py:184 core/models.py:184
msgid "staff status"
msgstr "kadrovski status"
#: build/lib/core/models.py:187 core/models.py:187
#: build/lib/core/models.py:186 core/models.py:186
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:190 core/models.py:190
#: build/lib/core/models.py:189 core/models.py:189
msgid "active"
msgstr "aktivni"
#: build/lib/core/models.py:193 core/models.py:193
#: build/lib/core/models.py:192 core/models.py:192
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:205 core/models.py:205
#: build/lib/core/models.py:204 core/models.py:204
msgid "user"
msgstr "uporabnik"
#: build/lib/core/models.py:206 core/models.py:206
#: build/lib/core/models.py:205 core/models.py:205
msgid "users"
msgstr "uporabniki"
#: build/lib/core/models.py:362 core/models.py:362
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
#: core/models.py:361 core/models.py:1430
msgid "title"
msgstr "naslov"
#: build/lib/core/models.py:363 core/models.py:363
#: build/lib/core/models.py:362 core/models.py:362
msgid "excerpt"
msgstr "odlomek"
#: build/lib/core/models.py:412 core/models.py:412
#: build/lib/core/models.py:411 core/models.py:411
msgid "Document"
msgstr "Dokument"
#: build/lib/core/models.py:413 core/models.py:413
#: build/lib/core/models.py:412 core/models.py:412
msgid "Documents"
msgstr "Dokumenti"
#: build/lib/core/models.py:425 build/lib/core/models.py:828 core/models.py:425
#: core/models.py:828
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
#: core/models.py:824
msgid "Untitled Document"
msgstr "Dokument brez naslova"
#: build/lib/core/models.py:829 core/models.py:829
msgid "Open"
msgstr "Odpri"
#: build/lib/core/models.py:864 core/models.py:864
#: build/lib/core/models.py:859 core/models.py:859
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} je delil dokument z vami!"
#: build/lib/core/models.py:868 core/models.py:868
#: build/lib/core/models.py:863 core/models.py:863
#, 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:874 core/models.py:874
#: build/lib/core/models.py:869 core/models.py:869
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} je delil dokument z vami: {title}"
#: build/lib/core/models.py:975 core/models.py:975
#: build/lib/core/models.py:969 core/models.py:969
msgid "Document/user link trace"
msgstr "Dokument/sled povezave uporabnika"
#: build/lib/core/models.py:976 core/models.py:976
#: build/lib/core/models.py:970 core/models.py:970
msgid "Document/user link traces"
msgstr "Sledi povezav dokumenta/uporabnika"
#: build/lib/core/models.py:982 core/models.py:982
#: build/lib/core/models.py:976 core/models.py:976
msgid "A link trace already exists for this document/user."
msgstr "Za ta dokument/uporabnika že obstaja sled povezave."
#: build/lib/core/models.py:1005 core/models.py:1005
#: build/lib/core/models.py:999 core/models.py:999
msgid "Document favorite"
msgstr "Priljubljeni dokument"
#: build/lib/core/models.py:1006 core/models.py:1006
#: build/lib/core/models.py:1000 core/models.py:1000
msgid "Document favorites"
msgstr "Priljubljeni dokumenti"
#: build/lib/core/models.py:1012 core/models.py:1012
#: build/lib/core/models.py:1006 core/models.py:1006
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:1034 core/models.py:1034
#: build/lib/core/models.py:1028 core/models.py:1028
msgid "Document/user relation"
msgstr "Odnos dokument/uporabnik"
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:1029 core/models.py:1029
msgid "Document/user relations"
msgstr "Odnosi dokument/uporabnik"
#: build/lib/core/models.py:1041 core/models.py:1041
#: build/lib/core/models.py:1035 core/models.py:1035
msgid "This user is already in this document."
msgstr "Ta uporabnik je že v tem dokumentu."
#: build/lib/core/models.py:1047 core/models.py:1047
#: build/lib/core/models.py:1041 core/models.py:1041
msgid "This team is already in this document."
msgstr "Ta ekipa je že v tem dokumentu."
#: build/lib/core/models.py:1053 core/models.py:1053
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
#: core/models.py:1047 core/models.py:1516
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:1204 core/models.py:1204
#: build/lib/core/models.py:1198 core/models.py:1198
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1205 core/models.py:1205
#: build/lib/core/models.py:1199 core/models.py:1199
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1211 core/models.py:1211
#: build/lib/core/models.py:1205 core/models.py:1205
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1268 core/models.py:1268
#: build/lib/core/models.py:1262 core/models.py:1262
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1272 core/models.py:1272
#: build/lib/core/models.py:1266 core/models.py:1266
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1278 core/models.py:1278
#: build/lib/core/models.py:1272 core/models.py:1272
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1320 core/models.py:1320
#: build/lib/core/models.py:1314 core/models.py:1314
msgid "Thread"
msgstr ""
#: build/lib/core/models.py:1321 core/models.py:1321
#: build/lib/core/models.py:1315 core/models.py:1315
msgid "Threads"
msgstr ""
#: build/lib/core/models.py:1324 build/lib/core/models.py:1376
#: core/models.py:1324 core/models.py:1376
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
#: core/models.py:1318 core/models.py:1370
msgid "Anonymous"
msgstr ""
#: build/lib/core/models.py:1371 core/models.py:1371
#: build/lib/core/models.py:1365 core/models.py:1365
msgid "Comment"
msgstr ""
#: build/lib/core/models.py:1372 core/models.py:1372
#: build/lib/core/models.py:1366 core/models.py:1366
msgid "Comments"
msgstr ""
#: build/lib/core/models.py:1421 core/models.py:1421
#: build/lib/core/models.py:1415 core/models.py:1415
msgid "This emoji has already been reacted to this comment."
msgstr ""
#: build/lib/core/models.py:1425 core/models.py:1425
#: build/lib/core/models.py:1419 core/models.py:1419
msgid "Reaction"
msgstr ""
#: build/lib/core/models.py:1426 core/models.py:1426
#: build/lib/core/models.py:1420 core/models.py:1420
msgid "Reactions"
msgstr ""
#: build/lib/core/models.py:1436 core/models.py:1436
#: 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
msgid "email address"
msgstr "elektronski naslov"
#: build/lib/core/models.py:1455 core/models.py:1455
#: build/lib/core/models.py:1606 core/models.py:1606
msgid "Document invitation"
msgstr "Vabilo na dokument"
#: build/lib/core/models.py:1456 core/models.py:1456
#: build/lib/core/models.py:1607 core/models.py:1607
msgid "Document invitations"
msgstr "Vabila na dokument"
#: build/lib/core/models.py:1476 core/models.py:1476
#: build/lib/core/models.py:1627 core/models.py:1627
msgid "This email is already associated to a registered user."
msgstr "Ta e-poštni naslov je že povezan z registriranim uporabnikom."
@@ -396,12 +446,17 @@ 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:219
#: 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/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:226
#: core/templates/mail/html/template.html:224
#: core/templates/mail/text/template.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-21 09:53+0000\n"
"PO-Revision-Date: 2026-01-28 20:12\n"
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-12-09 11: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:28 core/admin.py:28
#: build/lib/core/admin.py:36 core/admin.py:36
msgid "Personal info"
msgstr "Personuppgifter"
#: build/lib/core/admin.py:41 build/lib/core/admin.py:121 core/admin.py:41
#: core/admin.py:121
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
msgid "Permissions"
msgstr "Behörigheter"
#: build/lib/core/admin.py:53 core/admin.py:53
#: build/lib/core/admin.py:61 core/admin.py:61
msgid "Important dates"
msgstr "Viktiga datum"
#: build/lib/core/admin.py:131 core/admin.py:131
#: build/lib/core/admin.py:147 core/admin.py:147
msgid "Tree structure"
msgstr ""
@@ -50,30 +50,38 @@ msgstr ""
msgid "Favorite"
msgstr "Favoriter"
#: build/lib/core/api/serializers.py:505 core/api/serializers.py:505
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
msgid "A new document was created on your behalf!"
msgstr "Ett nytt dokument skapades åt dig!"
#: build/lib/core/api/serializers.py:509 core/api/serializers.py:509
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
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:545 core/api/serializers.py:545
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
msgid "This field is required."
msgstr ""
#: build/lib/core/api/serializers.py:556 core/api/serializers.py:556
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr ""
#: build/lib/core/api/viewsets.py:1122 core/api/viewsets.py:1122
#, python-brace-format
msgid "copy of {title}"
#: build/lib/core/api/serializers.py:694 core/api/serializers.py:694
msgid "Body"
msgstr ""
#: build/lib/core/apps.py:12 core/apps.py:12
msgid "Impress core application"
#: 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
#, python-brace-format
msgid "copy of {title}"
msgstr ""
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
@@ -135,259 +143,301 @@ msgstr ""
msgid "Right"
msgstr ""
#: build/lib/core/models.py:81 core/models.py:81
#: build/lib/core/models.py:80 core/models.py:80
msgid "id"
msgstr ""
#: build/lib/core/models.py:82 core/models.py:82
#: build/lib/core/models.py:81 core/models.py:81
msgid "primary key for the record as UUID"
msgstr ""
#: build/lib/core/models.py:88 core/models.py:88
#: build/lib/core/models.py:87 core/models.py:87
msgid "created on"
msgstr ""
#: build/lib/core/models.py:89 core/models.py:89
#: build/lib/core/models.py:88 core/models.py:88
msgid "date and time at which a record was created"
msgstr ""
#: build/lib/core/models.py:94 core/models.py:94
#: build/lib/core/models.py:93 core/models.py:93
msgid "updated on"
msgstr ""
#: build/lib/core/models.py:95 core/models.py:95
#: build/lib/core/models.py:94 core/models.py:94
msgid "date and time at which a record was last updated"
msgstr ""
#: build/lib/core/models.py:131 core/models.py:131
#: build/lib/core/models.py:130 core/models.py:130
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:142 core/models.py:142
#: build/lib/core/models.py:141 core/models.py:141
msgid "sub"
msgstr ""
#: build/lib/core/models.py:143 core/models.py:143
#: build/lib/core/models.py:142 core/models.py:142
msgid "Required. 255 characters or fewer. ASCII characters only."
msgstr ""
#: build/lib/core/models.py:151 core/models.py:151
#: build/lib/core/models.py:150 core/models.py:150
msgid "full name"
msgstr ""
#: build/lib/core/models.py:153 core/models.py:153
#: build/lib/core/models.py:152 core/models.py:152
msgid "short name"
msgstr ""
#: build/lib/core/models.py:156 core/models.py:156
#: build/lib/core/models.py:155 core/models.py:155
msgid "identity email address"
msgstr ""
#: build/lib/core/models.py:161 core/models.py:161
#: build/lib/core/models.py:160 core/models.py:160
msgid "admin email address"
msgstr ""
#: build/lib/core/models.py:168 core/models.py:168
#: build/lib/core/models.py:167 core/models.py:167
msgid "language"
msgstr ""
#: build/lib/core/models.py:169 core/models.py:169
#: build/lib/core/models.py:168 core/models.py:168
msgid "The language in which the user wants to see the interface."
msgstr ""
#: build/lib/core/models.py:177 core/models.py:177
#: build/lib/core/models.py:176 core/models.py:176
msgid "The timezone in which the user wants to see times."
msgstr ""
#: build/lib/core/models.py:180 core/models.py:180
#: build/lib/core/models.py:179 core/models.py:179
msgid "device"
msgstr ""
#: build/lib/core/models.py:182 core/models.py:182
#: build/lib/core/models.py:181 core/models.py:181
msgid "Whether the user is a device or a real user."
msgstr ""
#: build/lib/core/models.py:185 core/models.py:185
#: build/lib/core/models.py:184 core/models.py:184
msgid "staff status"
msgstr ""
#: build/lib/core/models.py:187 core/models.py:187
#: build/lib/core/models.py:186 core/models.py:186
msgid "Whether the user can log into this admin site."
msgstr ""
#: build/lib/core/models.py:190 core/models.py:190
#: build/lib/core/models.py:189 core/models.py:189
msgid "active"
msgstr "aktiv"
#: build/lib/core/models.py:193 core/models.py:193
#: build/lib/core/models.py:192 core/models.py:192
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: build/lib/core/models.py:205 core/models.py:205
#: build/lib/core/models.py:204 core/models.py:204
msgid "user"
msgstr ""
#: build/lib/core/models.py:206 core/models.py:206
#: build/lib/core/models.py:205 core/models.py:205
msgid "users"
msgstr ""
#: build/lib/core/models.py:362 core/models.py:362
#: 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:363 core/models.py:363
#: build/lib/core/models.py:362 core/models.py:362
msgid "excerpt"
msgstr ""
#: build/lib/core/models.py:412 core/models.py:412
#: build/lib/core/models.py:411 core/models.py:411
msgid "Document"
msgstr ""
#: build/lib/core/models.py:413 core/models.py:413
#: build/lib/core/models.py:412 core/models.py:412
msgid "Documents"
msgstr ""
#: build/lib/core/models.py:425 build/lib/core/models.py:828 core/models.py:425
#: core/models.py:828
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
#: core/models.py:824
msgid "Untitled Document"
msgstr ""
#: build/lib/core/models.py:829 core/models.py:829
msgid "Open"
msgstr "Öppna"
#: build/lib/core/models.py:864 core/models.py:864
#: build/lib/core/models.py:859 core/models.py:859
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
#: build/lib/core/models.py:868 core/models.py:868
#: build/lib/core/models.py:863 core/models.py:863
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr ""
#: build/lib/core/models.py:874 core/models.py:874
#: build/lib/core/models.py:869 core/models.py:869
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr ""
#: build/lib/core/models.py:975 core/models.py:975
#: build/lib/core/models.py:969 core/models.py:969
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:976 core/models.py:976
#: build/lib/core/models.py:970 core/models.py:970
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:982 core/models.py:982
#: build/lib/core/models.py:976 core/models.py:976
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:1005 core/models.py:1005
#: build/lib/core/models.py:999 core/models.py:999
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:1006 core/models.py:1006
#: build/lib/core/models.py:1000 core/models.py:1000
msgid "Document favorites"
msgstr ""
#: build/lib/core/models.py:1012 core/models.py:1012
#: build/lib/core/models.py:1006 core/models.py:1006
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:1034 core/models.py:1034
#: build/lib/core/models.py:1028 core/models.py:1028
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:1029 core/models.py:1029
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:1041 core/models.py:1041
#: build/lib/core/models.py:1035 core/models.py:1035
msgid "This user is already in this document."
msgstr ""
#: build/lib/core/models.py:1047 core/models.py:1047
#: build/lib/core/models.py:1041 core/models.py:1041
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:1053 core/models.py:1053
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
#: core/models.py:1047 core/models.py:1516
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:1204 core/models.py:1204
#: build/lib/core/models.py:1198 core/models.py:1198
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1205 core/models.py:1205
#: build/lib/core/models.py:1199 core/models.py:1199
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1211 core/models.py:1211
#: build/lib/core/models.py:1205 core/models.py:1205
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1268 core/models.py:1268
#: build/lib/core/models.py:1262 core/models.py:1262
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1272 core/models.py:1272
#: build/lib/core/models.py:1266 core/models.py:1266
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1278 core/models.py:1278
#: build/lib/core/models.py:1272 core/models.py:1272
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1320 core/models.py:1320
#: build/lib/core/models.py:1314 core/models.py:1314
msgid "Thread"
msgstr ""
#: build/lib/core/models.py:1321 core/models.py:1321
#: build/lib/core/models.py:1315 core/models.py:1315
msgid "Threads"
msgstr ""
#: build/lib/core/models.py:1324 build/lib/core/models.py:1376
#: core/models.py:1324 core/models.py:1376
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
#: core/models.py:1318 core/models.py:1370
msgid "Anonymous"
msgstr ""
#: build/lib/core/models.py:1371 core/models.py:1371
#: build/lib/core/models.py:1365 core/models.py:1365
msgid "Comment"
msgstr ""
#: build/lib/core/models.py:1372 core/models.py:1372
#: build/lib/core/models.py:1366 core/models.py:1366
msgid "Comments"
msgstr ""
#: build/lib/core/models.py:1421 core/models.py:1421
#: build/lib/core/models.py:1415 core/models.py:1415
msgid "This emoji has already been reacted to this comment."
msgstr ""
#: build/lib/core/models.py:1425 core/models.py:1425
#: build/lib/core/models.py:1419 core/models.py:1419
msgid "Reaction"
msgstr ""
#: build/lib/core/models.py:1426 core/models.py:1426
#: build/lib/core/models.py:1420 core/models.py:1420
msgid "Reactions"
msgstr ""
#: build/lib/core/models.py:1436 core/models.py:1436
#: 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
msgid "email address"
msgstr "e-postadress"
#: build/lib/core/models.py:1455 core/models.py:1455
#: build/lib/core/models.py:1606 core/models.py:1606
msgid "Document invitation"
msgstr "Bjud in dokument"
#: build/lib/core/models.py:1456 core/models.py:1456
#: build/lib/core/models.py:1607 core/models.py:1607
msgid "Document invitations"
msgstr "Inbjudningar dokument"
#: build/lib/core/models.py:1476 core/models.py:1476
#: build/lib/core/models.py:1627 core/models.py:1627
msgid "This email is already associated to a registered user."
msgstr "Denna e-postadress är redan associerad med en registrerad användare."
@@ -396,12 +446,17 @@ 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:219
#: 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/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:226
#: core/templates/mail/html/template.html:224
#: core/templates/mail/text/template.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-21 09:53+0000\n"
"PO-Revision-Date: 2026-01-28 20:12\n"
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-12-09 11: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:28 core/admin.py:28
#: build/lib/core/admin.py:36 core/admin.py:36
msgid "Personal info"
msgstr ""
#: build/lib/core/admin.py:41 build/lib/core/admin.py:121 core/admin.py:41
#: core/admin.py:121
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
msgid "Permissions"
msgstr ""
#: build/lib/core/admin.py:53 core/admin.py:53
#: build/lib/core/admin.py:61 core/admin.py:61
msgid "Important dates"
msgstr ""
#: build/lib/core/admin.py:131 core/admin.py:131
#: build/lib/core/admin.py:147 core/admin.py:147
msgid "Tree structure"
msgstr ""
@@ -50,30 +50,38 @@ msgstr ""
msgid "Favorite"
msgstr ""
#: build/lib/core/api/serializers.py:505 core/api/serializers.py:505
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
msgid "A new document was created on your behalf!"
msgstr ""
#: build/lib/core/api/serializers.py:509 core/api/serializers.py:509
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
msgid "You have been granted ownership of a new document:"
msgstr ""
#: build/lib/core/api/serializers.py:545 core/api/serializers.py:545
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
msgid "This field is required."
msgstr ""
#: build/lib/core/api/serializers.py:556 core/api/serializers.py:556
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr ""
#: build/lib/core/api/viewsets.py:1122 core/api/viewsets.py:1122
#, python-brace-format
msgid "copy of {title}"
#: build/lib/core/api/serializers.py:694 core/api/serializers.py:694
msgid "Body"
msgstr ""
#: build/lib/core/apps.py:12 core/apps.py:12
msgid "Impress core application"
#: 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
#, python-brace-format
msgid "copy of {title}"
msgstr ""
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
@@ -135,259 +143,301 @@ msgstr ""
msgid "Right"
msgstr ""
#: build/lib/core/models.py:81 core/models.py:81
#: build/lib/core/models.py:80 core/models.py:80
msgid "id"
msgstr ""
#: build/lib/core/models.py:82 core/models.py:82
#: build/lib/core/models.py:81 core/models.py:81
msgid "primary key for the record as UUID"
msgstr ""
#: build/lib/core/models.py:88 core/models.py:88
#: build/lib/core/models.py:87 core/models.py:87
msgid "created on"
msgstr ""
#: build/lib/core/models.py:89 core/models.py:89
#: build/lib/core/models.py:88 core/models.py:88
msgid "date and time at which a record was created"
msgstr ""
#: build/lib/core/models.py:94 core/models.py:94
#: build/lib/core/models.py:93 core/models.py:93
msgid "updated on"
msgstr ""
#: build/lib/core/models.py:95 core/models.py:95
#: build/lib/core/models.py:94 core/models.py:94
msgid "date and time at which a record was last updated"
msgstr ""
#: build/lib/core/models.py:131 core/models.py:131
#: build/lib/core/models.py:130 core/models.py:130
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:142 core/models.py:142
#: build/lib/core/models.py:141 core/models.py:141
msgid "sub"
msgstr ""
#: build/lib/core/models.py:143 core/models.py:143
#: build/lib/core/models.py:142 core/models.py:142
msgid "Required. 255 characters or fewer. ASCII characters only."
msgstr ""
#: build/lib/core/models.py:151 core/models.py:151
#: build/lib/core/models.py:150 core/models.py:150
msgid "full name"
msgstr ""
#: build/lib/core/models.py:153 core/models.py:153
#: build/lib/core/models.py:152 core/models.py:152
msgid "short name"
msgstr ""
#: build/lib/core/models.py:156 core/models.py:156
#: build/lib/core/models.py:155 core/models.py:155
msgid "identity email address"
msgstr ""
#: build/lib/core/models.py:161 core/models.py:161
#: build/lib/core/models.py:160 core/models.py:160
msgid "admin email address"
msgstr ""
#: build/lib/core/models.py:168 core/models.py:168
#: build/lib/core/models.py:167 core/models.py:167
msgid "language"
msgstr ""
#: build/lib/core/models.py:169 core/models.py:169
#: build/lib/core/models.py:168 core/models.py:168
msgid "The language in which the user wants to see the interface."
msgstr ""
#: build/lib/core/models.py:177 core/models.py:177
#: build/lib/core/models.py:176 core/models.py:176
msgid "The timezone in which the user wants to see times."
msgstr ""
#: build/lib/core/models.py:180 core/models.py:180
#: build/lib/core/models.py:179 core/models.py:179
msgid "device"
msgstr ""
#: build/lib/core/models.py:182 core/models.py:182
#: build/lib/core/models.py:181 core/models.py:181
msgid "Whether the user is a device or a real user."
msgstr ""
#: build/lib/core/models.py:185 core/models.py:185
#: build/lib/core/models.py:184 core/models.py:184
msgid "staff status"
msgstr ""
#: build/lib/core/models.py:187 core/models.py:187
#: build/lib/core/models.py:186 core/models.py:186
msgid "Whether the user can log into this admin site."
msgstr ""
#: build/lib/core/models.py:190 core/models.py:190
#: build/lib/core/models.py:189 core/models.py:189
msgid "active"
msgstr ""
#: build/lib/core/models.py:193 core/models.py:193
#: build/lib/core/models.py:192 core/models.py:192
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: build/lib/core/models.py:205 core/models.py:205
#: build/lib/core/models.py:204 core/models.py:204
msgid "user"
msgstr ""
#: build/lib/core/models.py:206 core/models.py:206
#: build/lib/core/models.py:205 core/models.py:205
msgid "users"
msgstr ""
#: build/lib/core/models.py:362 core/models.py:362
#: 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:363 core/models.py:363
#: build/lib/core/models.py:362 core/models.py:362
msgid "excerpt"
msgstr ""
#: build/lib/core/models.py:412 core/models.py:412
#: build/lib/core/models.py:411 core/models.py:411
msgid "Document"
msgstr ""
#: build/lib/core/models.py:413 core/models.py:413
#: build/lib/core/models.py:412 core/models.py:412
msgid "Documents"
msgstr ""
#: build/lib/core/models.py:425 build/lib/core/models.py:828 core/models.py:425
#: core/models.py:828
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
#: core/models.py:824
msgid "Untitled Document"
msgstr ""
#: build/lib/core/models.py:829 core/models.py:829
msgid "Open"
msgstr ""
#: build/lib/core/models.py:864 core/models.py:864
#: build/lib/core/models.py:859 core/models.py:859
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
#: build/lib/core/models.py:868 core/models.py:868
#: build/lib/core/models.py:863 core/models.py:863
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr ""
#: build/lib/core/models.py:874 core/models.py:874
#: build/lib/core/models.py:869 core/models.py:869
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr ""
#: build/lib/core/models.py:975 core/models.py:975
#: build/lib/core/models.py:969 core/models.py:969
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:976 core/models.py:976
#: build/lib/core/models.py:970 core/models.py:970
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:982 core/models.py:982
#: build/lib/core/models.py:976 core/models.py:976
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:1005 core/models.py:1005
#: build/lib/core/models.py:999 core/models.py:999
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:1006 core/models.py:1006
#: build/lib/core/models.py:1000 core/models.py:1000
msgid "Document favorites"
msgstr ""
#: build/lib/core/models.py:1012 core/models.py:1012
#: build/lib/core/models.py:1006 core/models.py:1006
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:1034 core/models.py:1034
#: build/lib/core/models.py:1028 core/models.py:1028
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:1029 core/models.py:1029
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:1041 core/models.py:1041
#: build/lib/core/models.py:1035 core/models.py:1035
msgid "This user is already in this document."
msgstr ""
#: build/lib/core/models.py:1047 core/models.py:1047
#: build/lib/core/models.py:1041 core/models.py:1041
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:1053 core/models.py:1053
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
#: core/models.py:1047 core/models.py:1516
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:1204 core/models.py:1204
#: build/lib/core/models.py:1198 core/models.py:1198
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1205 core/models.py:1205
#: build/lib/core/models.py:1199 core/models.py:1199
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1211 core/models.py:1211
#: build/lib/core/models.py:1205 core/models.py:1205
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1268 core/models.py:1268
#: build/lib/core/models.py:1262 core/models.py:1262
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1272 core/models.py:1272
#: build/lib/core/models.py:1266 core/models.py:1266
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1278 core/models.py:1278
#: build/lib/core/models.py:1272 core/models.py:1272
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1320 core/models.py:1320
#: build/lib/core/models.py:1314 core/models.py:1314
msgid "Thread"
msgstr ""
#: build/lib/core/models.py:1321 core/models.py:1321
#: build/lib/core/models.py:1315 core/models.py:1315
msgid "Threads"
msgstr ""
#: build/lib/core/models.py:1324 build/lib/core/models.py:1376
#: core/models.py:1324 core/models.py:1376
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
#: core/models.py:1318 core/models.py:1370
msgid "Anonymous"
msgstr ""
#: build/lib/core/models.py:1371 core/models.py:1371
#: build/lib/core/models.py:1365 core/models.py:1365
msgid "Comment"
msgstr ""
#: build/lib/core/models.py:1372 core/models.py:1372
#: build/lib/core/models.py:1366 core/models.py:1366
msgid "Comments"
msgstr ""
#: build/lib/core/models.py:1421 core/models.py:1421
#: build/lib/core/models.py:1415 core/models.py:1415
msgid "This emoji has already been reacted to this comment."
msgstr ""
#: build/lib/core/models.py:1425 core/models.py:1425
#: build/lib/core/models.py:1419 core/models.py:1419
msgid "Reaction"
msgstr ""
#: build/lib/core/models.py:1426 core/models.py:1426
#: build/lib/core/models.py:1420 core/models.py:1420
msgid "Reactions"
msgstr ""
#: build/lib/core/models.py:1436 core/models.py:1436
#: 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
msgid "email address"
msgstr ""
#: build/lib/core/models.py:1455 core/models.py:1455
#: build/lib/core/models.py:1606 core/models.py:1606
msgid "Document invitation"
msgstr ""
#: build/lib/core/models.py:1456 core/models.py:1456
#: build/lib/core/models.py:1607 core/models.py:1607
msgid "Document invitations"
msgstr ""
#: build/lib/core/models.py:1476 core/models.py:1476
#: build/lib/core/models.py:1627 core/models.py:1627
msgid "This email is already associated to a registered user."
msgstr ""
@@ -396,12 +446,17 @@ msgstr ""
msgid "Logo email"
msgstr ""
#: core/templates/mail/html/template.html:219
#: 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/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:226
#: core/templates/mail/html/template.html:224
#: core/templates/mail/text/template.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-21 09:53+0000\n"
"PO-Revision-Date: 2026-01-28 20:12\n"
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-12-09 11: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:28 core/admin.py:28
#: build/lib/core/admin.py:36 core/admin.py:36
msgid "Personal info"
msgstr "Особисті дані"
#: build/lib/core/admin.py:41 build/lib/core/admin.py:121 core/admin.py:41
#: core/admin.py:121
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
msgid "Permissions"
msgstr "Дозволи"
#: build/lib/core/admin.py:53 core/admin.py:53
#: build/lib/core/admin.py:61 core/admin.py:61
msgid "Important dates"
msgstr "Важливі дати"
#: build/lib/core/admin.py:131 core/admin.py:131
#: build/lib/core/admin.py:147 core/admin.py:147
msgid "Tree structure"
msgstr "Ієрархічна структура"
@@ -50,32 +50,40 @@ msgstr "Приховано"
msgid "Favorite"
msgstr "Обране"
#: build/lib/core/api/serializers.py:505 core/api/serializers.py:505
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
msgid "A new document was created on your behalf!"
msgstr "Новий документ був створений від вашого імені!"
#: build/lib/core/api/serializers.py:509 core/api/serializers.py:509
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
msgid "You have been granted ownership of a new document:"
msgstr "Ви тепер є власником нового документа:"
#: build/lib/core/api/serializers.py:545 core/api/serializers.py:545
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
msgid "This field is required."
msgstr "Це поле є обов’язковим."
#: build/lib/core/api/serializers.py:556 core/api/serializers.py:556
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr "Доступ до посилання '%(link_reach)s' заборонено на основі конфігурації батьківського документа."
#: build/lib/core/api/viewsets.py:1122 core/api/viewsets.py:1122
#: 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
#, 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"
@@ -135,259 +143,301 @@ msgstr "Ліворуч"
msgid "Right"
msgstr "Праворуч"
#: build/lib/core/models.py:81 core/models.py:81
#: build/lib/core/models.py:80 core/models.py:80
msgid "id"
msgstr "id"
#: build/lib/core/models.py:82 core/models.py:82
#: build/lib/core/models.py:81 core/models.py:81
msgid "primary key for the record as UUID"
msgstr "первинний ключ для запису як UUID"
#: build/lib/core/models.py:88 core/models.py:88
#: build/lib/core/models.py:87 core/models.py:87
msgid "created on"
msgstr "створено"
#: build/lib/core/models.py:89 core/models.py:89
#: build/lib/core/models.py:88 core/models.py:88
msgid "date and time at which a record was created"
msgstr "дата і час, коли запис було створено"
#: build/lib/core/models.py:94 core/models.py:94
#: build/lib/core/models.py:93 core/models.py:93
msgid "updated on"
msgstr "оновлено"
#: build/lib/core/models.py:95 core/models.py:95
#: build/lib/core/models.py:94 core/models.py:94
msgid "date and time at which a record was last updated"
msgstr "дата і час, коли запис був востаннє оновлений"
#: build/lib/core/models.py:131 core/models.py:131
#: build/lib/core/models.py:130 core/models.py:130
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:142 core/models.py:142
#: build/lib/core/models.py:141 core/models.py:141
msgid "sub"
msgstr "вкладений документ"
#: build/lib/core/models.py:143 core/models.py:143
#: build/lib/core/models.py:142 core/models.py:142
msgid "Required. 255 characters or fewer. ASCII characters only."
msgstr "Обов'язкове. 255 символів або менше. Тільки символи ASCII."
#: build/lib/core/models.py:151 core/models.py:151
#: build/lib/core/models.py:150 core/models.py:150
msgid "full name"
msgstr "повне ім'я"
#: build/lib/core/models.py:153 core/models.py:153
#: build/lib/core/models.py:152 core/models.py:152
msgid "short name"
msgstr "коротке ім'я"
#: build/lib/core/models.py:156 core/models.py:156
#: build/lib/core/models.py:155 core/models.py:155
msgid "identity email address"
msgstr "адреса електронної пошти особи"
#: build/lib/core/models.py:161 core/models.py:161
#: build/lib/core/models.py:160 core/models.py:160
msgid "admin email address"
msgstr "електронна адреса адміністратора"
#: build/lib/core/models.py:168 core/models.py:168
#: build/lib/core/models.py:167 core/models.py:167
msgid "language"
msgstr "мова"
#: build/lib/core/models.py:169 core/models.py:169
#: build/lib/core/models.py:168 core/models.py:168
msgid "The language in which the user wants to see the interface."
msgstr "Мова, якою користувач хоче бачити інтерфейс."
#: build/lib/core/models.py:177 core/models.py:177
#: build/lib/core/models.py:176 core/models.py:176
msgid "The timezone in which the user wants to see times."
msgstr "Часовий пояс, в якому користувач хоче бачити час."
#: build/lib/core/models.py:180 core/models.py:180
#: build/lib/core/models.py:179 core/models.py:179
msgid "device"
msgstr "пристрій"
#: build/lib/core/models.py:182 core/models.py:182
#: build/lib/core/models.py:181 core/models.py:181
msgid "Whether the user is a device or a real user."
msgstr "Чи є користувач пристроєм чи реальним користувачем."
#: build/lib/core/models.py:185 core/models.py:185
#: build/lib/core/models.py:184 core/models.py:184
msgid "staff status"
msgstr "статус співробітника"
#: build/lib/core/models.py:187 core/models.py:187
#: build/lib/core/models.py:186 core/models.py:186
msgid "Whether the user can log into this admin site."
msgstr "Чи може користувач увійти на цей сайт адміністратора."
#: build/lib/core/models.py:190 core/models.py:190
#: build/lib/core/models.py:189 core/models.py:189
msgid "active"
msgstr "активний"
#: build/lib/core/models.py:193 core/models.py:193
#: build/lib/core/models.py:192 core/models.py:192
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "Чи слід ставитися до цього користувача як до активного. Зніміть вибір замість видалення облікового запису."
#: build/lib/core/models.py:205 core/models.py:205
#: build/lib/core/models.py:204 core/models.py:204
msgid "user"
msgstr "користувач"
#: build/lib/core/models.py:206 core/models.py:206
#: build/lib/core/models.py:205 core/models.py:205
msgid "users"
msgstr "користувачі"
#: build/lib/core/models.py:362 core/models.py:362
#: 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:363 core/models.py:363
#: build/lib/core/models.py:362 core/models.py:362
msgid "excerpt"
msgstr "уривок"
#: build/lib/core/models.py:412 core/models.py:412
#: build/lib/core/models.py:411 core/models.py:411
msgid "Document"
msgstr "Документ"
#: build/lib/core/models.py:413 core/models.py:413
#: build/lib/core/models.py:412 core/models.py:412
msgid "Documents"
msgstr "Документи"
#: build/lib/core/models.py:425 build/lib/core/models.py:828 core/models.py:425
#: core/models.py:828
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
#: core/models.py:824
msgid "Untitled Document"
msgstr "Документ без назви"
#: build/lib/core/models.py:829 core/models.py:829
msgid "Open"
msgstr "Відкрити"
#: build/lib/core/models.py:864 core/models.py:864
#: build/lib/core/models.py:859 core/models.py:859
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} ділиться з вами документом!"
#: build/lib/core/models.py:868 core/models.py:868
#: build/lib/core/models.py:863 core/models.py:863
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} запрошує вас для роботи з документом із роллю \"{role}\":"
#: build/lib/core/models.py:874 core/models.py:874
#: build/lib/core/models.py:869 core/models.py:869
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} ділиться з вами документом: {title}"
#: build/lib/core/models.py:975 core/models.py:975
#: build/lib/core/models.py:969 core/models.py:969
msgid "Document/user link trace"
msgstr "Трасування посилання Документ/користувач"
#: build/lib/core/models.py:976 core/models.py:976
#: build/lib/core/models.py:970 core/models.py:970
msgid "Document/user link traces"
msgstr "Трасування посилань Документ/користувач"
#: build/lib/core/models.py:982 core/models.py:982
#: build/lib/core/models.py:976 core/models.py:976
msgid "A link trace already exists for this document/user."
msgstr "Відстеження вже існуючих посилань для цього документа/користувача."
#: build/lib/core/models.py:1005 core/models.py:1005
#: build/lib/core/models.py:999 core/models.py:999
msgid "Document favorite"
msgstr "Обраний документ"
#: build/lib/core/models.py:1006 core/models.py:1006
#: build/lib/core/models.py:1000 core/models.py:1000
msgid "Document favorites"
msgstr "Обрані документи"
#: build/lib/core/models.py:1012 core/models.py:1012
#: build/lib/core/models.py:1006 core/models.py:1006
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "Цей документ вже вказаний як обраний для одного користувача."
#: build/lib/core/models.py:1034 core/models.py:1034
#: build/lib/core/models.py:1028 core/models.py:1028
msgid "Document/user relation"
msgstr "Відносини документ/користувач"
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:1029 core/models.py:1029
msgid "Document/user relations"
msgstr "Відносини документ/користувач"
#: build/lib/core/models.py:1041 core/models.py:1041
#: build/lib/core/models.py:1035 core/models.py:1035
msgid "This user is already in this document."
msgstr "Цей користувач вже має доступ до цього документу."
#: build/lib/core/models.py:1047 core/models.py:1047
#: build/lib/core/models.py:1041 core/models.py:1041
msgid "This team is already in this document."
msgstr "Ця команда вже має доступ до цього документа."
#: build/lib/core/models.py:1053 core/models.py:1053
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
#: core/models.py:1047 core/models.py:1516
msgid "Either user or team must be set, not both."
msgstr "Вкажіть користувача або команду, а не обох."
#: build/lib/core/models.py:1204 core/models.py:1204
#: build/lib/core/models.py:1198 core/models.py:1198
msgid "Document ask for access"
msgstr "Запит доступу до документа"
#: build/lib/core/models.py:1205 core/models.py:1205
#: build/lib/core/models.py:1199 core/models.py:1199
msgid "Document ask for accesses"
msgstr "Запит доступу для документа"
#: build/lib/core/models.py:1211 core/models.py:1211
#: build/lib/core/models.py:1205 core/models.py:1205
msgid "This user has already asked for access to this document."
msgstr "Цей користувач вже попросив доступ до цього документа."
#: build/lib/core/models.py:1268 core/models.py:1268
#: build/lib/core/models.py:1262 core/models.py:1262
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "{name} хоче отримати доступ до документа!"
#: build/lib/core/models.py:1272 core/models.py:1272
#: build/lib/core/models.py:1266 core/models.py:1266
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr "{name} бажає отримати доступ до наступного документа:"
#: build/lib/core/models.py:1278 core/models.py:1278
#: build/lib/core/models.py:1272 core/models.py:1272
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr "{name} запитує доступ до документа: {title}"
#: build/lib/core/models.py:1320 core/models.py:1320
#: build/lib/core/models.py:1314 core/models.py:1314
msgid "Thread"
msgstr "Обговорення"
#: build/lib/core/models.py:1321 core/models.py:1321
#: build/lib/core/models.py:1315 core/models.py:1315
msgid "Threads"
msgstr "Обговорення"
#: build/lib/core/models.py:1324 build/lib/core/models.py:1376
#: core/models.py:1324 core/models.py:1376
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
#: core/models.py:1318 core/models.py:1370
msgid "Anonymous"
msgstr "Анонім"
#: build/lib/core/models.py:1371 core/models.py:1371
#: build/lib/core/models.py:1365 core/models.py:1365
msgid "Comment"
msgstr "Коментар"
#: build/lib/core/models.py:1372 core/models.py:1372
#: build/lib/core/models.py:1366 core/models.py:1366
msgid "Comments"
msgstr "Коментарі"
#: build/lib/core/models.py:1421 core/models.py:1421
#: build/lib/core/models.py:1415 core/models.py:1415
msgid "This emoji has already been reacted to this comment."
msgstr "Цим емодзі вже відреагували на цей коментар."
#: build/lib/core/models.py:1425 core/models.py:1425
#: build/lib/core/models.py:1419 core/models.py:1419
msgid "Reaction"
msgstr "Реакція"
#: build/lib/core/models.py:1426 core/models.py:1426
#: build/lib/core/models.py:1420 core/models.py:1420
msgid "Reactions"
msgstr "Реакції"
#: build/lib/core/models.py:1436 core/models.py:1436
#: 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
msgid "email address"
msgstr "електронна адреса"
#: build/lib/core/models.py:1455 core/models.py:1455
#: build/lib/core/models.py:1606 core/models.py:1606
msgid "Document invitation"
msgstr "Запрошення до редагування документа"
#: build/lib/core/models.py:1456 core/models.py:1456
#: build/lib/core/models.py:1607 core/models.py:1607
msgid "Document invitations"
msgstr "Запрошення до редагування документів"
#: build/lib/core/models.py:1476 core/models.py:1476
#: build/lib/core/models.py:1627 core/models.py:1627
msgid "This email is already associated to a registered user."
msgstr "Ця електронна пошта вже пов'язана з зареєстрованим користувачем."
@@ -396,12 +446,17 @@ msgstr "Ця електронна пошта вже пов'язана з зар
msgid "Logo email"
msgstr "Логотип пошти"
#: core/templates/mail/html/template.html:219
#: 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/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:226
#: core/templates/mail/html/template.html:224
#: core/templates/mail/text/template.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-21 09:53+0000\n"
"PO-Revision-Date: 2026-01-28 20:12\n"
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-12-09 11:12\n"
"Last-Translator: \n"
"Language-Team: Chinese Simplified\n"
"Language: zh_CN\n"
@@ -17,115 +17,123 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:28 core/admin.py:28
#: build/lib/core/admin.py:36 core/admin.py:36
msgid "Personal info"
msgstr "個人資訊"
msgstr "个人信息"
#: build/lib/core/admin.py:41 build/lib/core/admin.py:121 core/admin.py:41
#: core/admin.py:121
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
msgid "Permissions"
msgstr "限"
msgstr "限"
#: build/lib/core/admin.py:53 core/admin.py:53
#: build/lib/core/admin.py:61 core/admin.py:61
msgid "Important dates"
msgstr "重要日期"
#: build/lib/core/admin.py:131 core/admin.py:131
#: build/lib/core/admin.py:147 core/admin.py:147
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:505 core/api/serializers.py:505
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
msgid "A new document was created on your behalf!"
msgstr "已代表您建立新文"
msgstr "已为您创建了一份新文"
#: build/lib/core/api/serializers.py:509 core/api/serializers.py:509
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
msgid "You have been granted ownership of a new document:"
msgstr "您已獲得新文的所有"
msgstr "您已被授予新文的所有"
#: build/lib/core/api/serializers.py:545 core/api/serializers.py:545
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
msgid "This field is required."
msgstr "此欄位為必填。"
msgstr "必填字段。"
#: build/lib/core/api/serializers.py:556 core/api/serializers.py:556
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr "根據父文件設定,不允許連結範圍「%(link_reach)s」。"
msgstr ""
#: build/lib/core/api/viewsets.py:1122 core/api/viewsets.py:1122
#: 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
#, 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"
@@ -135,275 +143,322 @@ msgstr "左"
msgid "Right"
msgstr "右"
#: build/lib/core/models.py:81 core/models.py:81
#: build/lib/core/models.py:80 core/models.py:80
msgid "id"
msgstr "ID"
msgstr "id"
#: build/lib/core/models.py:82 core/models.py:82
#: build/lib/core/models.py:81 core/models.py:81
msgid "primary key for the record as UUID"
msgstr "記錄的主鍵(UUID"
msgstr "记录的主密钥为 UUID"
#: build/lib/core/models.py:87 core/models.py:87
msgid "created on"
msgstr "创建时间"
#: build/lib/core/models.py:88 core/models.py:88
msgid "created on"
msgstr "建立於"
#: build/lib/core/models.py:89 core/models.py:89
msgid "date and time at which a record was created"
msgstr "記錄建立的日期與時間"
msgstr "记录的创建日期和时间"
#: build/lib/core/models.py:93 core/models.py:93
msgid "updated on"
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:131 core/models.py:131
#: build/lib/core/models.py:130 core/models.py:130
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:142 core/models.py:142
#: build/lib/core/models.py:141 core/models.py:141
msgid "sub"
msgstr "sub"
#: build/lib/core/models.py:143 core/models.py:143
#: build/lib/core/models.py:142 core/models.py:142
msgid "Required. 255 characters or fewer. ASCII characters only."
msgstr "必填。255 個字元(含)以下。僅限 ASCII 字元。"
msgstr "必填项。限255个字符以内。仅支持ASCII字符。"
#: build/lib/core/models.py:151 core/models.py:151
#: build/lib/core/models.py:150 core/models.py:150
msgid "full name"
msgstr "全名"
#: build/lib/core/models.py:153 core/models.py:153
#: build/lib/core/models.py:152 core/models.py:152
msgid "short name"
msgstr "簡稱"
msgstr "简称"
#: build/lib/core/models.py:156 core/models.py:156
#: build/lib/core/models.py:155 core/models.py:155
msgid "identity email address"
msgstr "身份驗證電子郵件地址"
msgstr "身份电子邮件地址"
#: build/lib/core/models.py:161 core/models.py:161
#: build/lib/core/models.py:160 core/models.py:160
msgid "admin email address"
msgstr "管理員電子郵件地址"
msgstr "管理员电子邮件地址"
#: build/lib/core/models.py:167 core/models.py:167
msgid "language"
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:177 core/models.py:177
#: build/lib/core/models.py:176 core/models.py:176
msgid "The timezone in which the user wants to see times."
msgstr "使用者希望時間顯示的時區。"
msgstr "用户查看时间希望的时区。"
#: build/lib/core/models.py:180 core/models.py:180
#: build/lib/core/models.py:179 core/models.py:179
msgid "device"
msgstr "裝置"
msgstr "设备"
#: build/lib/core/models.py:182 core/models.py:182
#: build/lib/core/models.py:181 core/models.py:181
msgid "Whether the user is a device or a real user."
msgstr "使用者是裝置還是真實使用者。"
msgstr "用户是设备还是真实用户。"
#: build/lib/core/models.py:185 core/models.py:185
#: build/lib/core/models.py:184 core/models.py:184
msgid "staff status"
msgstr "工作人員狀態"
msgstr "员工状态"
#: build/lib/core/models.py:187 core/models.py:187
#: build/lib/core/models.py:186 core/models.py:186
msgid "Whether the user can log into this admin site."
msgstr "使用者是否可以登入此管理後台。"
msgstr "用户是否可以登录该管理员站点。"
#: build/lib/core/models.py:190 core/models.py:190
#: build/lib/core/models.py:189 core/models.py:189
msgid "active"
msgstr "啟用"
msgstr "激活"
#: build/lib/core/models.py:193 core/models.py:193
#: build/lib/core/models.py:192 core/models.py:192
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "此使用者是否應被視為處於啟用狀態。請取消勾選此項而非刪除帳號。"
msgstr "是否应将此用户视为活跃用户。取消选择此选项而不是删除账户。"
#: build/lib/core/models.py:204 core/models.py:204
msgid "user"
msgstr "用户"
#: build/lib/core/models.py:205 core/models.py:205
msgid "user"
msgstr "使用者"
#: build/lib/core/models.py:206 core/models.py:206
msgid "users"
msgstr "使用者"
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: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:412 core/models.py:412
#: build/lib/core/models.py:411 core/models.py:411
msgid "Document"
msgstr "文"
msgstr "文"
#: build/lib/core/models.py:413 core/models.py:413
#: build/lib/core/models.py:412 core/models.py:412
msgid "Documents"
msgstr "文件"
msgstr "个文档"
#: build/lib/core/models.py:425 build/lib/core/models.py:828 core/models.py:425
#: core/models.py:828
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
#: core/models.py:824
msgid "Untitled Document"
msgstr "未命名文"
msgstr "未命名文"
#: build/lib/core/models.py:829 core/models.py:829
msgid "Open"
msgstr "開啟"
#: build/lib/core/models.py:864 core/models.py:864
#: build/lib/core/models.py:859 core/models.py:859
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} 與您分享了一份文件"
msgstr "{name} 与您共享了一个文档"
#: build/lib/core/models.py:868 core/models.py:868
#: build/lib/core/models.py:863 core/models.py:863
#, 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:874 core/models.py:874
#: build/lib/core/models.py:869 core/models.py:869
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} 與您分享了一份文件{title}"
msgstr "{name} 与您共享了一个文档{title}"
#: build/lib/core/models.py:975 core/models.py:975
#: build/lib/core/models.py:969 core/models.py:969
msgid "Document/user link trace"
msgstr "文件/使用者連結追蹤"
msgstr "文档/用户链接跟踪"
#: build/lib/core/models.py:970 core/models.py:970
msgid "Document/user link traces"
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:1005 core/models.py:1005
#: build/lib/core/models.py:999 core/models.py:999
msgid "Document favorite"
msgstr "文收藏"
msgstr "文收藏"
#: build/lib/core/models.py:1000 core/models.py:1000
msgid "Document favorites"
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:1034 core/models.py:1034
#: build/lib/core/models.py:1028 core/models.py:1028
msgid "Document/user relation"
msgstr "文件/使用者關聯"
msgstr "文档/用户关系"
#: build/lib/core/models.py:1029 core/models.py:1029
msgid "Document/user relations"
msgstr "文档/用户关系集"
#: build/lib/core/models.py:1035 core/models.py:1035
msgid "Document/user relations"
msgstr "文件/使用者關聯"
msgid "This user is already in this document."
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:1053 core/models.py:1053
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
#: core/models.py:1047 core/models.py:1516
msgid "Either user or team must be set, not both."
msgstr "必須設定使用者或團隊其中之一,不能同時設定兩者。"
msgstr "必须设置用户或团队之一,不能同时设置两者。"
#: build/lib/core/models.py:1204 core/models.py:1204
#: build/lib/core/models.py:1198 core/models.py:1198
msgid "Document ask for access"
msgstr "要求文件存取權"
msgstr "文档需要访问权限"
#: build/lib/core/models.py:1199 core/models.py:1199
msgid "Document ask for accesses"
msgstr "文档需要访问权限"
#: build/lib/core/models.py:1205 core/models.py:1205
msgid "Document ask for accesses"
msgstr "要求文件存取權"
#: build/lib/core/models.py:1211 core/models.py:1211
msgid "This user has already asked for access to this document."
msgstr "此使用者已要求過存取此文件的權限。"
msgstr "用户已申请该文档的访问权限。"
#: build/lib/core/models.py:1268 core/models.py:1268
#: build/lib/core/models.py:1262 core/models.py:1262
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "{name} 想要存取文件"
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} 申请访问以下文档:"
#: 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:1320 core/models.py:1320
#: build/lib/core/models.py:1314 core/models.py:1314
msgid "Thread"
msgstr "對話串"
msgstr ""
#: build/lib/core/models.py:1321 core/models.py:1321
#: build/lib/core/models.py:1315 core/models.py:1315
msgid "Threads"
msgstr "對話串"
msgstr ""
#: build/lib/core/models.py:1324 build/lib/core/models.py:1376
#: core/models.py:1324 core/models.py:1376
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
#: core/models.py:1318 core/models.py:1370
msgid "Anonymous"
msgstr "匿名"
msgstr ""
#: build/lib/core/models.py:1371 core/models.py:1371
#: build/lib/core/models.py:1365 core/models.py:1365
msgid "Comment"
msgstr "評論"
msgstr ""
#: build/lib/core/models.py:1372 core/models.py:1372
#: build/lib/core/models.py:1366 core/models.py:1366
msgid "Comments"
msgstr "評論"
msgstr ""
#: build/lib/core/models.py:1421 core/models.py:1421
#: build/lib/core/models.py:1415 core/models.py:1415
msgid "This emoji has already been reacted to this comment."
msgstr "此評論已標記過此表情符號。"
msgstr ""
#: build/lib/core/models.py:1425 core/models.py:1425
#: build/lib/core/models.py:1419 core/models.py:1419
msgid "Reaction"
msgstr "回應"
msgstr ""
#: build/lib/core/models.py:1426 core/models.py:1426
#: build/lib/core/models.py:1420 core/models.py:1420
msgid "Reactions"
msgstr "回應"
msgstr ""
#: build/lib/core/models.py:1436 core/models.py:1436
#: 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
msgid "email address"
msgstr "電子郵件地址"
msgstr "电子邮件地址"
#: build/lib/core/models.py:1455 core/models.py:1455
#: build/lib/core/models.py:1606 core/models.py:1606
msgid "Document invitation"
msgstr "文件邀請"
msgstr "文档邀请"
#: build/lib/core/models.py:1456 core/models.py:1456
#: build/lib/core/models.py:1607 core/models.py:1607
msgid "Document invitations"
msgstr "文件邀請"
msgstr "文档邀请"
#: build/lib/core/models.py:1476 core/models.py:1476
#: build/lib/core/models.py:1627 core/models.py:1627
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:219
#: 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/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:226
#: core/templates/mail/html/template.html:224
#: core/templates/mail/text/template.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
msgstr " 由 %(brandname)s 提供 "
msgstr " 由 %(brandname)s 倾力打造。 "

View File

@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "impress"
version = "4.5.0"
version = "4.2.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 = "Docs is a collaborative text editor designed to address common challenges in knowledge building and sharing."
description = "An application to print markdown to pdf from a set of managed templates."
keywords = ["Django", "Contacts", "Templates", "RBAC"]
license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"beautifulsoup4==4.14.3",
"boto3==1.42.17",
"beautifulsoup4==4.14.2",
"boto3==1.40.74",
"Brotli==1.2.0",
"celery[redis]==5.5.3",
"django-configurations==2.5.1",
"django-cors-headers==4.9.0",
"django-countries==8.2.0",
"django-countries==8.1.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<6.0.0",
"django-treebeard==4.8.0",
"django==5.2.9",
"django-treebeard==4.7.1",
"djangorestframework==3.16.1",
"drf_spectacular==0.29.0",
"dockerflow==2024.4.2",
@@ -48,19 +48,18 @@ 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==5.0.2",
"mozilla-django-oidc==4.0.1",
"nested-multipart-parser==1.6.0",
"openai==2.14.0",
"psycopg[binary]==3.3.2",
"pycrdt==0.12.44",
"openai==2.8.0",
"psycopg[binary]==3.2.12",
"pycrdt==0.12.43",
"PyJWT==2.10.1",
"python-magic==0.4.27",
"redis<6.0.0",
"requests==2.32.5",
"sentry-sdk==2.48.0",
"sentry-sdk==2.44.0",
"whitenoise==6.11.0",
]
@@ -74,20 +73,20 @@ dependencies = [
dev = [
"django-extensions==4.1",
"django-test-migrations==1.5.0",
"drf-spectacular-sidecar==2025.12.1",
"drf-spectacular-sidecar==2025.10.1",
"freezegun==1.5.5",
"ipdb==0.13.13",
"ipython==9.8.0",
"pyfakefs==6.0.0",
"ipython==9.7.0",
"pyfakefs==5.10.2",
"pylint-django==2.6.1",
"pylint<4.0.0",
"pytest-cov==7.0.0",
"pytest-django==4.11.1",
"pytest==9.0.2",
"pytest==9.0.1",
"pytest-icdiff==0.9",
"pytest-xdist==3.8.0",
"responses==0.25.8",
"ruff==0.14.10",
"ruff==0.14.5",
"types-requests==2.32.4.20250913",
]

View File

@@ -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

View File

@@ -1,60 +0,0 @@
![473389927-e4ff1794-69f3-460a-85f8-fec993cd74d6.png](http://localhost:3000/assets/logo-suite-numerique.png)![497094770-53e5f8e2-c93e-4a0b-a82f-cd184fd03f51.svg](http://localhost:3000/assets/icon-docs.svg)
# 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.

View File

@@ -126,20 +126,6 @@ test.describe('Config', () => {
).toBeAttached();
});
test('it checks FRONTEND_JS_URL config', async ({ page }) => {
await overrideConfig(page, {
FRONTEND_JS_URL: 'http://localhost:123465/js/script.js',
});
await page.goto('/');
await expect(
page
.locator('script[src="http://localhost:123465/js/script.js"]')
.first(),
).toBeAttached();
});
test('it checks theme_customization.translations config', async ({
page,
}) => {
@@ -159,6 +145,10 @@ test.describe('Config', () => {
await expect(page.getByText('MyCustomDocs')).toBeAttached();
});
});
test.describe('Config: Not logged', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('it checks the config api is called', async ({ page }) => {
const responsePromise = page.waitForResponse(
@@ -178,10 +168,6 @@ test.describe('Config', () => {
expect(configApi).toStrictEqual(CONFIG_LEFT);
});
});
test.describe('Config: Not logged', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('it checks that theme is configured from config endpoint', async ({
page,

View File

@@ -58,7 +58,7 @@ test.describe('Doc Comments', () => {
await page.getByRole('button', { name: '👍' }).click();
await expect(
thread.getByRole('img', { name: `E2E ${browserName}` }).first(),
thread.getByRole('img', { name: 'E2E Chromium' }).first(),
).toBeVisible();
await expect(thread.getByText('This is a comment').first()).toBeVisible();
await expect(thread.getByText(`E2E ${browserName}`).first()).toBeVisible();
@@ -153,7 +153,7 @@ test.describe('Doc Comments', () => {
await thread.getByRole('menuitem', { name: 'Edit comment' }).click();
const commentEditor = thread.getByText('This is a comment').first();
await commentEditor.fill('This is an edited comment');
const saveBtn = thread.locator('button[data-test="save"]').first();
const saveBtn = thread.getByRole('button', { name: 'Save' });
await saveBtn.click();
await expect(saveBtn).toBeHidden();
await expect(
@@ -163,8 +163,7 @@ test.describe('Doc Comments', () => {
// Add second comment
await thread.getByRole('paragraph').last().fill('This is a second comment');
await saveBtn.click();
await expect(saveBtn).toBeHidden();
await thread.getByRole('button', { name: 'Save' }).click();
await expect(
thread.getByText('This is an edited comment').first(),
).toBeVisible();
@@ -372,7 +371,7 @@ test.describe('Doc Comments', () => {
test.describe('Doc Comments mobile', () => {
test.use({ viewport: { width: 500, height: 1200 } });
test('Can comments on mobile', async ({ page, browserName }) => {
test('Comments are not visible on mobile', async ({ page, browserName }) => {
const [title] = await createDoc(
page,
'comment-mobile',
@@ -388,18 +387,7 @@ test.describe('Doc Comments mobile', () => {
// Checks add react reaction
const editor = await writeInEditor({ page, text: 'Hello' });
await editor.getByText('Hello').selectText();
await page.getByRole('button', { name: 'Comment' }).click();
const thread = page.locator('.bn-thread');
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();
await expect(thread.getByText('This is a comment').first()).toBeVisible();
await expect(page.getByRole('button', { name: 'Comment' })).toBeHidden();
await expect(page.getByRole('button', { name: 'Paragraph' })).toBeVisible();
});
});

View File

@@ -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();
@@ -880,18 +880,14 @@ test.describe('Doc Editor', () => {
// Wait for the interlink to be created and rendered
const editor = await getEditor({ page });
const interlinkChild = editor
.locator('.--docs--interlinking-link-inline-content')
.first();
const interlinkChild2 = editor.getByRole('button', {
name: docChild2,
});
await expect(interlinkChild).toBeVisible({ timeout: 10000 });
await expect(interlinkChild).toContainText('😀');
await expect(interlinkChild).toContainText(docChild2);
await expect(interlinkChild.locator('svg').first()).toBeHidden();
await interlinkChild.click();
// wait for navigation to complete
await page.waitForTimeout(1000);
await expect(interlinkChild2).toBeVisible({ timeout: 10000 });
await expect(interlinkChild2).toContainText('😀');
await expect(interlinkChild2.locator('svg').first()).toBeHidden();
await interlinkChild2.click();
await verifyDocName(page, docChild2);
@@ -901,9 +897,11 @@ test.describe('Doc Editor', () => {
await input.fill(docChild1);
await searchContainer.getByText(docChild1).click();
await expect(interlinkChild).toContainText(docChild1);
await expect(interlinkChild).toBeVisible({ timeout: 10000 });
await expect(interlinkChild.locator('svg').first()).toBeVisible();
const interlinkChild1 = editor.getByRole('button', {
name: docChild1,
});
await expect(interlinkChild1).toBeVisible({ timeout: 10000 });
await expect(interlinkChild1.locator('svg').first()).toBeVisible();
await page.keyboard.press('@');
@@ -963,40 +961,16 @@ test.describe('Doc Editor', () => {
test('it embeds PDF', async ({ page, browserName }) => {
await createDoc(page, 'doc-toolbar', browserName, 1);
await page.getByRole('button', { name: 'Share' }).click();
await updateShareLink(page, 'Public', 'Reading');
await page.getByRole('button', { name: 'Close the share modal' }).click();
await openSuggestionMenu({ page });
await page.getByText('Embed a PDF file').click();
const pdfBlock = page.locator('div[data-content-type="pdf"]').last();
const pdfBlock = page.locator('div[data-content-type="pdf"]').first();
await expect(pdfBlock).toBeVisible();
// Try with invalid PDF first
await page
.getByText(/Add (PDF|file)/)
.first()
.click();
await page.locator('[data-test="embed-tab"]').click();
await page
.locator('[data-test="embed-input"]')
.fill('https://example.test/test.test');
await page.locator('[data-test="embed-input-button"]').click();
await expect(page.getByText('Invalid or missing PDF file')).toBeVisible();
await openSuggestionMenu({ page });
await page.getByText('Embed a PDF file').click();
// Now with a valid PDF
await page.getByText(/Add (PDF|file)/).click();
const fileChooserPromise = page.waitForEvent('filechooser');
const downloadPromise = page.waitForEvent('download');
await page.getByText(/Upload (PDF|file)/).click();
const fileChooser = await fileChooserPromise;
@@ -1005,16 +979,24 @@ test.describe('Doc Editor', () => {
// Wait for the media-check to be processed
await page.waitForTimeout(1000);
const pdfIframe = page
.locator('.--docs--editor-container iframe.bn-visual-media')
const pdfEmbed = page
.locator('.--docs--editor-container embed.bn-visual-media')
.first();
// Check src of pdf
expect(await pdfIframe.getAttribute('src')).toMatch(
expect(await pdfEmbed.getAttribute('src')).toMatch(
/http:\/\/localhost:8083\/media\/.*\/attachments\/.*.pdf/,
);
await expect(pdfIframe).toHaveAttribute('role', 'presentation');
await expect(pdfEmbed).toHaveAttribute('type', 'application/pdf');
await expect(pdfEmbed).toHaveAttribute('role', 'presentation');
// Check download with original filename
await page.locator('.bn-block-content[data-content-type="pdf"]').click();
await page.locator('[data-test="downloadfile"]').click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe('test-pdf.pdf');
});
test('it preserves text when switching between mobile and desktop views', async ({

View File

@@ -12,7 +12,7 @@ import {
waitForLanguageSwitch,
} from './utils-common';
import { openSuggestionMenu, writeInEditor } from './utils-editor';
import { comparePDFWithAssetFolder, overrideDocContent } from './utils-export';
import { createRootSubPage } from './utils-sub-pages';
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -32,9 +32,10 @@ test.describe('Doc Export', () => {
await expect(page.getByTestId('modal-export-title')).toBeVisible();
await expect(
page.getByText(
'Export your document to print or download in .docx, .odt, .pdf or .html(zip) format.',
),
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(
@@ -45,14 +46,81 @@ test.describe('Doc Export', () => {
await expect(page.getByTestId('doc-export-download-button')).toBeVisible();
});
/**
* 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 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);
});
test('it exports the doc to docx', async ({ page, browserName }) => {
const randomDoc = await overrideDocContent({ 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();
await page
.getByRole('button', {
@@ -75,14 +143,29 @@ 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 overrideDocContent({ 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();
await page
.getByRole('button', {
@@ -234,6 +317,20 @@ 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();
@@ -261,6 +358,108 @@ 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,
@@ -307,18 +506,116 @@ test.describe('Doc Export', () => {
expect(pdfString).toContain('/Lang (fr)');
});
test('it exports the doc to PDF with PRINT feature and checks regressions', async ({
test('it exports the doc with interlinking', async ({
page,
browserName,
}, testInfo) => {
// 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();
}
}) => {
const [randomDoc] = await createDoc(
page,
'export-interlinking',
browserName,
1,
);
await overrideDocContent({ page, browserName });
await verifyDocName(page, randomDoc);
const { name: docChild } = await createRootSubPage(
page,
browserName,
'export-interlink-child',
);
await verifyDocName(page, docChild);
await page.locator('.bn-block-outer').last().fill('/');
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 editor = page.locator('.ProseMirror.bn-editor');
const interlink = editor.getByRole('button', {
name: randomDoc,
});
await expect(interlink).toBeVisible();
const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`${docChild}.pdf`);
});
await page
.getByRole('button', {
name: 'Export the document',
})
.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);
await page.locator('.bn-block-outer').last().fill('/');
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 editor = page.locator('.ProseMirror.bn-editor');
const interlink = editor.getByRole('button', {
name: randomDoc,
});
await expect(interlink).toBeVisible();
await page
.getByRole('button', {
@@ -327,77 +624,15 @@ test.describe('Doc Export', () => {
.click();
await page.getByRole('combobox', { name: 'Format' }).click();
await page.getByRole('option', { name: 'Print' }).click();
await page.getByRole('button', { name: 'Print' }).click();
await expect(page.locator('#print-only-content-styles')).toBeAttached();
await page.emulateMedia({ media: 'print' });
const pdfBuffer = await page.pdf({
printBackground: true,
preferCSSPageSize: true,
format: 'A4',
scale: 1,
});
// If we need to update the PDF regression fixture, uncomment the line below
// await savePDFToAssetFolder(
// pdfBuffer,
// 'doc-export-PDF-browser-regressions.pdf',
// );
// Assert the generated PDF matches the initial PDF regression fixture
await comparePDFWithAssetFolder({
originPdfBuffer: pdfBuffer,
filename: 'doc-export-PDF-browser-regressions.pdf',
compareTextContent: false,
comparePixel: false,
testInfo,
});
await expect(page.locator('#print-only-content-styles')).not.toBeAttached();
});
test('it exports the doc to PDF and checks regressions', async ({
page,
browserName,
}, testInfo) => {
// 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();
}
const randomDoc = await overrideDocContent({ page, browserName });
await page
.getByRole('button', {
name: 'Export the document',
})
.click();
await page.getByRole('option', { name: 'Odt' }).click();
const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`${randomDoc}.pdf`);
return download.suggestedFilename().includes(`${docChild}.odt`);
});
await page.getByTestId('doc-export-download-button').click();
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());
// If we need to update the PDF regression fixture, uncomment the line below
//await savePDFToAssetFolder(pdfBuffer, 'doc-export-regressions.pdf');
// Assert the generated PDF matches "assets/doc-export-regressions.pdf"
await comparePDFWithAssetFolder({
originPdfBuffer: pdfBuffer,
filename: 'doc-export-regressions.pdf',
testInfo,
});
expect(download.suggestedFilename()).toBe(`${docChild}.odt`);
});
});

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