Compare commits

..

201 Commits

Author SHA1 Message Date
Bastien Guerry
eb26641374 📝(docs) ortho/typo fixes in docs/env.md 2025-05-06 09:43:12 +02:00
Bastien Guerry
561a185363 📝(docs) sort environment variables alphabetically 2025-05-06 09:39:57 +02:00
Anthony LC
9b2f7966f6 🌐(i18n) update translated strings
Update translated files with new translations
2025-05-05 11:17:58 +02:00
Anthony LC
5ad30b404d 🌐(i18n) add PO of new languages
New languages were added to Crowdin.
We import the new translations from Crowdin
to version them in the repository.
2025-05-02 16:25:50 +02:00
Anthony LC
12524f35b7 🌐(i18n) remove chinese language
We're going to make languages ​​configurable
per instance, but until we manage that, we're going
to remove Chinese from the default language list.

- Remove the chinese language from the default language
list.
- Change Spanish to Español
2025-05-02 16:25:50 +02:00
Anthony LC
f8a40cf8cc (frontend) add advanced table features
We added advanced table features to the
table editor, including:
- split / merge cells
- cell background color
- cell text color
- header

We adapted the export and brought some improvements
compare to the previous version.

The export PDF supports colspan (merge horizontally),
but does not support the rowspan (merge vertically)
for now.
2025-04-30 17:22:21 +02:00
Anthony LC
c32fdb67ac (frontend) add @blocknote/code-block
To reduce the bundles size, the highlight syntax
library is not included in blocknote core anymore.
We need to add a separate dependency in order
to have the code block syntax highlight feature.
2025-04-30 17:22:21 +02:00
Anthony LC
7f2a21cdc9 🔥(frontend) remove Quote custom block
Last Blocknote upgrade included a Quote block,
better to use their built-in one.
2025-04-30 17:22:21 +02:00
Anthony LC
4ad917906c ⬆️(dependencies) update js dependencies 2025-04-30 17:22:21 +02:00
Anthony LC
9ca79688c9 ♻️(frontend) bind ui with ability access
Some actions were not available in the frontend
but allowed in the backend, this commit binds the frontend
ui with the ability access coming from the backend.
2025-04-30 17:02:13 +02:00
Manuel Raynaud
7f0eb9117e 🔒️(drf) disable browsable HTML API renderer (#919)
The `BrowsableAPIRenderer` generates a form to test POST/PUT/... actions
and fill the FK fields with unfiltered data. This issue has been spoted
on visio and fixed suitenumerique/meet#508
2025-04-30 16:23:26 +02:00
Quentin BEY
2557c6bc77 (backend) add django-lasuite dependency
Use the OIDC backend from the new library and add settings to setup OIDC
token storage required for later calls to OIDC Resource Servers.
2025-04-29 13:15:43 +02:00
Manuel Raynaud
df173c3ce6 🔧(helmfile) personalize keycloak configuration
The keycloak configuration used in dev environment is too generic and we
can have a conflict with other project that are using the same ingress
domain. Also the namespace was missing in the keycloak extra ConfigMap
leading to creating it in the default namespace.
2025-04-28 21:41:02 +02:00
Anthony LC
b58c991c81 🐛(nginx) fix 404 when accessing a doc
We improve the nginx way to access to a specific
doc.
We stop to wait for a initial attempt that
give a 404. If we see a UUID in the url we will
redirect to the doc/[id] page. Next will then
manage the 404.
2025-04-28 21:41:02 +02:00
Martin Weinelt
96f6aeea60 🔧(backend) Allow overwriting the data directory (#893)
## Purpose

Deployments that don't rely on Docker should be given the option to use
a different data directory.

## Proposal

Allow customization of the `DATA_DIR` through an environment variable of
the same name.

If the environment variable is not set the behaviour remains the same as
before.

Signed-off-by: Martin Weinelt <hexa@darmstadt.ccc.de>
2025-04-28 15:41:28 +00:00
Nathan Panchout
9465f1a6ec 🔒(frontend) enhance file download security (#889)
## Purpose

Added a safety check for URLs in the FileDownloadButton component. Now,
before opening a URL, it verifies if the URL is safe using the isSafeUrl
function.
This prevents potentially unsafe URLs from being opened in a new tab.
2025-04-28 12:50:14 +00:00
virgile-dev
98f11ff8ac 🌐(i18n) add spanish and chinese (#884)
All the spanish and chinese translations are complete on crowdin. We
activate it in django settings and download all translations from
crowdin

Signed-off-by: virgile-deville <virgile.deville@beta.gouv.fr>
2025-04-28 12:36:34 +00:00
renovate[bot]
b29daa2d77 ⬆️(dependencies) update python dependencies (#847)
This PR contains the following updates:

| Package | Change | Age | Adoption | Passing | Confidence |
|---|---|---|---|---|---|
| [boto3](https://redirect.github.com/boto/boto3) | `==1.37.24` ->
`==1.37.33` |
[![age](https://developer.mend.io/api/mc/badges/age/pypi/boto3/1.37.33?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![adoption](https://developer.mend.io/api/mc/badges/adoption/pypi/boto3/1.37.33?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![passing](https://developer.mend.io/api/mc/badges/compatibility/pypi/boto3/1.37.24/1.37.33?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/pypi/boto3/1.37.24/1.37.33?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
| [celery](https://docs.celeryq.dev/)
([source](https://redirect.github.com/celery/celery),
[changelog](https://docs.celeryq.dev/en/stable/changelog.html)) |
`==5.5.0` -> `==5.5.1` |
[![age](https://developer.mend.io/api/mc/badges/age/pypi/celery/5.5.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![adoption](https://developer.mend.io/api/mc/badges/adoption/pypi/celery/5.5.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![passing](https://developer.mend.io/api/mc/badges/compatibility/pypi/celery/5.5.0/5.5.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/pypi/celery/5.5.0/5.5.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
| [django](https://redirect.github.com/django/django)
([changelog](https://docs.djangoproject.com/en/stable/releases/)) |
`==5.1.8` -> `==5.2` |
[![age](https://developer.mend.io/api/mc/badges/age/pypi/django/5.2?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![adoption](https://developer.mend.io/api/mc/badges/adoption/pypi/django/5.2?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![passing](https://developer.mend.io/api/mc/badges/compatibility/pypi/django/5.1.8/5.2?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/pypi/django/5.1.8/5.2?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
|
[django-extensions](https://redirect.github.com/django-extensions/django-extensions)
([changelog](https://redirect.github.com/django-extensions/django-extensions/blob/main/CHANGELOG.md))
| `==3.2.3` -> `==4.1` |
[![age](https://developer.mend.io/api/mc/badges/age/pypi/django-extensions/4.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![adoption](https://developer.mend.io/api/mc/badges/adoption/pypi/django-extensions/4.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![passing](https://developer.mend.io/api/mc/badges/compatibility/pypi/django-extensions/3.2.3/4.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/pypi/django-extensions/3.2.3/4.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
|
[django-storages](https://redirect.github.com/jschneier/django-storages)
([changelog](https://redirect.github.com/jschneier/django-storages/blob/master/CHANGELOG.rst))
| `==1.14.5` -> `==1.14.6` |
[![age](https://developer.mend.io/api/mc/badges/age/pypi/django-storages/1.14.6?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![adoption](https://developer.mend.io/api/mc/badges/adoption/pypi/django-storages/1.14.6?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![passing](https://developer.mend.io/api/mc/badges/compatibility/pypi/django-storages/1.14.5/1.14.6?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/pypi/django-storages/1.14.5/1.14.6?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
|
[drf-spectacular-sidecar](https://redirect.github.com/tfranzel/drf-spectacular-sidecar)
| `==2025.3.1` -> `==2025.4.1` |
[![age](https://developer.mend.io/api/mc/badges/age/pypi/drf-spectacular-sidecar/2025.4.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![adoption](https://developer.mend.io/api/mc/badges/adoption/pypi/drf-spectacular-sidecar/2025.4.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![passing](https://developer.mend.io/api/mc/badges/compatibility/pypi/drf-spectacular-sidecar/2025.3.1/2025.4.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/pypi/drf-spectacular-sidecar/2025.3.1/2025.4.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
| [ipython](https://redirect.github.com/ipython/ipython) | `==9.0.2` ->
`==9.1.0` |
[![age](https://developer.mend.io/api/mc/badges/age/pypi/ipython/9.1.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![adoption](https://developer.mend.io/api/mc/badges/adoption/pypi/ipython/9.1.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![passing](https://developer.mend.io/api/mc/badges/compatibility/pypi/ipython/9.0.2/9.1.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/pypi/ipython/9.0.2/9.1.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
| [lxml](https://lxml.de/)
([source](https://redirect.github.com/lxml/lxml),
[changelog](https://git.launchpad.net/lxml/plain/CHANGES.txt)) |
`==5.3.1` -> `==5.3.2` |
[![age](https://developer.mend.io/api/mc/badges/age/pypi/lxml/5.3.2?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![adoption](https://developer.mend.io/api/mc/badges/adoption/pypi/lxml/5.3.2?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![passing](https://developer.mend.io/api/mc/badges/compatibility/pypi/lxml/5.3.1/5.3.2?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/pypi/lxml/5.3.1/5.3.2?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
| [markdown](https://redirect.github.com/Python-Markdown/markdown)
([changelog](https://python-markdown.github.io/changelog/)) | `==3.7` ->
`==3.8` |
[![age](https://developer.mend.io/api/mc/badges/age/pypi/markdown/3.8?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![adoption](https://developer.mend.io/api/mc/badges/adoption/pypi/markdown/3.8?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![passing](https://developer.mend.io/api/mc/badges/compatibility/pypi/markdown/3.7/3.8?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/pypi/markdown/3.7/3.8?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
| [openai](https://redirect.github.com/openai/openai-python) |
`==1.70.0` -> `==1.73.0` |
[![age](https://developer.mend.io/api/mc/badges/age/pypi/openai/1.73.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![adoption](https://developer.mend.io/api/mc/badges/adoption/pypi/openai/1.73.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![passing](https://developer.mend.io/api/mc/badges/compatibility/pypi/openai/1.70.0/1.73.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/pypi/openai/1.70.0/1.73.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
| [pycrdt](https://redirect.github.com/jupyter-server/pycrdt) |
`==0.12.10` -> `==0.12.12` |
[![age](https://developer.mend.io/api/mc/badges/age/pypi/pycrdt/0.12.12?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![adoption](https://developer.mend.io/api/mc/badges/adoption/pypi/pycrdt/0.12.12?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![passing](https://developer.mend.io/api/mc/badges/compatibility/pypi/pycrdt/0.12.10/0.12.12?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/pypi/pycrdt/0.12.10/0.12.12?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
| [pytest-cov](https://redirect.github.com/pytest-dev/pytest-cov)
([changelog](https://pytest-cov.readthedocs.io/en/latest/changelog.html))
| `==6.0.0` -> `==6.1.1` |
[![age](https://developer.mend.io/api/mc/badges/age/pypi/pytest-cov/6.1.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![adoption](https://developer.mend.io/api/mc/badges/adoption/pypi/pytest-cov/6.1.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![passing](https://developer.mend.io/api/mc/badges/compatibility/pypi/pytest-cov/6.0.0/6.1.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/pypi/pytest-cov/6.0.0/6.1.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
| [pytest-django](https://redirect.github.com/pytest-dev/pytest-django)
([changelog](https://pytest-django.readthedocs.io/en/latest/changelog.html))
| `==4.10.0` -> `==4.11.1` |
[![age](https://developer.mend.io/api/mc/badges/age/pypi/pytest-django/4.11.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![adoption](https://developer.mend.io/api/mc/badges/adoption/pypi/pytest-django/4.11.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![passing](https://developer.mend.io/api/mc/badges/compatibility/pypi/pytest-django/4.10.0/4.11.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/pypi/pytest-django/4.10.0/4.11.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
| [ruff](https://docs.astral.sh/ruff)
([source](https://redirect.github.com/astral-sh/ruff),
[changelog](https://redirect.github.com/astral-sh/ruff/blob/main/CHANGELOG.md))
| `==0.11.2` -> `==0.11.5` |
[![age](https://developer.mend.io/api/mc/badges/age/pypi/ruff/0.11.5?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![adoption](https://developer.mend.io/api/mc/badges/adoption/pypi/ruff/0.11.5?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![passing](https://developer.mend.io/api/mc/badges/compatibility/pypi/ruff/0.11.2/0.11.5?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/pypi/ruff/0.11.2/0.11.5?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
| [sentry-sdk](https://redirect.github.com/getsentry/sentry-python)
([changelog](https://redirect.github.com/getsentry/sentry-python/blob/master/CHANGELOG.md))
| `==2.25.0` -> `==2.25.1` |
[![age](https://developer.mend.io/api/mc/badges/age/pypi/sentry-sdk/2.25.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![adoption](https://developer.mend.io/api/mc/badges/adoption/pypi/sentry-sdk/2.25.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![passing](https://developer.mend.io/api/mc/badges/compatibility/pypi/sentry-sdk/2.25.0/2.25.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/pypi/sentry-sdk/2.25.0/2.25.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|

---

### Release Notes

<details>
<summary>boto/boto3 (boto3)</summary>

###
[`v1.37.33`](https://redirect.github.com/boto/boto3/blob/HEAD/CHANGELOG.rst#13733)

[Compare
Source](https://redirect.github.com/boto/boto3/compare/1.37.32...1.37.33)

\=======

- api-change:`connect-contact-lens`: \[`botocore`] Making sentiment
optional for ListRealtimeContactAnalysisSegments Response depending on
conversational analytics configuration
- api-change:`datazone`: \[`botocore`] Raise hard limit of authorized
principals per SubscriptionTarget from 10 to 20.
- api-change:`detective`: \[`botocore`] Add support for Detective
DualStack endpoints
- api-change:`dynamodb`: \[`botocore`] Doc only update for API
descriptions.
- api-change:`marketplace-entitlement`: \[`botocore`] Add support for
Marketplace Entitlement Service dual-stack endpoints for CN and GOV
regions
- api-change:`meteringmarketplace`: \[`botocore`] Add support for
Marketplace Metering Service dual-stack endpoints for CN regions
- api-change:`pcs`: \[`botocore`] Changed the minimum length of
clusterIdentifier, computeNodeGroupIdentifier, and queueIdentifier to 3.
- api-change:`verifiedpermissions`: \[`botocore`] Adds deletion
protection support to policy stores. Deletion protection is disabled by
default, can be enabled via the CreatePolicyStore or UpdatePolicyStore
APIs, and is visible in GetPolicyStore.
- bugfix:`download_fileobj`: Fileobj provided in append mode will no
longer allow concurrent writes to preserve data integrity.

###
[`v1.37.32`](https://redirect.github.com/boto/boto3/blob/HEAD/CHANGELOG.rst#13732)

[Compare
Source](https://redirect.github.com/boto/boto3/compare/1.37.31...1.37.32)

\=======

- api-change:`application-autoscaling`: \[`botocore`] Application Auto
Scaling now supports horizontal scaling for Elasticache Memcached
self-designed clusters using target tracking scaling policies and
scheduled scaling.
- api-change:`elasticache`: \[`botocore`] AWS ElastiCache SDK now
supports using MemcachedUpgradeConfig parameter with ModifyCacheCluster
API to enable updating Memcached cache node types. Please refer to
updated AWS ElastiCache public documentation for detailed information on
API usage and implementation.
- api-change:`m2`: \[`botocore`] Introduce three new APIs:
CreateDataSetExportTask, GetDataSetExportTask and
ListDataSetExportHistory. Add support for batch restart for Blu Age
applications.
- api-change:`medialive`: \[`botocore`] AWS Elemental MediaLive /
Features : Add support for CMAF Ingest CaptionLanguageMappings,
TimedMetadataId3 settings, and Link InputResolution.
- api-change:`qbusiness`: \[`botocore`] Adds functionality to
enable/disable a new Q Business Hallucination Reduction feature. If
enabled, Q Business will detect and attempt to remove Hallucinations
from certain Chat requests.
- api-change:`quicksight`: \[`botocore`] Add support to analysis and
sheet level highlighting in QuickSight.

###
[`v1.37.31`](https://redirect.github.com/boto/boto3/blob/HEAD/CHANGELOG.rst#13731)

[Compare
Source](https://redirect.github.com/boto/boto3/compare/1.37.30...1.37.31)

\=======

- api-change:`controlcatalog`: \[`botocore`] The GetControl API now
surfaces a control's Severity, CreateTime, and Identifier for a
control's Implementation. The ListControls API now surfaces a control's
Behavior, Severity, CreateTime, and Identifier for a control's
Implementation.
- api-change:`dynamodb`: \[`botocore`] Documentation update for
secondary indexes and Create_Table.
- api-change:`glue`: \[`botocore`] The TableOptimizer APIs in AWS Glue
now return the DpuHours field in each TableOptimizerRun, providing
clients visibility to the DPU-hours used for billing in managed Apache
Iceberg table compaction optimization.
- api-change:`groundstation`: \[`botocore`] Support tagging Agents and
adjust input field validations
- api-change:`transfer`: \[`botocore`] This launch includes 2
enhancements to SFTP connectors user-experience: 1) Customers can
self-serve concurrent connections setting for their connectors, and 2)
Customers can discover the public host key of remote servers using their
SFTP connectors.

###
[`v1.37.30`](https://redirect.github.com/boto/boto3/blob/HEAD/CHANGELOG.rst#13730)

[Compare
Source](https://redirect.github.com/boto/boto3/compare/1.37.29...1.37.30)

\=======

- api-change:`bedrock-runtime`: \[`botocore`] This release introduces
our latest bedrock runtime API, InvokeModelWithBidirectionalStream. The
API supports both input and output streams and is supported by only
HTTP2.0.
- api-change:`ce`: \[`botocore`] This release supports Pagination traits
on Cost Anomaly Detection APIs.
- api-change:`cost-optimization-hub`: \[`botocore`] This release adds
resource type "MemoryDbReservedInstances" and resource type
"DynamoDbReservedCapacity" to the GetRecommendation,
ListRecommendations, and ListRecommendationSummaries APIs to support new
MemoryDB and DynamoDB RI recommendations.
- api-change:`iotfleetwise`: \[`botocore`] This release adds the option
to update the strategy of state templates already associated to a
vehicle, without the need to remove and re-add them.
- api-change:`securityhub`: \[`botocore`] Documentation updates for AWS
Security Hub.
- api-change:`storagegateway`: \[`botocore`] Added new
ActiveDirectoryStatus value, ListCacheReports paginator, and support for
longer pagination tokens.
- api-change:`taxsettings`: \[`botocore`] Uzbekistan Launch on
TaxSettings Page

###
[`v1.37.29`](https://redirect.github.com/boto/boto3/blob/HEAD/CHANGELOG.rst#13729)

[Compare
Source](https://redirect.github.com/boto/boto3/compare/1.37.28...1.37.29)

\=======

- api-change:`bedrock`: \[`botocore`] New options for how to handle
harmful content detected by Amazon Bedrock Guardrails.
- api-change:`bedrock-runtime`: \[`botocore`] New options for how to
handle harmful content detected by Amazon Bedrock Guardrails.
- api-change:`codebuild`: \[`botocore`] AWS CodeBuild now offers an
enhanced debugging experience.
- api-change:`glue`: \[`botocore`] Add input validations for multiple
Glue APIs
- api-change:`medialive`: \[`botocore`] AWS Elemental MediaLive now
supports SDI inputs to MediaLive Anywhere Channels in workflows that use
AWS SDKs.
- api-change:`personalize`: \[`botocore`] Add support for eventsConfig
for CreateSolution, UpdateSolution, DescribeSolution,
DescribeSolutionVersion. Add support for GetSolutionMetrics to return
weighted NDCG metrics when eventsConfig is enabled for the solution.
- api-change:`transfer`: \[`botocore`] This launch enables customers to
manage contents of their remote directories, by deleting old files or
moving files to archive folders in remote servers once they have been
retrieved. Customers will be able to automate the process using
event-driven architecture.

###
[`v1.37.28`](https://redirect.github.com/boto/boto3/blob/HEAD/CHANGELOG.rst#13728)

[Compare
Source](https://redirect.github.com/boto/boto3/compare/1.37.27...1.37.28)

\=======

- api-change:`ds-data`: \[`botocore`] Doc only update - fixed broken
links.
-   api-change:`ec2`: \[`botocore`] Doc-only updates for Amazon EC2
- api-change:`events`: \[`botocore`] Amazon EventBridge adds support for
customer-managed keys on Archives and validations for two fields:
eventSourceArn and kmsKeyIdentifier.
- api-change:`s3control`: \[`botocore`] Updated max size of Prefixes
parameter of Scope data type.

###
[`v1.37.27`](https://redirect.github.com/boto/boto3/blob/HEAD/CHANGELOG.rst#13727)

[Compare
Source](https://redirect.github.com/boto/boto3/compare/1.37.26...1.37.27)

\=======

- api-change:`bedrock-agent`: \[`botocore`] Added optional
"customMetadataField" for Amazon Aurora knowledge bases, allowing
single-column metadata. Also added optional "textIndexName" for MongoDB
Atlas knowledge bases, enabling hybrid search support.
- api-change:`chime-sdk-voice`: \[`botocore`] Added FOC date as an
attribute of PhoneNumberOrder, added AccessDeniedException as a possible
return type of ValidateE911Address
- api-change:`mailmanager`: \[`botocore`] Add support for Dual_Stack and
PrivateLink types of IngressPoint. For configuration requests, SES Mail
Manager will now accept both IPv4/IPv6 dual-stack endpoints and AWS
PrivateLink VPC endpoints for email receiving.
- api-change:`opensearch`: \[`botocore`] Improve descriptions for
various API commands and data types.
- api-change:`route53`: \[`botocore`] Added us-gov-east-1 and
us-gov-west-1 as valid Latency Based Routing regions for
change-resource-record-sets.
- api-change:`sagemaker`: \[`botocore`] Adds support for i3en, m7i, r7i
instance types for SageMaker Hyperpod
- api-change:`sesv2`: \[`botocore`] This release enables customers to
provide attachments in the SESv2 SendEmail and SendBulkEmail APIs.
- api-change:`transcribe`: \[`botocore`] This Feature Adds Support for
the "zh-HK" Locale for Batch Operations
- enhancement:Eventstream: \[`botocore`] The event streams maximum
payload size is now required to be 24Mb or less.

###
[`v1.37.26`](https://redirect.github.com/boto/boto3/blob/HEAD/CHANGELOG.rst#13726)

[Compare
Source](https://redirect.github.com/boto/boto3/compare/1.37.25...1.37.26)

\=======

- api-change:`application-signals`: \[`botocore`] Application Signals
now supports creating Service Level Objectives on service dependencies.
Users can now create or update SLOs on discovered service dependencies
to monitor their standard application metrics.
- api-change:`codebuild`: \[`botocore`] This release adds support for
environment type WINDOWS_SERVER\_2022\_CONTAINER in ProjectEnvironment
- api-change:`ecr`: \[`botocore`] Fix for customer issues related to AWS
account ID and size limitation for token.
- api-change:`ecs`: \[`botocore`] This is an Amazon ECS documentation
only update to address various tickets.
- api-change:`lexv2-models`: \[`botocore`] Release feature of
errorlogging for lex bot, customer can config this feature in bot
version to generate log for error exception which helps debug
- api-change:`medialive`: \[`botocore`] Added support for SMPTE 2110
inputs when running a channel in a MediaLive Anywhere cluster. This
feature enables ingestion of SMPTE 2110-compliant video, audio, and
ancillary streams by reading SDP files that AWS Elemental MediaLive can
retrieve from a network source.

###
[`v1.37.25`](https://redirect.github.com/boto/boto3/blob/HEAD/CHANGELOG.rst#13725)

[Compare
Source](https://redirect.github.com/boto/boto3/compare/1.37.24...1.37.25)

\=======

- api-change:`cleanrooms`: \[`botocore`] This release adds support for
updating the analytics engine of a collaboration.
- api-change:`sagemaker`: \[`botocore`] Added tagging support for
SageMaker notebook instance lifecycle configurations

</details>

<details>
<summary>celery/celery (celery)</summary>

###
[`v5.5.1`](https://redirect.github.com/celery/celery/blob/HEAD/Changelog.rst#551)

[Compare
Source](https://redirect.github.com/celery/celery/compare/v5.5.0...v5.5.1)

\=====

:release-date: 2025-04-08
:release-by: Tomer Nosrati

What's Changed

```

- Fixed "AttributeError: list object has no attribute strip" with quorum queues and failover brokers (#&#8203;9657)
- Prepare for release: v5.5.1 (#&#8203;9660)

.. _version-5.5.0:
```

</details>

<details>
<summary>django/django (django)</summary>

###
[`v5.2`](https://redirect.github.com/django/django/compare/5.1.8...5.2)

[Compare
Source](https://redirect.github.com/django/django/compare/5.1.8...5.2)

</details>

<details>
<summary>django-extensions/django-extensions
(django-extensions)</summary>

###
[`v4.1`](https://redirect.github.com/django-extensions/django-extensions/blob/HEAD/CHANGELOG.md#41)

[Compare
Source](https://redirect.github.com/django-extensions/django-extensions/compare/4.0...4.1)

Changes:

- Add: show_permissions command
([#&#8203;1920](https://redirect.github.com/django-extensions/django-extensions/issues/1920))
- Improvement: graph_models, style per app
([#&#8203;1848](https://redirect.github.com/django-extensions/django-extensions/issues/1848))
- Fix: JSONField, bulk_update's
([#&#8203;1924](https://redirect.github.com/django-extensions/django-extensions/issues/1924))

###
[`v4.0`](https://redirect.github.com/django-extensions/django-extensions/blob/HEAD/CHANGELOG.md#40)

[Compare
Source](https://redirect.github.com/django-extensions/django-extensions/compare/3.2.3...4.0)

Changes:

-   Improvement: Support for Python 3.12 and 3.13
-   Improvement: Support for Django 5.x
-   Improvement: Switch from setup.{cfg,py} to pyproject.toml
- Improvement: graph_models, Add option to display field choices in
graph_models
([#&#8203;1854](https://redirect.github.com/django-extensions/django-extensions/issues/1854))
- Improvement: graph_models, Add webp support
([#&#8203;1857](https://redirect.github.com/django-extensions/django-extensions/issues/1857))
- Improvement: graph_models, Support for ordering edges on
pydot/dot/graphviz
([#&#8203;1914](https://redirect.github.com/django-extensions/django-extensions/issues/1914))
- Improvement: mail_debug, Update mail_debug command to use aiosmtpd
([#&#8203;1880](https://redirect.github.com/django-extensions/django-extensions/issues/1880))
- Improvement: shell_plus, Improve error message for missing import
([#&#8203;1898](https://redirect.github.com/django-extensions/django-extensions/issues/1898))
- Improvement: reset_db, Add reset_db support for django_tenants
([#&#8203;1855](https://redirect.github.com/django-extensions/django-extensions/issues/1855))
- Improvement: docs, various improvements
([#&#8203;1852](https://redirect.github.com/django-extensions/django-extensions/issues/1852),
[#&#8203;1888](https://redirect.github.com/django-extensions/django-extensions/issues/1888),
[#&#8203;1882](https://redirect.github.com/django-extensions/django-extensions/issues/1882),
[#&#8203;1901](https://redirect.github.com/django-extensions/django-extensions/issues/1901),
[#&#8203;1912](https://redirect.github.com/django-extensions/django-extensions/issues/1912),
[#&#8203;1913](https://redirect.github.com/django-extensions/django-extensions/issues/1913))
- Improvement: jobs, Handle non-package modules when looking for job
definitions
([#&#8203;1887](https://redirect.github.com/django-extensions/django-extensions/issues/1887))
- Improvement: Add django-prometheus DB backends support
([#&#8203;1800](https://redirect.github.com/django-extensions/django-extensions/issues/1800))
- Improvement: Call post_command when the command raises an unhandled
exception
([#&#8203;1837](https://redirect.github.com/django-extensions/django-extensions/issues/1837))
- Fix: sqldiff, do not consider ('serial', 'integer') nor ('bigserial',
'bigint') as a `field-type-differ`
([#&#8203;1867](https://redirect.github.com/django-extensions/django-extensions/issues/1867))
- Fix: shell_plus, Fix start up order and add history
([#&#8203;1869](https://redirect.github.com/django-extensions/django-extensions/issues/1869))
- Remove pipchecker and associated tests
([#&#8203;1906](https://redirect.github.com/django-extensions/django-extensions/issues/1906))
- Following Django's release numbering style more closely (see
https://docs.djangoproject.com/en/5.2/internals/release-process/ )

</details>

<details>
<summary>jschneier/django-storages (django-storages)</summary>

###
[`v1.14.6`](https://redirect.github.com/jschneier/django-storages/compare/1.14.5...1.14.6)

[Compare
Source](https://redirect.github.com/jschneier/django-storages/compare/1.14.5...1.14.6)

</details>

<details>
<summary>tfranzel/drf-spectacular-sidecar
(drf-spectacular-sidecar)</summary>

###
[`v2025.4.1`](https://redirect.github.com/tfranzel/drf-spectacular-sidecar/compare/2025.3.1...2025.4.1)

[Compare
Source](https://redirect.github.com/tfranzel/drf-spectacular-sidecar/compare/2025.3.1...2025.4.1)

</details>

<details>
<summary>ipython/ipython (ipython)</summary>

###
[`v9.1.0`](https://redirect.github.com/ipython/ipython/compare/9.0.2...9.1.0)

[Compare
Source](https://redirect.github.com/ipython/ipython/compare/9.0.2...9.1.0)

</details>

<details>
<summary>lxml/lxml (lxml)</summary>

###
[`v5.3.2`](https://redirect.github.com/lxml/lxml/blob/HEAD/CHANGES.txt#532-2025-04-05)

[Compare
Source](https://redirect.github.com/lxml/lxml/compare/lxml-5.3.1...lxml-5.3.2)

\==================

This release resolves CVE-2025-24928 as described in
https://gitlab.gnome.org/GNOME/libxml2/-/issues/847

## Bugs fixed

-   Binary wheels use libxml2 2.12.10 and libxslt 1.1.42.

- Binary wheels for Windows use a patched libxml2 2.11.9 and libxslt
1.1.39.

</details>

<details>
<summary>Python-Markdown/markdown (markdown)</summary>

###
[`v3.8`](https://redirect.github.com/Python-Markdown/markdown/releases/tag/3.8)

[Compare
Source](https://redirect.github.com/Python-Markdown/markdown/compare/3.7...3.8)

##### Changed

- DRY fix in `abbr` extension by introducing method `create_element`
([#&#8203;1483](https://redirect.github.com/Python-Markdown/markdown/issues/1483)).
-   Clean up test directory by removing some redundant tests and port
    non-redundant cases to the newer test framework.
- Improved performance of the raw HTML post-processor
([#&#8203;1510](https://redirect.github.com/Python-Markdown/markdown/issues/1510)).

##### Fixed

- Backslash Unescape IDs set via `attr_list` on `toc`
([#&#8203;1493](https://redirect.github.com/Python-Markdown/markdown/issues/1493)).
- Ensure `md_in_html` processes content inside "markdown" blocks as they
are
parsed outside of "markdown" blocks to keep things more consistent for
third-party extensions
([#&#8203;1503](https://redirect.github.com/Python-Markdown/markdown/issues/1503)).
- `md_in_html` handle tags within inline code blocks better
([#&#8203;1075](https://redirect.github.com/Python-Markdown/markdown/issues/1075)).
- `md_in_html` fix handling of one-liner block HTML handling
([#&#8203;1074](https://redirect.github.com/Python-Markdown/markdown/issues/1074)).
- Ensure `<center>` is treated like a block-level element
([#&#8203;1481](https://redirect.github.com/Python-Markdown/markdown/issues/1481)).
- Ensure that `abbr` extension respects `AtomicString` and does not
process
perceived abbreviations in these strings
([#&#8203;1512](https://redirect.github.com/Python-Markdown/markdown/issues/1512)).
- Ensure `smarty` extension correctly renders nested closing quotes
([#&#8203;1514](https://redirect.github.com/Python-Markdown/markdown/issues/1514)).

</details>

<details>
<summary>openai/openai-python (openai)</summary>

###
[`v1.73.0`](https://redirect.github.com/openai/openai-python/blob/HEAD/CHANGELOG.md#1730-2025-04-12)

[Compare
Source](https://redirect.github.com/openai/openai-python/compare/v1.72.0...v1.73.0)

Full Changelog:
[v1.72.0...v1.73.0](https://redirect.github.com/openai/openai-python/compare/v1.72.0...v1.73.0)

##### Features

- **api:** manual updates
([a3253dd](a3253dd798))

##### Bug Fixes

- **perf:** optimize some hot paths
([f79d39f](f79d39fbca))
- **perf:** skip traversing types for NotGiven values
([28d220d](28d220de3b))

##### Chores

- **internal:** expand CI branch coverage
([#&#8203;2295](https://redirect.github.com/openai/openai-python/issues/2295))
([0ae783b](0ae783b991))
- **internal:** reduce CI branch coverage
([2fb7d42](2fb7d425cd))
- slight wording improvement in README
([#&#8203;2291](https://redirect.github.com/openai/openai-python/issues/2291))
([e020759](e0207598d1))
- workaround build errors
([4e10c96](4e10c96a48))

###
[`v1.72.0`](https://redirect.github.com/openai/openai-python/blob/HEAD/CHANGELOG.md#1720-2025-04-08)

[Compare
Source](https://redirect.github.com/openai/openai-python/compare/v1.71.0...v1.72.0)

Full Changelog:
[v1.71.0...v1.72.0](https://redirect.github.com/openai/openai-python/compare/v1.71.0...v1.72.0)

##### Features

- **api:** Add evalapi to sdk
([#&#8203;2287](https://redirect.github.com/openai/openai-python/issues/2287))
([35262fc](35262fcef6))

##### Chores

- **internal:** fix examples
([#&#8203;2288](https://redirect.github.com/openai/openai-python/issues/2288))
([39defd6](39defd61e8))
- **internal:** skip broken test
([#&#8203;2289](https://redirect.github.com/openai/openai-python/issues/2289))
([e2c9bce](e2c9bce1f5))
- **internal:** slight transform perf improvement
([#&#8203;2284](https://redirect.github.com/openai/openai-python/issues/2284))
([746174f](746174fae7))
- **tests:** improve enum examples
([#&#8203;2286](https://redirect.github.com/openai/openai-python/issues/2286))
([c9dd81c](c9dd81ce02))

###
[`v1.71.0`](https://redirect.github.com/openai/openai-python/blob/HEAD/CHANGELOG.md#1710-2025-04-07)

[Compare
Source](https://redirect.github.com/openai/openai-python/compare/v1.70.0...v1.71.0)

Full Changelog:
[v1.70.0...v1.71.0](https://redirect.github.com/openai/openai-python/compare/v1.70.0...v1.71.0)

##### Features

- **api:** manual updates
([bf8b4b6](bf8b4b6990))
- **api:** manual updates
([3e37aa3](3e37aa3e15))
- **api:** manual updates
([dba9b65](dba9b656fa))
- **api:** manual updates
([f0c463b](f0c463b478))

##### Chores

- **deps:** allow websockets v15
([#&#8203;2281](https://redirect.github.com/openai/openai-python/issues/2281))
([19c619e](19c619ea95))
- **internal:** only run examples workflow in main repo
([#&#8203;2282](https://redirect.github.com/openai/openai-python/issues/2282))
([c3e0927](c3e0927d3f))
- **internal:** remove trailing character
([#&#8203;2277](https://redirect.github.com/openai/openai-python/issues/2277))
([5a21a2d](5a21a2d799))
- Remove deprecated/unused remote spec feature
([23f76eb](23f76eb0b9))

</details>

<details>
<summary>jupyter-server/pycrdt (pycrdt)</summary>

###
[`v0.12.12`](https://redirect.github.com/jupyter-server/pycrdt/blob/HEAD/CHANGELOG.md#01212)

[Compare
Source](https://redirect.github.com/jupyter-server/pycrdt/compare/0.12.11...0.12.12)

-   Add doc and shared type `events()` async event iterator.
-   Fix deadlock while getting root type from within transaction.

###
[`v0.12.11`](https://redirect.github.com/jupyter-server/pycrdt/blob/HEAD/CHANGELOG.md#01211)

[Compare
Source](https://redirect.github.com/jupyter-server/pycrdt/compare/0.12.10...0.12.11)

-   Upgrade `pyo3` to v0.24.1.

</details>

<details>
<summary>pytest-dev/pytest-cov (pytest-cov)</summary>

###
[`v6.1.1`](https://redirect.github.com/pytest-dev/pytest-cov/blob/HEAD/CHANGELOG.rst#611-2025-04-05)

[Compare
Source](https://redirect.github.com/pytest-dev/pytest-cov/compare/v6.1.0...v6.1.1)

- Fixed breakage that occurs when `--cov-context` and the `no_cover`
marker are used together.

###
[`v6.1.0`](https://redirect.github.com/pytest-dev/pytest-cov/blob/HEAD/CHANGELOG.rst#610-2025-04-01)

[Compare
Source](https://redirect.github.com/pytest-dev/pytest-cov/compare/v6.0.0...v6.1.0)

- Change terminal output to use full width lines for the coverage
header.
Contributed by Tsvika Shapira in `#&#8203;678
<https://github.com/pytest-dev/pytest-cov/pull/678>`\_.
- Removed unnecessary CovFailUnderWarning. Fixes `#&#8203;675
<https://github.com/pytest-dev/pytest-cov/issues/675>`\_.
- Fixed the term report not using the precision specified via
`--cov-precision`.

</details>

<details>
<summary>pytest-dev/pytest-django (pytest-django)</summary>

###
[`v4.11.1`](https://redirect.github.com/pytest-dev/pytest-django/releases/tag/v4.11.1)

[Compare
Source](https://redirect.github.com/pytest-dev/pytest-django/compare/v4.11.0...v4.11.1)


https://github.com/pytest-dev/pytest-django/blob/main/docs/changelog.rst#v4111-2025-04-03

###
[`v4.11.0`](https://redirect.github.com/pytest-dev/pytest-django/releases/tag/v4.11.0)

[Compare
Source](https://redirect.github.com/pytest-dev/pytest-django/compare/v4.10.0...v4.11.0)


https://github.com/pytest-dev/pytest-django/blob/main/docs/changelog.rst#v4110-2025-04-01

</details>

<details>
<summary>astral-sh/ruff (ruff)</summary>

###
[`v0.11.5`](https://redirect.github.com/astral-sh/ruff/blob/HEAD/CHANGELOG.md#0115)

[Compare
Source](https://redirect.github.com/astral-sh/ruff/compare/0.11.4...0.11.5)

##### Preview features

- \[`airflow`] Add missing `AIR302` attribute check
([#&#8203;17115](https://redirect.github.com/astral-sh/ruff/pull/17115))
- \[`airflow`] Expand module path check to individual symbols (`AIR302`)
([#&#8203;17278](https://redirect.github.com/astral-sh/ruff/pull/17278))
- \[`airflow`] Extract `AIR312` from `AIR302` rules (`AIR302`, `AIR312`)
([#&#8203;17152](https://redirect.github.com/astral-sh/ruff/pull/17152))
- \[`airflow`] Update oudated `AIR301`, `AIR302` rules
([#&#8203;17123](https://redirect.github.com/astral-sh/ruff/pull/17123))
- \[syntax-errors] Async comprehension in sync comprehension
([#&#8203;17177](https://redirect.github.com/astral-sh/ruff/pull/17177))
- \[syntax-errors] Check annotations in annotated assignments
([#&#8203;17283](https://redirect.github.com/astral-sh/ruff/pull/17283))
- \[syntax-errors] Extend annotation checks to `await`
([#&#8203;17282](https://redirect.github.com/astral-sh/ruff/pull/17282))

##### Bug fixes

- \[`flake8-pie`] Avoid false positive for multiple assignment with
`auto()` (`PIE796`)
([#&#8203;17274](https://redirect.github.com/astral-sh/ruff/pull/17274))

##### Rule changes

- \[`ruff`] Fix `RUF100` to detect unused file-level `noqa` directives
with specific codes
([#&#8203;17042](https://redirect.github.com/astral-sh/ruff/issues/17042))
([#&#8203;17061](https://redirect.github.com/astral-sh/ruff/pull/17061))
- \[`flake8-pytest-style`] Avoid false positive for legacy form of
`pytest.raises` (`PT011`)
([#&#8203;17231](https://redirect.github.com/astral-sh/ruff/pull/17231))

##### Documentation

- Fix formatting of "See Style Guide" link
([#&#8203;17272](https://redirect.github.com/astral-sh/ruff/pull/17272))

###
[`v0.11.4`](https://redirect.github.com/astral-sh/ruff/blob/HEAD/CHANGELOG.md#0114)

[Compare
Source](https://redirect.github.com/astral-sh/ruff/compare/0.11.3...0.11.4)

##### Preview features

- \[`ruff`] Implement `invalid-rule-code` as `RUF102`
([#&#8203;17138](https://redirect.github.com/astral-sh/ruff/pull/17138))
- \[syntax-errors] Detect duplicate keys in `match` mapping patterns
([#&#8203;17129](https://redirect.github.com/astral-sh/ruff/pull/17129))
- \[syntax-errors] Detect duplicate attributes in `match` class patterns
([#&#8203;17186](https://redirect.github.com/astral-sh/ruff/pull/17186))
- \[syntax-errors] Detect invalid syntax in annotations
([#&#8203;17101](https://redirect.github.com/astral-sh/ruff/pull/17101))

##### Bug fixes

- \[syntax-errors] Fix multiple assignment error for class fields in
`match` patterns
([#&#8203;17184](https://redirect.github.com/astral-sh/ruff/pull/17184))
- Don't skip visiting non-tuple slice in `typing.Annotated` subscripts
([#&#8203;17201](https://redirect.github.com/astral-sh/ruff/pull/17201))

###
[`v0.11.3`](https://redirect.github.com/astral-sh/ruff/blob/HEAD/CHANGELOG.md#0113)

[Compare
Source](https://redirect.github.com/astral-sh/ruff/compare/0.11.2...0.11.3)

##### Preview features

- \[`airflow`] Add more autofixes for `AIR302`
([#&#8203;16876](https://redirect.github.com/astral-sh/ruff/pull/16876),
[#&#8203;16977](https://redirect.github.com/astral-sh/ruff/pull/16977),
[#&#8203;16976](https://redirect.github.com/astral-sh/ruff/pull/16976),
[#&#8203;16965](https://redirect.github.com/astral-sh/ruff/pull/16965))
- \[`airflow`] Move `AIR301` to `AIR002`
([#&#8203;16978](https://redirect.github.com/astral-sh/ruff/pull/16978))
- \[`airflow`] Move `AIR302` to `AIR301` and `AIR303` to `AIR302`
([#&#8203;17151](https://redirect.github.com/astral-sh/ruff/pull/17151))
- \[`flake8-bandit`] Mark `str` and `list[str]` literals as trusted
input (`S603`)
([#&#8203;17136](https://redirect.github.com/astral-sh/ruff/pull/17136))
- \[`ruff`] Support slices in `RUF005`
([#&#8203;17078](https://redirect.github.com/astral-sh/ruff/pull/17078))
- \[syntax-errors] Start detecting compile-time syntax errors
([#&#8203;16106](https://redirect.github.com/astral-sh/ruff/pull/16106))
- \[syntax-errors] Duplicate type parameter names
([#&#8203;16858](https://redirect.github.com/astral-sh/ruff/pull/16858))
- \[syntax-errors] Irrefutable `case` pattern before final case
([#&#8203;16905](https://redirect.github.com/astral-sh/ruff/pull/16905))
- \[syntax-errors] Multiple assignments in `case` pattern
([#&#8203;16957](https://redirect.github.com/astral-sh/ruff/pull/16957))
- \[syntax-errors] Single starred assignment target
([#&#8203;17024](https://redirect.github.com/astral-sh/ruff/pull/17024))
- \[syntax-errors] Starred expressions in `return`, `yield`, and `for`
([#&#8203;17134](https://redirect.github.com/astral-sh/ruff/pull/17134))
- \[syntax-errors] Store to or delete `__debug__`
([#&#8203;16984](https://redirect.github.com/astral-sh/ruff/pull/16984))

##### Bug fixes

- Error instead of `panic!` when running Ruff from a deleted directory
([#&#8203;16903](https://redirect.github.com/astral-sh/ruff/issues/16903))
([#&#8203;17054](https://redirect.github.com/astral-sh/ruff/pull/17054))
- \[syntax-errors] Fix false positive for parenthesized tuple index
([#&#8203;16948](https://redirect.github.com/astral-sh/ruff/pull/16948))

##### CLI

- Check `pyproject.toml` correctly when it is passed via stdin
([#&#8203;16971](https://redirect.github.com/astral-sh/ruff/pull/16971))

##### Configuration

- \[`flake8-import-conventions`] Add import `numpy.typing as npt` to
default `flake8-import-conventions.aliases`
([#&#8203;17133](https://redirect.github.com/astral-sh/ruff/pull/17133))

##### Documentation

- \[`refurb`] Document why `UserDict`, `UserList`, and `UserString` are
preferred over `dict`, `list`, and `str` (`FURB189`)
([#&#8203;16927](https://redirect.github.com/astral-sh/ruff/pull/16927))

</details>

<details>
<summary>getsentry/sentry-python (sentry-sdk)</summary>

###
[`v2.25.1`](https://redirect.github.com/getsentry/sentry-python/blob/HEAD/CHANGELOG.md#2251)

[Compare
Source](https://redirect.github.com/getsentry/sentry-python/compare/2.25.0...2.25.1)

##### Various fixes & improvements

- fix(logs): Add a class which batches groups of logs together.
([#&#8203;4229](https://redirect.github.com/getsentry/sentry-python/issues/4229))
by [@&#8203;colin-sentry](https://redirect.github.com/colin-sentry)
- fix(logs): Use repr instead of json for message and arguments
([#&#8203;4227](https://redirect.github.com/getsentry/sentry-python/issues/4227))
by [@&#8203;colin-sentry](https://redirect.github.com/colin-sentry)
- fix(logs): Debug output from Sentry logs should always be `debug`
level.
([#&#8203;4224](https://redirect.github.com/getsentry/sentry-python/issues/4224))
by [@&#8203;antonpirker](https://redirect.github.com/antonpirker)
- fix(ai): Do not consume anthropic streaming stop
([#&#8203;4232](https://redirect.github.com/getsentry/sentry-python/issues/4232))
by [@&#8203;colin-sentry](https://redirect.github.com/colin-sentry)
- fix(spotlight): Do not spam sentry_sdk.warnings logger w/ Spotlight
([#&#8203;4219](https://redirect.github.com/getsentry/sentry-python/issues/4219))
by [@&#8203;BYK](https://redirect.github.com/BYK)
- fix(docs): fixed code snippet
([#&#8203;4218](https://redirect.github.com/getsentry/sentry-python/issues/4218))
by [@&#8203;antonpirker](https://redirect.github.com/antonpirker)
- build(deps): bump actions/create-github-app-token from 1.11.7 to
1.12.0
([#&#8203;4214](https://redirect.github.com/getsentry/sentry-python/issues/4214))
by [@&#8203;dependabot](https://redirect.github.com/dependabot)

</details>

---

### Configuration

📅 **Schedule**: Branch creation - "before 7am on monday" (UTC),
Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the
rebase/retry checkbox.

👻 **Immortal**: This PR will be recreated if closed unmerged. Get
[config
help](https://redirect.github.com/renovatebot/renovate/discussions) if
that's undesired.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/suitenumerique/docs).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiIzOS4yMjcuMyIsInVwZGF0ZWRJblZlciI6IjM5LjIzOC4wIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6WyJkZXBlbmRlbmNpZXMiLCJub0NoYW5nZUxvZyJdfQ==-->

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Manuel Raynaud <manu@raynaud.io>
2025-04-28 14:05:52 +02:00
Tom Hubrecht
5cdbdbf215 (settings) Allow configuring PKCE for the SSO (#886)
C.f.
https://mozilla-django-oidc.readthedocs.io/en/latest/settings.html#OIDC_USE_PKCE

## Purpose

Add pkce settings

## Proposal
Get the settings from the environment

Signed-off-by: Tom Hubrecht <github@mail.hubrecht.ovh>
2025-04-28 12:54:30 +02:00
Anthony LC
5268699d50 ⬆️(dependencies) update js dependencies 2025-04-23 11:43:50 +02:00
virgile-dev
cdafe6fd33 📝(readme) update xl packages info (#885)
Info message so people fulfill their licencing obligations

Signed-off-by: virgile-deville <virgile.deville@beta.gouv.fr>
2025-04-22 13:57:45 +00:00
Anthony LC
4307b4f433 🐛(backend) race condition create doc
When 2 docs are created almost at the same time,
the second one will fail because the first one.
We get a unicity error on the path key already
used ("impress_document_path_key").
To fix this issue, we will lock the table the
time to create the document, the next query will
wait for the lock to be released.
2025-04-22 11:43:29 +02:00
Anthony LC
3bf33d202a ️(frontend) reduce unblocking time for config
We will serve the config from the cache if available
in waiting for the config to be loaded.
It will remove the loading time for the config except
when the config is not available in the cache.
2025-04-22 11:23:55 +02:00
Anthony LC
101cef7d70 ♻️(frontend) refacto useCunninghamTheme
Refacto useCunninghamTheme, we don't need a function
to have access to the tokens anymore.
2025-04-22 10:38:51 +02:00
Samuel Paccoud - DINUM
419079ac69 🚸(backend) make document search on title accent-insensitive
This should work in both cases:
- search for "vélo" when the document title contains "velo"
- search for "velo" when the document title contains "vélo"
2025-04-17 20:28:14 +02:00
Anthony LC
ecd06560c6 🚚(frontend) Display homepage on /home url
The homepage is now accessible at the /home URL.
Before the homepage was accessible on the /login URL.
We still keep the /login URL for backward compatibility.
2025-04-13 13:25:40 +02:00
Anthony LC
e9ab099ce0 🚩(frontend) integrate homepage feature flag
If the homepage feature flag is enabled,
the homepage will be displayed.
2025-04-13 13:25:40 +02:00
Anthony LC
67b69d05f7 🚩(backend) add homepage feature flag
Add a homepage feature flag that we will
propagate to the frontend.
It will be used to enable or disable the
homepage at runtime.
2025-04-13 13:25:40 +02:00
virgile-deville
f429eb053a 📝(readme) remove preprod account
Having a preprod account using a yopmail account was a security bad practice

Co-authored-by: Samuel Paccoud <sampaccoud@users.noreply.github.com>
2025-04-11 22:29:22 +02:00
Olivier Laurendeau
ad11b7f554 📝(docs) init architecture documentation
- Add docs about architecture
- Add ADR about the CRDT choice
2025-04-10 15:15:15 +02:00
Anthony LC
3d5adad227 🔖(minor) release 3.1.0
Added:
- 🚩(backend) add feature flag for the footer
- 🔧(backend) add view to manage footer json
- (frontend) add custom css style
- 🚩(frontend) conditionally render AI button only
  when feature is enabled

Changed:
- 🚨(frontend) block button when creating doc

Fixed:
- 🐛(back) validate document content in serializer
- 🐛(frontend) fix selection click
  past end of content
2025-04-08 12:41:38 +02:00
Anthony LC
de8e812f2f 🥚(frontend) remove easter egg
Remove the easter egg from the console.
2025-04-07 13:18:04 +02:00
Nathan Panchout
7a1601c682 (frontend) update favicon files and links
- Added new favicon files: favicon-dark.png and favicon.png.
- Updated the _app.tsx file to link to the new favicon files, supporting
both light and dark color schemes.
2025-04-07 13:18:04 +02:00
Nathan Panchout
0537572542 ♻️(frontend) icon component refactoring
- Add variant to IconComponent and remove $isMaterialIcon prop
- Replace all Text component used as icon with the Icon component.
2025-04-07 13:18:04 +02:00
Anthony LC
8aab007ad1 🐛(frontend) do not display firefox modal if not necessary
It is necessary to display the firefox modal only
if the user has something to save.
2025-04-07 13:18:04 +02:00
Anthony LC
cde3de43f7 ♻️(frontend) misc improvements
- add opacity props on Box
- rename to cunningham-style.css
- update illustration-docs-empty.png
- smaller tooltip
2025-04-07 13:18:04 +02:00
Anthony LC
8c0c3c2f44 🐛(frontend) fix selection click past end of content
On Chrome, when we click at the end of a line,
the cursor is placed at the beginning of the line.
We fix this behavior, now the cursor is placed
at the end of the line.
2025-04-07 13:18:04 +02:00
Anthony LC
c11d59c434 🚩(backend) add feature flag for the footer
We added the feature flag `FRONTEND_FOOTER_FEATURE_ENABLED`
to enable or disable the footer in the frontend.
2025-04-04 15:44:38 +02:00
Manuel Raynaud
8836109945 ♻️(back) reset cache after every test
We move the cache reset in the global conf test to not have to think
about reseting the cache when we implement test.
2025-04-04 15:44:38 +02:00
Anthony LC
ba136ff82f 🔧(backend) add view to manage footer json
We added the `FRONTEND_URL_JSON_FOOTER` environment
variable. It will give the possibility to generate
your own footer content in the frontend.
If the variable is not set, the footer will not
be displayed.
2025-04-04 15:44:38 +02:00
Anthony LC
96d9d1a184 🔊(y-provider) improve and add logs
We have somes entries with "No cookies", we
add more logs to understand why we have this case.
We add the datetime in front of each entries as
well.
2025-04-03 16:27:40 +02:00
Manuel Raynaud
771ffdc7cc 🔥(y-provider) remove npm in docker image
We use yarn and not npm, we remove npm because it has a dependencie with
cross-spawn which has a CVE.
2025-04-03 10:41:55 +02:00
Manuel Raynaud
82eba1e8ea 🔥(ci) force ci to fails if trivy fails
If trivy fails we must stop the CI to avoid publishing images with
security issues.
2025-04-03 10:41:55 +02:00
renovate[bot]
8c42599d0f ⬆️(dependencies) update django to v5.1.8 [SECURITY] 2025-04-03 10:28:12 +02:00
renovate[bot]
8620cf4857 ⬆️(dependencies) update next to v15.2.4 [SECURITY] 2025-04-03 07:02:20 +02:00
Baptiste Fontaine
2a7da73248 📝(docs) fix grammar and typos
Fix grammar and typos in docs/installation.md file.
2025-04-02 11:53:56 +02:00
Manuel Raynaud
e8e9922832 (back) remove url-normalize dependency
We have the dependency url-normalize installed but we don't use it in
our codebase.
2025-04-01 09:51:29 +02:00
renovate[bot]
2da4ce4570 ⬆️(dependencies) update python dependencies 2025-04-01 09:51:29 +02:00
Anthony LC
50b90f9ae7 (e2e) fix some flaky tests
Some tests were flaky, causing them to fail
intermittently. This commit aims to address
this issue.
2025-03-31 12:34:04 +02:00
Anthony LC
65ddf7fbe8 📝(docs) add documentation about runtime theming
Add a documentation page about runtime theming.
It explains how to use the theming system
and provide a example.
2025-03-31 12:34:04 +02:00
Anthony LC
d3a7ee74b3 💄(frontend) add classname to components
To be easily customized, we added a classname
to most of the components.
2025-03-31 12:34:04 +02:00
Anthony LC
65e450c6cc (frontend) add custom css style
From the config, we can add custom css style
to the app.
2025-03-31 12:34:04 +02:00
Anthony LC
725cae5470 🔧(backend) add FRONTEND_CSS_URL env var
We added the `FRONTEND_CSS_URL` environment
variable. It will give the possibility to add a
css layer at runtime.
2025-03-31 12:34:04 +02:00
Anthony LC
3881930e82 🚨(frontend) block button when creating doc
When the user clicks on the button to create a new doc,
the button is disabled to prevent multiple clicks.
Multiple clicks on the button could create multiple docs
and create a error about duplicated paths.
2025-03-31 11:29:28 +02:00
Anthony LC
910686293c ️(frontend) improve heading store
Reset headings only when the headers are not
equal to the previous ones. It will prevent
unnecessary rerenders.
2025-03-31 11:29:28 +02:00
Anthony LC
7e7c9ac4c5 ♻️(frontend) replace useModal hook
useModal hook does not use useCallback for its
methods that creates useless rerenders.
2025-03-31 11:29:28 +02:00
Anthony LC
d5d2cfab8e ♻️(frontend) improve useSaveDoc hook
- add tests for useSaveDoc hook
- simplify hooks states
- reduce useEffect calls
2025-03-31 11:29:28 +02:00
Matthias
f2ed8e0ea1 🐛(frontend) conditionally render AI button in toolbar
Added a feature flag check to ensure the AIGroupButton is only rendered
when AI_FEATURE_ENABLED is explicitly set to "true". This prevents the
AI button from appearing when the feature is not configured or disabled.

Fixes #782

Signed-off-by: Matthias <matthias@universum.com>
2025-03-31 11:04:00 +02:00
Manuel Raynaud
fbe8a26dba 🐛(back) validate document content in serializer
We recently extract images url in the content. For this, we assume that
the document content is always in base64. We enforce this assumption by
checking if it's a valide base64 in the serializer.
2025-03-29 19:08:39 +01:00
Berry den Hartog
3e974be9f4 📝(docs) describe environmental options for docs backend (#821)
Signed-off-by: 
Berry den Hartog <38954346+berrydenhartog@users.noreply.github.com>
2025-03-28 17:15:35 +00:00
Bastien Guerry
10f9d25920 📄(legal) add warning about legal compliance
We need to double-check our legal constraints regarding the use of XL
packages within Docs. In the meantime, sends a message to potential
reusers.
2025-03-28 17:15:16 +01:00
Jacques ROUSSEL
4178693e63 🐛(ci) use github action for argocd webhook notification
In order to refactor this notification between alls projetcs, we choose
to use a custom github action
2025-03-28 16:42:45 +01:00
Anthony LC
53be6de5f8 🔖(major) release 3.0.0
Added:
- 📄(legal) Require contributors to sign a DCO

Changed:
- ♻️(frontend) Integrate UI kit
- 🏗️(y-provider) manage auth in y-provider app

Fixed:
- 🐛(backend) compute ancestor_links in get_abilities
  if needed
- 🔒️(back) restrict access to document accesses
2025-03-28 15:32:08 +01:00
Anthony LC
4ff90abdee 🐛(service-worker) force reload new service worker
When multiple tabs are open, the new service worker
can stay in the "waiting" state and not be activated
until the other tabs with the old service worker
are closed.
We fix this by forcing the other tabs to reload
the page when a new service worker is detected.
All tabs will then be reloaded and the new service
worker will be activated.
2025-03-28 15:32:08 +01:00
Anthony LC
544dd00c16 🔧(helm) adapt setting helm dev file
The way that collaboration server authentifies the user
has changed. We adapt the configuration to the new
way of doing it, by removing the nginx auth url,
and by adding COLLABORATION_BACKEND_BASE_URL
setting.
2025-03-28 15:32:08 +01:00
Anthony LC
a3cd4c51ea 🩹(frontend) fine tunning for v3.0.0
- fix width select export
2025-03-28 15:32:08 +01:00
Manuel Raynaud
7e1eed3abd (y-provider) check hocuspocus documentName validity
We only use uuid v4 as hocuspocus dicument name. To be sure nothing else
is used we check that the documentName is a valid uuid version 4.
2025-03-27 18:42:04 +01:00
Manuel Raynaud
8bee476b5b 🔥(back) remove collaboration-auth endpoint
We don't need anymore the collaboration-auth endpoint. Every code
related to it is removed.
2025-03-27 18:42:04 +01:00
Manuel Raynaud
e86919fb9a 🏗️(y-provider) manage auth in y-provider app
The way to connect to the hocuspocus server needs to be proxified in
nginx to query a dedicated route in the django application and then
follow the request to the express server with the additionnal headers.
The auth can be done in the express server by querying the backend on
the document retrieve endpoint. If the response status code is 200, the
user has access to the document, otherwise it is not the case. Then we
can check the abilities to determine what the user can do or not.
2025-03-27 18:42:04 +01:00
Manuel Raynaud
a5b9169eb6 ♻️(back) replace Ypy by pycrdt
Ypy is deprecated and unmaintained. We have problem with parsing
existing documents. We replace it by pycrdt, library actively maintained
and without the issues we have with Ypy.
2025-03-27 18:27:04 +01:00
Manuel Raynaud
c0dfb4b6b3 ♻️(back) remove filtering on logging handler
Level filtering was used on the logging console handler. We remove as it
is not necessary to have it.
2025-03-27 18:27:04 +01:00
Manuel Raynaud
be051ad7d2 🐛(ci) use sha256 to sign argocd webhook call
The argocd webhook call needs now to use sha256 digest now to sign
2025-03-27 18:27:04 +01:00
Manuel Raynaud
a4452784e1 🔒️(back) restrict accesss to document accesses
Every user having an access to a document, no matter its role have
access to the entire accesses list with all the user details. Only
owner or admin should be able to have the entire list, for the other
roles, they have access to the list containing only owner and
administrator with less information on the username. The email and its
id is removed
2025-03-26 10:40:53 +01:00
Quentin BEY
2929e98260 ♻️(documents) inherit manager from queryset
During a code review, I saw we are overriding the MP_NodeManager and
redefine the queryset filters:

- The MP_NodeManager sorts the queryset by `path` by default and it's
  not done on our side, is it on purpose?
- The fact we need to redefine `readable_per_se` as a boilerplate is
  surprising.

I suggest we use the Django mechanism to generate the manager from the
queryset.
2025-03-24 15:04:50 +01:00
Manuel Raynaud
a1914c6259 🐛(backend) compute ancestor_links in get_abilities if needed
The refactor made in the tree view caching the ancestors_links to not
compute them again in the document.get_abilities method lead to a bug.
If the get_abilities method is called without ancestors_links, then they
are computed on all the ancestors but not from the highest readable
ancestor for the current user. We have to compute them with this
constraint.
2025-03-24 14:04:46 +01:00
Samuel Paccoud - DINUM
c882f1386c ♻️(backend) remove lazy from languages field on User model
The idea behind wrapping choices in `lazy` function was to allow
overriding the list of languages in tests with `override_settings`.
This was causin makemigrations to keep on including the field in
migrations when it is not needed. Since we finally don't override
the LANGUAGES setting in tests, we can remove it to fix the problem.
2025-03-24 10:43:45 +01:00
Samuel Paccoud - DINUM
c02f19a2cd (backend) extract attachment keys from updated content for access
We can't prevent document editors from copy/pasting content to from one
document to another. The problem is that copying content, will copy the
urls pointing to attachments but if we don't do anything, the reader of
the document to which the content is being pasted, may not be allowed to
access the attachment files from the original document.

Using the work from the previous commit, we can grant access to the readers
of the target document by extracting the attachment keys from the content and
adding themto the target document's "attachments" field. Before doing this,
we check that the current user can indeed access the attachment files extracted
from the content and that they are allowed to edit the current document.
2025-03-24 10:43:45 +01:00
Samuel Paccoud - DINUM
34a208a80d (backend) add duplicate action to the document API endpoint
We took this opportunity to refactor the way access is controlled on
media attachments. We now add the media key to a list on the document
instance each time a media is uploaded to a document. This list is
passed along when a document is duplicated, allowing us to grant
access to readers on the new document, even if they don't have or
lost access to the original document.

We also propose an option to reproduce the same access rights on the
duplicate document as what was in place on the original document.
This can be requested by passing the "with_accesses=true" option in
the query string.

The tricky point is that we need to extract attachment keys from the
existing documents and set them on the new "attachments" field that is
now used to track access rights on media files.
2025-03-24 10:43:45 +01:00
Samuel Paccoud - DINUM
6976bb7c78 (backend) fix migration test using model factory
Migration tests should not import and use factories or models
directly from the code because they would not be in sync with
the database in the state that each state needs to test it.

Instead the migrator object passed as argument allows us to
retrieve a minimal version of the models in sync with the state
of the database that we are testing. What we get is a minimal
model and we need to simulate all the methods that we could have
on the real model and that are needed for testing.
2025-03-24 10:43:45 +01:00
Samuel Paccoud - DINUM
621393165f (backend) add missing test on media-auth and collaboration-auth
These methods were involved in a bug that was fixed without first
evidencing the error in a test:
https://github.com/suitenumerique/docs/pull/556

Fixes https://github.com/suitenumerique/docs/issues/567
2025-03-24 10:43:45 +01:00
Samuel Paccoud - DINUM
3e9b530985 (backend) add missing tests for collaboration auth
Tests were forgotten. While writing the tests, I fixed
a few edge cases like the possibility to connect to the
collaboration server for an anonymous user.
2025-03-24 10:43:45 +01:00
Samuel Paccoud - DINUM
54f9b3963e ♻️(backend) refactor media_auth and collaboration_auth for flexibility
These 2 actions had factorized code but a few iterations lead to
spaghetti code where factorized code includes "if" clauses.

Refactor abstractions so that code factorization really works.
2025-03-24 10:43:45 +01:00
Samuel Paccoud - DINUM
710bbf512c (backend) add util to extract text from Ydoc content
Documents content is stored in the Ydoc format. We need a util
to extract it as xml/text.
2025-03-24 10:43:45 +01:00
Jacques ROUSSEL
747ca70186 🐛(ci) fix Tilt resources dependencies
The Tilt stack was not starting properly due to dependency issues. We
need to wait for PostgreSQL to be running before starting the migration.
2025-03-24 09:33:15 +01:00
renovate[bot]
9374495fda ⬆️(dependencies) update next to v15.2.3 [SECURITY] 2025-03-24 09:18:33 +01:00
Bastien Guerry
ef7cc67387 📄(legal) Require contributors to sign a DCO
Contributors are required to sign off their commits: this confirms
that they have read and accepted https://developercertificate.org.
2025-03-23 09:57:35 +01:00
Sylvain Zimmer
a8529e434a 🐛(media) fix compatibility with Scaleway Object Storage
Some providers with S3-compatible APIs have slightly different
implementations. In this case, Scaleway didn't accept version_id=""
and has a different version ID scheme. This was tested successfully
and should remain compatible with any other provider.
2025-03-22 18:00:43 +01:00
Manuel Raynaud
f8203a1766 🚨(back) lint code with ruff 0.11.2
New Ruff rule (C420) detects code that should be linted. We apply this
new rule on our code.
2025-03-22 10:28:48 +01:00
renovate[bot]
ce8b98e256 ⬆️(dependencies) update python dependencies 2025-03-22 10:28:48 +01:00
Anthony LC
4243519eee 🔥(frontend) remove Marianne font
Marianne font is now part of the UI kit.
We can remove it from the project.
2025-03-21 17:49:06 +01:00
Nathan Panchout
1abf529891 (frontend) refactor and theme token update
The configuration file has been simplified by importing configurations
from @gouvfr-lasuite/ui-kit . Colors and components have been updated to
reflect the new values. Additionally, adjustments have been made to
global styles, including the addition of styles for Material icons. Form
components have also been modified to incorporate the new style
properties.
2025-03-21 17:49:06 +01:00
Nathan Panchout
69ca4af539 (frontend) updated dependencies and added new packages
Added several new dependencies to the `package.json` file, including
`@dnd-kit/core`, `@dnd-kit/modifiers`, `@fontsource/material-icons`, and
`@gouvfr-lasuite/ui-kit`.
2025-03-21 17:49:06 +01:00
Anthony LC
14b2adedfb 🔖(minor) release 2.6.0
Added:
- 📝(doc) add publiccode.yml

Changed
- 🚸(frontend) ctrl+k modal not when editor is focused

Fixed:
- 🐛(back) allow only images to be used with
  the cors-proxy
- 🐛(backend) stop returning inactive users
  on the list endpoint
- 🔒️(backend) require at least 5 characters
  to search for users
- 🔒️(back) throttle user list endpoint
- 🔒️(back) remove pagination and limit to
   5 for user list endpoint
2025-03-21 17:07:26 +01:00
Anthony LC
a7edb382a7 🩹(frontent) change selector to block cmd+k
Multiple ctrl+k could open the search modal, we
change the selector, now if the toolbar is displayed
we don't open the search modal.
2025-03-21 17:07:26 +01:00
Anthony LC
fb5400c26b ️(frontend) search users with at least 5 characters
We now only search for users when the query
is at least 5 characters long.
2025-03-21 15:44:09 +01:00
Manuel Raynaud
8473facbee 🔒️(back) throttle user list endpoint
The user list endpoint is throttle to avoid users discovery. The
throttle is set to 500 requests per day. This can be changed using the
settings API_USERS_LIST_THROTTLE_RATE.
2025-03-21 15:44:09 +01:00
Anthony LC
5db446e8a8 🏷️(frontend) adapt type for user search
The response from the user request is now an
array of users, we don't paginate anymore.
We adapt the types to reflect this.
2025-03-21 15:44:09 +01:00
Manuel Raynaud
34dfb3fd66 🔒️(back) remove pagination and limit to 5 for user list endpoint
The user list endpoint does not use anymore a pagination, the results is
directly return in a list and the max results returned is limited to 5.
In order to modify this limit the settings API_USERS_LIST_LIMIT is
used.
2025-03-21 15:44:09 +01:00
Samuel Paccoud - DINUM
f9a91eda2d 🐛(backend) stop returning inactive users on the list endpoint
inactive users should not be returned as we don't want users to be
able to share new documents with them.
2025-03-21 15:44:09 +01:00
Samuel Paccoud - DINUM
eba926dea4 🔒️(backend) require at least 5 characters to search for users
Listing users is made a little to easy for authenticated users.
2025-03-21 15:44:09 +01:00
Anthony LC
3839a2e8b1 💄(frontend) improve contrast of Beta icon
The colors of the Beta icon were not contrasted
enough. This was posing an accessibility issue.
We now use a more contrasted color.
2025-03-21 09:22:42 +01:00
Anthony LC
a88d62e07d 🌐(frontend) make Docs title translatable
The title of the docs page was not translatable.
We now use the `t` function to translate the title.
2025-03-21 09:22:42 +01:00
Paul Mustière
b61a7a4961 📝(docs) fix typo
Correct language to not be past tense
2025-03-21 06:38:27 +01:00
Anthony LC
20d32ecc4e 🚸(frontend) ctrl+k modal not when editor is focused
ctrl+k interaction was as well used in the editor.
So if the user has a focus on the editor, we don't
open the searchmodal.
2025-03-20 17:43:32 +01:00
Manuel Raynaud
313acf4f78 🐛(back) allow only images to be used with the cors-proxy
The cors-proxy endpoint allowed to use every type of files and to
execute it in the browser. We limit the scope only to images and
Content-Security-Policy and Content-Disposition headers are also added
to not allow script execution that can be present in a SVG file.
2025-03-20 16:10:47 +01:00
Bastien
3a6105cc7e 📝(doc) add publiccode.yml (#770)
publiccode.yml is a standard for describing Free Software projects,
similar to other initiatives such as https://codemeta.github.io.

It is particularly suitable for describing projects funded by public
administrations. See https://github.com/publiccodeyml/publiccode.yml
2025-03-19 21:28:32 +01:00
Anthony LC
bbe17156be 🔖(minor) release 2.5.0
Added:
- 📝(doc) Added GNU Make link to README
- (frontend) add pinning on doc detail
- 🚩(frontend) feature flag analytic on copy as html
- (frontend) Custom block divider with export
- 🌐(i18n) activate dutch language

Changed:
- 🧑‍💻(frontend) change literal section open source
- ♻️(frontend) replace cors proxy for export
- 🚨(gitlint) Allow uppercase in commit messages

Fixed:
- 🐛(frontend) SVG export
- 🐛(frontend) remove scroll listener table content
- 🔒️(back) restrict access to favorite_list endpoint
- 🐛(backend) refactor to fix filtering on children
    and descendants views
- 🐛(action) fix notify-argocd workflow
- 🚨(helm) fix helmfile lint
- 🚚(frontend) redirect to 401 page when 401 error
2025-03-19 14:11:47 +01:00
Anthony LC
51cc26b916 🐛(frontend) improve svg export to be less pixelized
Some SVGs were pixelized in the exported files.
We now add the wanted size to the svg conversion to
make sure the images are exported with the correct size
and so less pixelized.
2025-03-19 14:11:47 +01:00
Anthony LC
cab8ef51df 🐛(frontend) unmount components Analytics
`useAnalytics` hooks was dispatching methods that
caused children components to be unmounted.
By declaring the methods out of the hook, we can
prevent the components from being unmounted.
2025-03-18 14:53:09 +01:00
Anthony LC
6627518017 🚚(frontend) redirect to 401 page when 401 error
Users could still be able to edit a document if the
session was expired. It could give the feeling that the
document was not saved.
If during a mutation request (POST, PUT, DELETE),
the server returns a 401 error,
the user is redirected to the 401 page.
2025-03-18 14:53:09 +01:00
Pedro Manse
12c18bc4e9 📝(README) Add link to GNU Make
Just like docker-compose, create link to the software's site on it's
first mention.

📝(Changelog) Added entry

📝(Changelog) Added pull request id
2025-03-18 11:07:22 +01:00
Anthony LC
aff330eb5b 🚨(gitlint) Allow uppercase in commit messages
Many developers use uppercase as the first letter
in their commit messages, it creates an error.
We will allow uppercase in commit messages to
lower frustration when committing.
2025-03-18 10:24:08 +01:00
Cameron King
bcdaedba9b 🐛(backend) add user/db to pg healthchecks
Adds PostgreSQL user and database names to the docker-compose.yaml healthchecks.
This resolves an error that appears in the logs, where 'root' is used by
default.
2025-03-18 09:41:27 +01:00
Manuel Raynaud
799814e3e3 🌐(i18n) activate dutch language
All the dutch translations are complete on crowdin. We activate it in
the django settings and download all translations from crowdin
2025-03-18 09:27:13 +01:00
virgile-dev
02c9b2ea2e 🐛(readme) fix preprod link to redirect to homepage (#747)
The current link redirects to a 404. New link redirect to homepage.
2025-03-17 16:02:45 +00:00
Manuel Raynaud
eb23aefd55 ♻️(back) use same base route path for swagger
Swaggers urls where not using the same base route path /api/v1.0, we
prepend it to have the same path everywhere. Moreover, a double slash
was used for swagger and redoc dashboard.
2025-03-17 15:02:34 +01:00
Manuel Raynaud
0c49019490 🚨(helm) fix helmfile lint
Latest release of helmfile is applying the change related before as a
warning. Environnements must be before releases but not in the same
document of repositories.
2025-03-17 14:40:55 +01:00
Anthony LC
170dbe07bb ⬆️(frontend) bump @babel/runtime /src/frontend
Bumps @babel/runtime from 7.26.7 to 7.26.10.
2025-03-17 13:50:20 +01:00
Manuel Raynaud
70136f2415 🐛(action) fix notify-argocd workflow
The notify-argocd workflow was not working correctly. The html_url sent
to argocd was not the good one anymore.
2025-03-17 12:09:18 +01:00
Anthony LC
2a8fc97f2f ⬆️(frontend) bump @babel/helpers in /src/frontend
Bumps @babel/helpers from 7.26.7 to 7.26.10.
2025-03-17 11:50:22 +01:00
Anthony LC
9570701bc3 ⬆️(frontend) bump @babel/runtime /src/mail
Bumps @babel/runtime from 7.26.0 to 7.26.10.
2025-03-17 11:36:04 +01:00
Anthony LC
4b28b3c23b (frontend) add pinning on doc detail
Add pinning button on doc detail page.
2025-03-17 11:16:50 +01:00
Anthony LC
f26fc43df0 🔥(frontend) remove DocTagPublic component
DocTagPublic component was removed because
it was not used.
2025-03-17 11:16:50 +01:00
Anthony LC
05a6818439 🧑‍💻(e2e) display more information when auth fails
When the auth fails, it was quite obscure to
understand what was going on.
We now take a screenshot of the page and display
the console logs.
2025-03-17 09:30:19 +01:00
Anthony LC
8056fd7d66 🚚(frontend) add import path for features/docs tsconfig
To simplify imports, we add the import path @/docs
to target ./src/features/docs/.
We changed all imports to use this path.
2025-03-17 09:30:19 +01:00
Samuel Paccoud - DINUM
c85224af42 📝(README) remove AGPL mention
This mention was confusing. We are only using min.io for development
purposes and this has nothing to do with the project's licence.
2025-03-16 19:00:14 +01:00
Anthony LC
70f1b6a8e8 🚩(frontend) add feature flag on "Copy as HTML"
As a blue print, we add a feature flag on
"Copy as HTML" button in the doc toolbox.
This feature flag is controlled by the `CopyAsHtml`
feature flag.

Be aware:
- if the feature flag is disabled, the button
will be shown
- if the feature flag is enabled and send true,
the button will be shown
- if the feature flag is enabled and send false,
the button will be hidden
2025-03-14 16:26:12 +01:00
Anthony LC
0f07fdcb65 📈(frontend) get user analytics
We will identify users by their email address.
This will help us understand how users interact
with the platform and improve the user experience.
2025-03-14 16:26:12 +01:00
Anthony LC
2e13dfb9bc 📈(frontend) abstract analytics classes
Add abstract classes for analytics services.
We will be able to add easily any analytic
services.
Our first analytic service usecase is Posthog.
2025-03-14 16:26:12 +01:00
renovate[bot]
a026435eb7 ⬆️(dependencies) update canvg to v4.0.3 [SECURITY] 2025-03-14 15:29:27 +01:00
Anthony LC
7007d56c38 🐛(frontend) fix svg not rendering export dox
The svg was not rendering in the dox export.
We overwrite the default mapping to convert the
svg to png before rendering.
The images could be out of the page as well,
we fixed this issue by adding a maxWidth to the image.
2025-03-14 15:13:51 +01:00
Anthony LC
0405e6a3f6 🐛(frontend) fix svg not rendering export pdf
The svg was not rendering in the pdf export.
We overwrite the default mapping to convert the
svg to png before rendering.
The images could be out of the page as well,
we fixed this issue by adding a maxWidth to the image.
2025-03-14 15:13:51 +01:00
Anthony LC
cb8bd4b937 🐛(frontend) fix flakiness e2e tests title doc
- fix multiple states title
- wait for stabilize network after create
- fix test other chromium browsers
- improve grid delete test
2025-03-14 14:49:17 +01:00
Anthony LC
4316b4e67d (frontend) adapt export to divider block
We have a new block type, the divider block.
We have to adapt the export to handle this
new block type.
2025-03-14 10:39:07 +01:00
Anthony LC
534085439f (frontend) add divider blocks to the editor
Add a custom block to add a divider in the editor.
2025-03-14 10:39:07 +01:00
Anthony LC
da02d3d756 🎨(frontend) use blockquote tag for quote block
Use the blockquote tag for quote block instead of
a paragraph tag.
2025-03-13 16:38:33 +01:00
Anthony LC
87960d3773 (AI) add emojify action to ai transform
The emojify action add emojis to the important
parts of the text.
2025-03-13 16:38:33 +01:00
Anthony LC
e0af6d36e1 (AI) add beautify action to ai transform
The beautify action add emojis to the important
parts of the text and add formatting to the text
to make it more readable.
2025-03-13 16:38:33 +01:00
Anthony LC
cbf9091d1c ️(AI) improve formating of ai translation
The ai translation were quite lossy about formatting.
Colors, background, breaklines, table sizes were
lost in the translation.
We improve the AI translation request to keep
the formatting as close as possible by using
html instead of markdown.
2025-03-13 16:38:33 +01:00
Anthony LC
9176328200 ♻️(frontend) replace cors proxy for export
We were using the cors proxy of Blocknote.js
to export the document. Now we use our own proxy
to avoid CORS issues.
2025-03-13 12:39:32 +01:00
Anthony LC
6efc2377fe (back) create a cors proxy fetching docs external resources
When exporting a document in PDF and if the doc contains external
resources, we want to fetch them using a proxy bypassing CORS
restrictions. To ensure this endpoint is not used for something else
than fetching urls contains in the doc, we use access control and check
if the url really exists in the document.
2025-03-13 12:39:32 +01:00
Anthony LC
1c02b0ad8e 📝(frontend) change literal section open source
Change literal section open source.
2025-03-12 09:53:01 +01:00
Manuel Raynaud
007854a877 ️(back) use redis as session backend in developement
We want to persist the session during development. Otherwise the session
is reset everytime the server is restart. This behavior make developing
bot a front and back feature a nigthmare, we spend our time login again
and again
2025-03-12 08:28:20 +01:00
Anthony LC
57cead448d 🏷️(frontend) adapt new doc type nb_accesses_direct
We renamed the `nb_accesses` field to `nb_accesses_direct`
and added a new `nb_accesses_ancestors` field.
We adapt the frontend to use the new fields.
2025-03-11 09:32:48 +01:00
Samuel Paccoud - DINUM
f20d256cd1 🐛(backend) fix numchild when soft deleting/restoring a document
The numchild attribute must be incremented/decremented manually
when we soft delete a document if we want it to remain accurate,
which is important to display the tree structure in the frontend.
2025-03-11 09:32:48 +01:00
Samuel Paccoud - DINUM
76c01df3ae (backend) add number of direct accesses related to a document
The "nb_accesses" field was displaying the number of access instances
related to a document or any of its ancestors. Some features on the
frontend require to know how many of these access instances are related
to the document directly.
2025-03-11 09:32:48 +01:00
Samuel Paccoud - DINUM
20315e9b60 (backend) limit link reach/role select options depending on ancestors
If a document already gets a link reach/role inheriting from one of its
ancestors, we should not propose setting link reach/role on the
document that would be more restrictive than what we inherited from
ancestors.
2025-03-11 09:32:48 +01:00
Samuel Paccoud - DINUM
2203d49a52 (backend) add new "descendants" action to document API endpoint
We want to be able to make a search query inside a hierchical document.
It's elegant to do it as a document detail action so that we benefit
from access control.
2025-03-11 09:32:48 +01:00
Samuel Paccoud - DINUM
56aa69f56a ♻️(backend) refactor list view to allow filtering other views
the "filter_queryset" method is called in the middle of the
"get_object" method. We use the "get_object" in actions like
"children", "tree", etc. which start by calling "get_object"
but return lists of documents.

We would like to apply filters to these views but the it didn't
work because the "get_object" method was also impacted by the
filters...

In a future PR, we should take control of the "get_object" method
and decouple all this. We need a quick solution to allow releasing
the hierchical documents feature in the frontend.
2025-03-11 09:32:48 +01:00
Samuel Paccoud - DINUM
0aabf26694 (backend) add "tree" action on document API endpoint
We want to display the tree structure to which a document belongs
on the left side panel of its detail view. For this, we need an
endpoint to retrieve the list view of the document's ancestors
opened.

By opened, we mean that when display the document, we also need to
display its siblings. When displaying the parent of the current
document, we also need to display the siblings of the parent...
2025-03-11 09:32:48 +01:00
Samuel Paccoud - DINUM
fcf8b38021 (backend) allow forcing page size within limits
We want to be able to increase the page size with by passing
the query string parameter "page_size".
2025-03-11 09:32:48 +01:00
renovate[bot]
757d7f35cd ⬆️(dependencies) update django to v5.1.7 [SECURITY] 2025-03-10 10:19:56 +01:00
Anthony LC
fdc49dc002 🐛(frontend) remove scroll listener table content
During a useEffect cleaning, the selector was
not the correct one. The debounce was not being
removed correctly neither.
2025-03-10 10:06:59 +01:00
Anthony LC
197ba47f73 ⬆️(frontend) bump cunningham to 3.0.0
Last bump to react 19 was a breaking change with
the previous version of Cunnigham, so we need to
update cunningham to 3.0.0 to be compatible with it.
We can now remove Cunnigham from the list of ignored
dependencies in the renovate.json file.
2025-03-10 09:26:19 +01:00
Anthony LC
d5997ba9d5 ⬆️(frontend) bump to react 19.0.0
Last version of Blocknotes is compatible with
React 19.0.0, it seems even necessary to
bump the version of React to 19.0.0.
We bump the version of React to 19.0.0 and
remove the react packages from renovate
list of ignored dependencies.
2025-03-10 09:26:19 +01:00
Anthony LC
1c6d18fdf3 📌(frontend) pin yjs globally
We had a warning about yjs multiple versions
between dependencies. We pinned yjs globally
to avoid this warning and potential side effects.
2025-03-10 09:26:19 +01:00
Anthony LC
24d126f410 🚨(frontend) fix linter
- fix linter
- remove unnecessary style files
2025-03-10 09:26:19 +01:00
renovate[bot]
a5e1751cf3 ⬆️(dependencies) update js dependencies 2025-03-10 09:26:19 +01:00
Manuel Raynaud
0cabb655ad 🔒️(back) restrict access to favorite_list endpoint
favorite_list endpoint is accessible to anonymous user. This lead to an
error 500. This endpoint should be accessible only to authenticated
users.
2025-03-07 09:04:44 +01:00
Manuel Raynaud
38eb6d45b7 🐛(back) fix ValidationError exception handler
The exception handler for the ValidationError was not testing correctly
the existence of some attributes like `message_dict`.
2025-03-07 09:04:44 +01:00
Anthony LC
5bb7ad643a 🔖(minor) release 2.4.0
Added:
- (frontend) synchronize language-choice

Changed:
- Use sentry tags instead of extra scope

Fixed:
- 🐛(frontend) fix collaboration error
2025-03-06 15:59:34 +01:00
Anthony LC
57b8881fc6 📌(frontend) pin blocknote globally
Blocknote does not pinned the version.
We get bumped version instead of the version we want.
We pin the version of blocknote globally to
avoid this issue.
2025-03-06 15:09:17 +01:00
Anthony LC
89ad610ba6 🐛(frontend) fix collaboration error
We upgrade blocknote to 0.23.2-hotfix.0,
it includes a fix with the collaboration.
2025-03-06 15:09:17 +01:00
rvveber
251787b835 🚨(tests) add language related tests; fix getByRole not working in tests
- adds tests and test-utility for solid language switching in tests
- fixes where ...getByRole(menuitem... would not return a valid object
2025-03-05 14:29:24 +01:00
rvveber
f95173e096 🐛(frontend) allow left panel to update on language change
- fixes a bug where after language-sync the left panel would remain
  in the same language as before.
2025-03-05 14:29:24 +01:00
rvveber
a7944cce80 (app) get language from backend; set browser-detected language if null
- adds useLanguageSynchronizer hook to update the:
  1. frontend-language to the user-preference - if there is one.
  2. user-preference to the (browser-detected) frontend-language - otherwise.
2025-03-05 14:29:24 +01:00
rvveber
7941fc91d5 🐛(frontend) set toolbar-popup to current language
- ensure editor is translated to i18n.resolvedLanguage => en
  as i18n.language could hold more accurate locale => en-GB etc..
2025-03-05 14:29:24 +01:00
rvveber
7fc83a4fcd (frontend) the LanguagePicker now uses config as options
- config endpoint languages are used as available options for LanguagePicker
- updating the language from it, triggers an update on the user via API
2025-03-05 14:29:24 +01:00
rvveber
2bf47b7705 (backend) the LanguagePicker now uses config as options
- config endpoint languages are used as available options for LanguagePicker
- updating the language from it, triggers an update on the user via API
2025-03-05 14:29:24 +01:00
rvveber
23b0214a2a (frontend) add language utility for "locale"
- Adds a helper for working with locales
- More details in their annotations
- Unnecessary, if in the future, the backend uses
  the same locales as the keys in the translations (ISO 639-1)
2025-03-05 14:29:24 +01:00
rvveber
f244509de3 (frontend) add API access for 'language' attribute on User model
- allow the language attribute on the user to be updated via API
- add frontend function to update the user language via API
- extend defaults on the test users, to have fixed language in E2E tests
- extend types and variables using the types with the new field
2025-03-05 14:29:24 +01:00
rvveber
fda5f8f008 (backend) add API access for 'language' attribute on User model
- allow the language attribute on the user to be updated via API
- add frontend function to update the user language via API
- extend defaults on the test users, to have fixed language in E2E tests
- extend types and variables using the types with the new field
2025-03-05 14:29:24 +01:00
rvveber
9a79b09b07 🔨(backend) make the 'language' attribute on the User model nullable
- allow the language on the user to be unset
- set the default language to be unset
- helps us determine that the user has yet to set a language preference
2025-03-05 14:29:24 +01:00
rvveber
b24acd14e2 🔨(frontend) email invitation in invited user's language
- language for invitation emails => language saved on the invited user
- if invited user does not exist yet => language of the sending user
- if for some reason no sending user => system default language
2025-03-05 14:29:24 +01:00
rvveber
1531846115 🔨(backend) email invitation in invited user's language
- language for invitation emails => language saved on the invited user
- if invited user does not exist yet => language of the sending user
- if for some reason no sending user => system default language
2025-03-05 14:29:24 +01:00
Manuel Raynaud
ebf6d46e37 ♻️(front) use sentry tags instead of extra scope
To ease filtering issues on sentry, we want to use tags instead of extra
scope. Tags are indexed and searchable, it's not the case with extra
scope. Moreover using setEtra to add additional data is deprecated.
2025-03-05 10:26:23 +01:00
Manuel Raynaud
b9b5f86cf4 ️(back) restrict documents to restore using only the queryset
To determine the descendant to restore or not, we were looking building
a complex exclude clause. This can be simplify focusing only on data we
already have without making an extra query to fetch the list of
descendant to exclude.
2025-03-04 18:03:18 +01:00
renovate[bot]
56412b0be5 ⬆️(dependencies) update python dependencies 2025-03-04 14:24:58 +01:00
Anthony LC
af052cd06b 🔖(minor) release 2.3.0
Added:
- 💄(frontend) add error pages
- 🔒️ Manage unsafe attachments
- (frontend) Custom block quote with export
- (frontend) add open source section homepage

Changed:
- 🛂(frontend) Restore version visibility
- 📝(doc) minor README.md formatting and wording enhancements
- ♻️Stop setting a default title on doc creation
- ♻️(frontend) misc ui improvements

Fixed:
- 🐛(backend) allow any type of extensions for media download
- ♻️(frontend) improve table pdf rendering
2025-03-04 12:12:57 +01:00
Anthony LC
8927635c5f 💄(frontend) hide Crisp when mobile and modal
The Crisp button was hidding buttons on mobile
when a modal was open. This commit hides the
Crisp button when a modal is open on mobile.
2025-03-04 12:12:57 +01:00
Anthony LC
76bce4313b 🩹(frontend) fine tuning 2.3.0
- improve medium button style when 2 lines
- improve design on Firefox input title
- manage title modal without doc title
- improve redirect when 401
2025-03-04 12:12:57 +01:00
Anthony LC
5ac71bfac1 🐛(service-worker) update sw to create a doc without body
Offline creation of a doc was broken because we
don't add a default title anymore when we create a
doc, leading to POST requests without body.
we need to adapt the service worker to handle this
case.
2025-03-04 12:12:57 +01:00
Anthony LC
cb4e148afc ♻️(email) adapt email when no title
Default title is not set when we create a document
anymore. We need to adapt the email to handle
this case.
2025-03-04 12:12:57 +01:00
AntoLC
2d24825be0 🌐(i18n) update translated strings
Update translated files with new translations
2025-03-04 12:12:57 +01:00
Anthony LC
7b1ddc0e05 🛂(backend) remove svg from unsafe
We added content-security-policy on nginx.
It should be safe to allow svg files now.
We remove the svg file from the unsafe
attachments list. We adapt the tests accordingly.
2025-03-03 13:18:40 +01:00
Manuel Raynaud
22a665e535 🔒️(nginx) manage Content-Security-Policy in nginx config
The media route is managed by nginx. On this route we want to add the
Content-Security-Header to forbid fetching any resources.
See : https://content-security-policy.com/
2025-03-03 13:18:40 +01:00
Manuel Raynaud
a22bf95bce 🔒️(back) set ContentDisposition on media upload
On the media upload endpoint, we want to set the content-disposition
header. Its value is based on the uploaded file mime-type and if flagged
as unsafe. If the file is not an image or is unsafe then the
contentDisposition is set to attachment to force its download.
Otherwise, we set it to inline.
2025-03-03 13:18:40 +01:00
Anthony LC
3ce1826355 🚚(frontend) toolbar components in BlockNoteToolBar folder
We moved the toolbar components in BlockNoteToolBar
folder.
2025-03-03 13:18:40 +01:00
Anthony LC
d099d58f77 🛂(frontend) secure download button
Blocknote download button opens the file in a new
tab, which could be not secure because of XSS attacks.
We replace the download button with a new one that
downloads the file instead of opening it in a new tab.
Some files are flags as unsafe (SVG / js / exe),
for these files we add a confirmation modal before
downloading the file to prevent the user from
downloading a file that could be harmful.
In the future, we could add other security layers
from this model, to analyze the file before
downloading it by example.
2025-03-03 13:18:40 +01:00
Anthony LC
ebd49f05a8 🚸(frontend) block click on unsafe image
We want to prevent the user to open unsafe images
in the browser. We blocked the click on the images.
To download them, the user will have to use the
download button.
2025-03-03 13:18:40 +01:00
Anthony LC
315c2c2c43 🐛(frontend) improve authenticated state
It can happen that the user is authenticated
then the token is expired. The authenticated
state should be updated to false in this case.
2025-03-03 13:18:40 +01:00
Anthony LC
e442908c50 💄(frontend) improve the design of the alert error
Since the new design implementation,
the alert error was not looking good.
This commit improves the design of the alert error.
2025-03-03 13:18:40 +01:00
Anthony LC
6672292d93 🛂(backend) add unsafe in the attachments filename
The frontend cannot access custom headers of a file,
so we need to add a flag in the filename.
We add the `unsafe` flag in the filename to
indicate that the file is unsafe.

Previous filename: "/{UUID4}.{extension}"
New filename: "/{UUID4}-unsafe.{extension}"
2025-03-03 13:18:40 +01:00
Manuel Raynaud
7dda74421f ♻️(back) extract ancestor deleted_at directly from db in restore method
In the restore method, all the ancestors with a deleted_at date set are
extracted from the database and then the oldest value is extracted using
the min python function. This usage of min can be removed by sorting
directly the deleted_at at the databse level and then fetching the first
one. It's faster and easier to maintain.
2025-03-03 13:05:36 +01:00
Anthony LC
9c25b684e3 (frontend) add open source section homepage
We decided to add the open source section on
the homepage of Docs.
2025-03-03 12:42:18 +01:00
Anthony LC
cd5ee3fb7c (frontend) adapt export to quote block
We have a new block type, the quote block.
We have to adapt the export to handle this
new block type.
2025-03-03 12:27:02 +01:00
Anthony LC
942c0f059c 🏗️(frontend) blockMapping refactoring
As made for TablePDF, we separate the block mapping
in separate files. This will allow us to have
a better separation of concerns and to have
a more maintainable codebase.
We improve as well the typing. It will be easier
to add new blocks in the future.
2025-03-03 12:27:02 +01:00
Anthony LC
3acee1e6fa (frontend) create feature doc-export
Create the feature doc-export, it will be
responsible for exporting the document.
2025-03-03 12:27:02 +01:00
Anthony LC
26ea32bd0b 🏷️(frontend) adapt title types
We recently changed the default title behavior.
It can now be undefined, we have to change the
types accordingly.
2025-03-03 12:27:02 +01:00
Anthony LC
7f6ffa0123 (frontend) add quote blocks to the editor
Add a custom block to quote in the editor.
2025-03-03 12:27:02 +01:00
Samuel Paccoud - DINUM
ef2127585c 🐛(backend) allow any type of extensions for media download
The regex to validate media file extensions was too restrictive.
2025-03-03 11:21:41 +01:00
353 changed files with 22908 additions and 11433 deletions

View File

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

View File

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

View File

@@ -8,9 +8,142 @@ and this project adheres to
## [Unreleased]
## Fixed
- 🔒(frontend) enhance file download security #889
## Added
- 🚸(backend) make document search on title accent-insensitive #874
- 🚩 add homepage feature flag #861
- ✨(settings) Allow configuring PKCE for the SSO #886
- 🌐(i18n) activate chinese and spanish languages #884
- 🔧(backend) allow overwriting the data directory #893
- (backend) add `django-lasuite` dependency #839
## Changed
- ⚡️(frontend) reduce unblocking time for config #867
- ♻️(frontend) bind UI with ability access #900
## Fixed
- 🐛(nginx) fix 404 when accessing a doc #866
- 🔒️(drf) disable browsable HTML API renderer #919
## [3.1.0] - 2025-04-07
## Added
- 🚩(backend) add feature flag for the footer #841
- 🔧(backend) add view to manage footer json #841
- ✨(frontend) add custom css style #771
- 🚩(frontend) conditionally render AI button only when feature is enabled #814
## Changed
- 🚨(frontend) block button when creating doc #749
## Fixed
- 🐛(back) validate document content in serializer #822
- 🐛(frontend) fix selection click past end of content #840
## [3.0.0] - 2025-03-28
## Added
- 📄(legal) Require contributors to sign a DCO #779
## Changed
- ♻️(frontend) Integrate UI kit #783
- 🏗️(y-provider) manage auth in y-provider app #804
## Fixed
- 🐛(backend) compute ancestor_links in get_abilities if needed #725
- 🔒️(back) restrict access to document accesses #801
## [2.6.0] - 2025-03-21
## Added
- 📝(doc) add publiccode.yml #770
## Changed
- 🚸(frontend) ctrl+k modal not when editor is focused #712
## Fixed
- 🐛(back) allow only images to be used with the cors-proxy #781
- 🐛(backend) stop returning inactive users on the list endpoint #636
- 🔒️(backend) require at least 5 characters to search for users #636
- 🔒️(back) throttle user list endpoint #636
- 🔒️(back) remove pagination and limit to 5 for user list endpoint #636
## [2.5.0] - 2025-03-18
## Added
- 📝(doc) Added GNU Make link to README #750
- ✨(frontend) add pinning on doc detail #711
- 🚩(frontend) feature flag analytic on copy as html #649
- ✨(frontend) Custom block divider with export #698
- 🌐(i18n) activate dutch language #742
- ✨(frontend) add Beautify action to AI transform #478
- ✨(frontend) add Emojify action to AI transform #478
## Changed
- 🧑‍💻(frontend) change literal section open source #702
- ♻️(frontend) replace cors proxy for export #695
- 🚨(gitlint) Allow uppercase in commit messages #756
- ♻️(frontend) Improve AI translations #478
## Fixed
- 🐛(frontend) SVG export #706
- 🐛(frontend) remove scroll listener table content #688
- 🔒️(back) restrict access to favorite_list endpoint #690
- 🐛(backend) refactor to fix filtering on children
and descendants views #695
- 🐛(action) fix notify-argocd workflow #713
- 🚨(helm) fix helmfile lint #736
- 🚚(frontend) redirect to 401 page when 401 error #759
## [2.4.0] - 2025-03-06
## Added
- ✨(frontend) synchronize language-choice #401
## Changed
- Use sentry tags instead of extra scope
## Fixed
- 🐛(frontend) fix collaboration error #684
## [2.3.0] - 2025-03-03
## Added
- ✨(backend) limit link reach/role select options depending on ancestors #645
- ✨(backend) add new "descendants" action to document API endpoint #645
- ✨(backend) new "tree" action on document detail endpoint #645
- ✨(backend) allow forcing page size within limits #645
- 💄(frontend) add error pages #643
- 🔒️ Manage unsafe attachments #663
- ✨(frontend) Custom block quote with export #646
- ✨(frontend) add open source section homepage #666
- ✨(frontend) synchronize language-choice #401
## Changed
@@ -21,7 +154,14 @@ and this project adheres to
## Fixed
- 🐛(backend) allow any type of extensions for media download #671
- ♻️(frontend) improve table pdf rendering
- 🐛(email) invitation emails in receivers language
## Fixed
- 🐛(backend) race condition create doc #633
## [2.2.0] - 2025-02-10
@@ -46,6 +186,8 @@ and this project adheres to
## Added
- ✨(backend) add duplicate action to the document API endpoint
- ⚗️(backend) add util to extract text from base64 yjs document
- ✨(backend) add soft delete and restore API endpoints to documents #516
- ✨(backend) allow organizing documents in a tree structure #516
- ✨(backend) add "excerpt" field to document list serializer #516
@@ -409,7 +551,13 @@ and this project adheres to
- ✨(frontend) Coming Soon page (#67)
- 🚀 Impress, project to manage your documents easily and collaboratively.
[unreleased]: https://github.com/numerique-gouv/impress/compare/v2.2.0...main
[unreleased]: https://github.com/numerique-gouv/impress/compare/v3.1.0...main
[v3.1.0]: https://github.com/numerique-gouv/impress/releases/v3.1.0
[v3.0.0]: https://github.com/numerique-gouv/impress/releases/v3.0.0
[v2.6.0]: https://github.com/numerique-gouv/impress/releases/v2.6.0
[v2.5.0]: https://github.com/numerique-gouv/impress/releases/v2.5.0
[v2.4.0]: https://github.com/numerique-gouv/impress/releases/v2.4.0
[v2.3.0]: https://github.com/numerique-gouv/impress/releases/v2.3.0
[v2.2.0]: https://github.com/numerique-gouv/impress/releases/v2.2.0
[v2.1.0]: https://github.com/numerique-gouv/impress/releases/v2.1.0
[v2.0.1]: https://github.com/numerique-gouv/impress/releases/v2.0.1

View File

@@ -4,6 +4,8 @@ Thank you for taking the time to contribute! Please follow these guidelines to e
To get started with the project, please refer to the [README.md](https://github.com/suitenumerique/docs/blob/main/README.md) for detailed instructions.
Contributors are required to sign off their commits with `git commit --sign-off`: this confirms that they have read and accepted the [Developer's Certificate of Origin 1.1](https://developercertificate.org/).
Please also check out our [dev handbook](https://suitenumerique.gitbook.io/handbook) to learn our best practices.
## Help us with translations
@@ -35,7 +37,7 @@ All commit messages must adhere to the following format:
* <**gitmoji**>: Use a gitmoji to represent the purpose of the commit. For example, ✨ for adding a new feature or 🔥 for removing something, see the list here: <https://gitmoji.dev/>.
* **(type)**: Describe the type of change. Common types include `backend`, `frontend`, `CI`, `docker` etc...
* **title**: A short, descriptive title for the change, starting with a lowercase character.
* **title**: A short, descriptive title for the change.
* **description**: Include additional details about what was changed and why.
### Example Commit Message

View File

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

View File

@@ -37,25 +37,22 @@ Docs is a collaborative text editor designed to address common challenges in kno
* 🤝 Collaborate with your team in real time
* 🔒 Granular access control to ensure your information is secure and only shared with the right people
* 📑 Professional document exports in multiple formats (.odt, .doc, .pdf) with customizable templates
* 📚 Built-in wiki functionality to turn your team's collaborative work into organized knowledge `ETA 02/2025`
* 📚 Built-in wiki functionality to turn your team's collaborative work into organized knowledge `ETA 05/2025`
### Self-host
* 🚀 Easy to install, scalable and secure alternative to Notion, Outline or Confluence
⚠️ For the PDF and Docx export Docs relies on XL packages from BlockNote licenced in AGPL-3.0. Please make sure you fulfill your obligations regarding BlockNote licensing (see https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-pdf-exporter/LICENSE and https://www.blocknotejs.org/about#partner-with-us).
## Getting started 🔧
### Test it
Test Docs on your browser by logging in on this [environment](https://impress-preprod.beta.numerique.gouv.fr/docs/0aa856e9-da41-4d59-b73d-a61cb2c1245f/)
```
email: test.docs@yopmail.com
password: I'd<3ToTestDocs
```
Test Docs on your browser by visiting this [demo document](https://impress-preprod.beta.numerique.gouv.fr/docs/6ee5aac4-4fb9-457d-95bf-bb56c2467713/)
### Run it locally
> ⚠️ Running Docs locally using the methods described below is for testing purposes only. It is based on building Docs using Minio as the S3 storage solution: if you want to use Minio for production deployment of Docs, you will need to comply with Minio's AGPL-3.0 licence.
> ⚠️ Running Docs locally using the methods described below is for testing purposes only. It is based on building Docs using Minio as the S3 storage solution but you can choose any S3 compatible object storage of your choice.
**Prerequisite**
@@ -75,7 +72,7 @@ Docker Compose version v2.32.4
**Project bootstrap**
The easiest way to start working on the project is to use GNU Make:
The easiest way to start working on the project is to use [GNU Make](https://www.gnu.org/software/make/):
```shellscript
$ make bootstrap FLUSH_ARGS='--no-input'
@@ -121,6 +118,7 @@ $ make run-backend
```
**Adding content**
You can create a basic demo site by running:
```shellscript

View File

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

View File

@@ -39,6 +39,9 @@ docker_build(
]
)
k8s_resource('impress-docs-backend-migrate', resource_deps=['postgres-postgresql'])
k8s_resource('impress-docs-backend-createsuperuser', resource_deps=['impress-docs-backend-migrate'])
k8s_resource('impress-docs-backend', resource_deps=['impress-docs-backend-migrate'])
k8s_yaml(local('cd ../src/helm && helmfile -n impress -e dev template .'))
migration = '''

View File

@@ -4,7 +4,7 @@ services:
postgresql:
image: postgres:16
healthcheck:
test: ["CMD-SHELL", "pg_isready"]
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
interval: 1s
timeout: 2s
retries: 300
@@ -185,16 +185,20 @@ services:
context: .
dockerfile: ./src/frontend/servers/y-provider/Dockerfile
target: y-provider
command: ["yarn", "workspace", "server-y-provider", "run", "dev"]
working_dir: /app/frontend
restart: unless-stopped
env_file:
- env.d/development/common
ports:
- "4444:4444"
volumes:
- ./src/frontend/:/app/frontend
kc_postgresql:
image: postgres:14.3
healthcheck:
test: ["CMD-SHELL", "pg_isready"]
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
interval: 1s
timeout: 2s
retries: 300

View File

@@ -4,54 +4,6 @@ server {
server_name localhost;
charset utf-8;
# Proxy auth for collaboration server
location /collaboration/ws/ {
# Collaboration Auth request configuration
auth_request /collaboration-auth;
auth_request_set $authHeader $upstream_http_authorization;
auth_request_set $canEdit $upstream_http_x_can_edit;
auth_request_set $userId $upstream_http_x_user_id;
# Pass specific headers from the auth response
proxy_set_header Authorization $authHeader;
proxy_set_header X-Can-Edit $canEdit;
proxy_set_header X-User-Id $userId;
# Ensure WebSocket upgrade
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
# Collaboration server
proxy_pass http://y-provider:4444;
# Set appropriate timeout for WebSocket
proxy_read_timeout 86400;
proxy_send_timeout 86400;
# Preserve original host and additional headers
proxy_set_header Host $host;
}
location /collaboration-auth {
proxy_pass http://app-dev:8000/api/v1.0/documents/collaboration-auth/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Original-URL $request_uri;
# Prevent the body from being passed
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Original-Method $request_method;
}
location /collaboration/api/ {
# Collaboration server
proxy_pass http://y-provider:4444;
proxy_set_header Host $host;
}
# Proxy auth for media
location /media/ {
# Auth request configuration

View File

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

19
docs/architecture.md Normal file
View File

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

100
docs/env.md Normal file
View File

@@ -0,0 +1,100 @@
# Docs variables
Here we describe all environment variables that can be set for the docs application.
## impress-backend container
These are the environment variables you can set for the impress-backend container.
| Option | Description | default |
|-------------------------------------------------|-----------------------------------------------------------------------------------------------|---------------------------------------------------------|
| AI_ALLOW_REACH_FROM | Users that can use AI must be this level. options are "public", "authenticated", "restricted" | authenticated |
| AI_API_KEY | Ai key to be used for AI Base url | |
| AI_BASE_URL | Openai compatible AI base url | |
| AI_MODEL | Ai Model to use | |
| ALLOW_LOGOUT_GET_METHOD | Allow get logout method | true |
| 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 |
| 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_STORAGE_BUCKET_NAME | Bucket name for s3 endpoint | impress-media-storage |
| CACHES_DEFAULT_TIMEOUT | Cache default timeout | 30 |
| COLLABORATION_API_URL | Collaboration api host | |
| COLLABORATION_SERVER_SECRET | Collaboration api secret | |
| COLLABORATION_WS_URL | Collaboration websocket url | |
| CONVERSION_API_CONTENT_FIELD | Conversion api content field | content |
| CONVERSION_API_ENDPOINT | Conversion API endpoint | convert-markdown |
| CONVERSION_API_SECURE | Require secure conversion api | false |
| CONVERSION_API_TIMEOUT | Conversion api timeout | 30 |
| 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 |
| DB_NAME | Name of the database | impress |
| DB_PASSWORD | Password to authenticate with | pass |
| DB_PORT | Port of the database | 5432 |
| DB_USER | User to authenticate with | dinum |
| DJANGO_ALLOWED_HOSTS | Allowed hosts | [] |
| DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | Celery broker transport options | {} |
| DJANGO_CELERY_BROKER_URL | Celery broker url | redis://redis:6379/0 |
| DJANGO_CORS_ALLOW_ALL_ORIGINS | Allow all CORS origins | true |
| DJANGO_CORS_ALLOWED_ORIGIN_REGEXES | List of origins allowed for CORS using regulair expressions | [] |
| DJANGO_CORS_ALLOWED_ORIGINS | List of origins allowed for CORS | [] |
| DJANGO_CSRF_TRUSTED_ORIGINS | CSRF trusted origins | [] |
| DJANGO_EMAIL_BACKEND | Email backend library | django.core.mail.backends.smtp.EmailBackend |
| DJANGO_EMAIL_BRAND_NAME | Brand name for email | |
| DJANGO_EMAIL_FROM | Email adress used as sender | from@example.com |
| DJANGO_EMAIL_HOST | Host name of email | |
| DJANGO_EMAIL_HOST_PASSWORD | Password to authenticate with on the email host | |
| 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_USE_SSL | Use sstl 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 | | [] |
| DOCUMENT_IMAGE_MAX_SIZE | Maximum size of document in bytes | 10485760 |
| FRONTEND_CSS_URL | To add a external css file to the app | |
| FRONTEND_FOOTER_FEATURE_ENABLED | Frontend feature flag to display the footer | false |
| FRONTEND_FOOTER_VIEW_CACHE_TIMEOUT | Cache duration of the json footer | 86400 |
| FRONTEND_HOMEPAGE_FEATURE_ENABLED | Frontend feature flag to display the homepage | false |
| FRONTEND_THEME | Frontend theme to use | |
| FRONTEND_URL_JSON_FOOTER | Url with a json to configure the footer | |
| LANGUAGE_CODE | Default language | en-us |
| LOGGING_LEVEL_LOGGERS_APP | Application logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO |
| LOGGING_LEVEL_LOGGERS_ROOT | Default logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO |
| LOGIN_REDIRECT_URL | Login redirect url | |
| LOGIN_REDIRECT_URL_FAILURE | Login redirect url on failure | |
| LOGOUT_REDIRECT_URL | Logout redirect url | |
| MEDIA_BASE_URL | | |
| Oidc_ALLOW_DUPLICATE_EMAILS | Allow dupplicate emails | false |
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | OIDC extra auth paramaters | {} |
| OIDC_CREATE_USER | Create used on OIDC | false |
| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | Fallback to email for identification | true |
| OIDC_OP_AUTHORIZATION_ENDPOINT | Autorization endpoint for OIDC | |
| OIDC_OP_JWKS_ENDPOINT | JWKS endpoint for OIDC | |
| OIDC_OP_LOGOUT_ENDPOINT | Logout endpoint for OIDC | |
| OIDC_OP_TOKEN_ENDPOINT | Token endpoint for OIDC | |
| OIDC_OP_USER_ENDPOINT | User endpoint for OIDC | |
| OIDC_REDIRECT_ALLOWED_HOSTS | Allowed hosts for OIDC redirect url | [] |
| OIDC_REDIRECT_REQUIRE_HTTPS | Require https for OIDC redirect url | false |
| OIDC_RP_CLIENT_ID | Client id used for OIDC | impress |
| OIDC_RP_CLIENT_SECRET | Client secret used for OIDC | |
| OIDC_RP_SCOPES | Scopes requested for OIDC | openid email |
| OIDC_RP_SIGN_ALGO | Verification algorithm used OIDC tokens | RS256 |
| OIDC_STORE_ID_TOKEN | Store OIDC token | true |
| OIDC_USE_NONCE | Use nonce for OIDC | true |
| OIDC_USERINFO_FULLNAME_FIELDS | OIDC token claims to create full name | ["first_name", "last_name"] |
| OIDC_USERINFO_SHORTNAME_FIELD | OIDC token claims to create shortname | first_name |
| POSTHOG_KEY | Posthog key for analytics | |
| REDIS_URL | Cache url | redis://redis:6379/1 |
| SENTRY_DSN | Sentry host | |
| SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | | false |
| STORAGES_STATICFILES_BACKEND | | whitenoise.storage.CompressedManifestStaticFilesStorage |
| TRASHBIN_CUTOFF_DAYS | Trashbin cutoff | 30 |
| USER_OIDC_ESSENTIAL_CLAIMS | Essential claims in OIDC token | [] |
| Y_PROVIDER_API_BASE_URL | Y Provider url | |
| Y_PROVIDER_API_KEY | Y provider API key | |

View File

@@ -33,8 +33,8 @@ backend:
OIDC_RP_SIGN_ALGO: RS256
OIDC_RP_SCOPES: "openid email"
OIDC_VERIFY_SSL: False
USER_OIDC_FIELD_TO_SHORTNAME: "given_name"
USER_OIDC_FIELDS_TO_FULLNAME: "given_name,usual_name"
OIDC_USERINFO_SHORTNAME_FIELD: "given_name"
OIDC_USERINFO_FULLNAME_FIELDS: "given_name,usual_name"
OIDC_REDIRECT_ALLOWED_HOSTS: https://impress.127.0.0.1.nip.io
OIDC_AUTH_REQUEST_EXTRA_PARAMS: "{'acr_values': 'eidas1'}"
LOGIN_REDIRECT_URL: https://impress.127.0.0.1.nip.io

View File

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

33
docs/theming.md Normal file
View File

@@ -0,0 +1,33 @@
# Runtime Theming 🎨
### How to Use
To use this feature, simply set the `FRONTEND_CSS_URL` environment variable to the URL of your custom CSS file. For example:
```javascript
FRONTEND_CSS_URL=http://anything/custom-style.css
```
Once you've set this variable, our application will load your custom CSS file and apply the styles to our frontend application.
### Benefits
This feature provides several benefits, including:
* **Easy customization** 🔄: With this feature, you can easily customize the look and feel of our application without requiring any code changes.
* **Flexibility** 🌈: You can use any CSS styles you like to create a custom theme that meets your needs.
* **Runtime theming** ⏱️: This feature allows you to change the theme of our application at runtime, without requiring a restart or recompilation.
### Example Use Case
Let's say you want to change the background color of our application to a custom color. You can create a custom CSS file with the following contents:
```css
body {
background-color: #3498db;
}
```
Then, set the `FRONTEND_CSS_URL` environment variable to the URL of your custom CSS file. Once you've done this, our application will load your custom CSS file and apply the styles, changing the background color to the custom color you specified.

View File

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

View File

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

27
publiccode.yml Normal file
View File

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

View File

@@ -9,20 +9,25 @@
"matchManagers": ["pep621"],
"matchPackageNames": []
},
{
"groupName": "allowed django versions",
"matchManagers": [
"pep621"
],
"matchPackageNames": [
"Django"
],
"allowedVersions": "<5.2"
},
{
"enabled": false,
"groupName": "ignored js dependencies",
"matchManagers": ["npm"],
"matchPackageNames": [
"@openfun/cunningham-react",
"@types/react",
"@types/react-dom",
"eslint",
"fetch-mock",
"node",
"node-fetch",
"react",
"react-dom",
"workbox-webpack-plugin"
]
}

View File

@@ -151,6 +151,8 @@ class DocumentAdmin(TreeAdmin):
"path",
"depth",
"numchild",
"duplicated_from",
"attachments",
)
},
),
@@ -166,8 +168,10 @@ class DocumentAdmin(TreeAdmin):
"updated_at",
)
readonly_fields = (
"attachments",
"creator",
"depth",
"duplicated_from",
"id",
"numchild",
"path",

View File

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

View File

@@ -1,5 +1,7 @@
"""API filters for Impress' core application."""
import unicodedata
from django.utils.translation import gettext_lazy as _
import django_filters
@@ -7,7 +9,50 @@ import django_filters
from core import models
def remove_accents(value):
"""Remove accents from a string (vélo -> velo)."""
return "".join(
c
for c in unicodedata.normalize("NFD", value)
if unicodedata.category(c) != "Mn"
)
class AccentInsensitiveCharFilter(django_filters.CharFilter):
"""
A custom CharFilter that filters on the accent-insensitive value searched.
"""
def filter(self, qs, value):
"""
Apply the filter to the queryset using the unaccented version of the field.
Args:
qs: The queryset to filter.
value: The value to search for in the unaccented field.
Returns:
A filtered queryset.
"""
if value:
value = remove_accents(value)
return super().filter(qs, value)
class DocumentFilter(django_filters.FilterSet):
"""
Custom filter for filtering documents on title (accent and case insensitive).
"""
title = AccentInsensitiveCharFilter(
field_name="title", lookup_expr="unaccent__icontains", label=_("Title")
)
class Meta:
model = models.Document
fields = ["title"]
class ListDocumentFilter(DocumentFilter):
"""
Custom filter for filtering documents.
"""
@@ -18,9 +63,6 @@ class DocumentFilter(django_filters.FilterSet):
is_favorite = django_filters.BooleanFilter(
method="filter_is_favorite", label=_("Favorite")
)
title = django_filters.CharFilter(
field_name="title", lookup_expr="icontains", label=_("Title")
)
class Meta:
model = models.Document

View File

@@ -1,6 +1,8 @@
"""Client serializers for the impress core app."""
import binascii
import mimetypes
from base64 import b64decode
from django.conf import settings
from django.db.models import Q
@@ -10,7 +12,7 @@ from django.utils.translation import gettext_lazy as _
import magic
from rest_framework import exceptions, serializers
from core import enums, models
from core import enums, models, utils
from core.services.ai_services import AI_ACTIONS
from core.services.converter_services import (
ConversionError,
@@ -21,6 +23,26 @@ from core.services.converter_services import (
class UserSerializer(serializers.ModelSerializer):
"""Serialize users."""
class Meta:
model = models.User
fields = ["id", "email", "full_name", "short_name", "language"]
read_only_fields = ["id", "email", "full_name", "short_name"]
class UserLightSerializer(UserSerializer):
"""Serialize users with limited fields."""
id = serializers.SerializerMethodField(read_only=True)
email = serializers.SerializerMethodField(read_only=True)
def get_id(self, _user):
"""Return always None. Here to have the same fields than in UserSerializer."""
return None
def get_email(self, _user):
"""Return always None. Here to have the same fields than in UserSerializer."""
return None
class Meta:
model = models.User
fields = ["id", "email", "full_name", "short_name"]
@@ -118,6 +140,17 @@ class DocumentAccessSerializer(BaseAccessSerializer):
read_only_fields = ["id", "abilities"]
class DocumentAccessLightSerializer(DocumentAccessSerializer):
"""Serialize document accesses with limited fields."""
user = UserLightSerializer(read_only=True)
class Meta:
model = models.DocumentAccess
fields = ["id", "user", "team", "role", "abilities"]
read_only_fields = ["id", "team", "role", "abilities"]
class TemplateAccessSerializer(BaseAccessSerializer):
"""Serialize template accesses."""
@@ -128,26 +161,14 @@ class TemplateAccessSerializer(BaseAccessSerializer):
read_only_fields = ["id", "abilities"]
class BaseResourceSerializer(serializers.ModelSerializer):
"""Serialize documents."""
abilities = serializers.SerializerMethodField(read_only=True)
accesses = TemplateAccessSerializer(many=True, read_only=True)
def get_abilities(self, document) -> dict:
"""Return abilities of the logged-in user on the instance."""
request = self.context.get("request")
if request:
return document.get_abilities(request.user)
return {}
class ListDocumentSerializer(BaseResourceSerializer):
class ListDocumentSerializer(serializers.ModelSerializer):
"""Serialize documents with limited fields for display in lists."""
is_favorite = serializers.BooleanField(read_only=True)
nb_accesses = serializers.IntegerField(read_only=True)
nb_accesses_ancestors = serializers.IntegerField(read_only=True)
nb_accesses_direct = serializers.IntegerField(read_only=True)
user_roles = serializers.SerializerMethodField(read_only=True)
abilities = serializers.SerializerMethodField(read_only=True)
class Meta:
model = models.Document
@@ -161,7 +182,8 @@ class ListDocumentSerializer(BaseResourceSerializer):
"is_favorite",
"link_role",
"link_reach",
"nb_accesses",
"nb_accesses_ancestors",
"nb_accesses_direct",
"numchild",
"path",
"title",
@@ -178,13 +200,30 @@ class ListDocumentSerializer(BaseResourceSerializer):
"is_favorite",
"link_role",
"link_reach",
"nb_accesses",
"nb_accesses_ancestors",
"nb_accesses_direct",
"numchild",
"path",
"updated_at",
"user_roles",
]
def get_abilities(self, document) -> dict:
"""Return abilities of the logged-in user on the instance."""
request = self.context.get("request")
if request:
paths_links_mapping = self.context.get("paths_links_mapping", None)
# Retrieve ancestor links from paths_links_mapping (if provided)
ancestors_links = (
paths_links_mapping.get(document.path[: -document.steplen])
if paths_links_mapping
else None
)
return document.get_abilities(request.user, ancestors_links=ancestors_links)
return {}
def get_user_roles(self, document):
"""
Return roles of the logged-in user for the current document,
@@ -214,7 +253,8 @@ class DocumentSerializer(ListDocumentSerializer):
"is_favorite",
"link_role",
"link_reach",
"nb_accesses",
"nb_accesses_ancestors",
"nb_accesses_direct",
"numchild",
"path",
"title",
@@ -230,7 +270,8 @@ class DocumentSerializer(ListDocumentSerializer):
"is_favorite",
"link_role",
"link_reach",
"nb_accesses",
"nb_accesses_ancestors",
"nb_accesses_direct",
"numchild",
"path",
"updated_at",
@@ -260,6 +301,65 @@ class DocumentSerializer(ListDocumentSerializer):
return value
def validate_content(self, value):
"""Validate the content field."""
if not value:
return None
try:
b64decode(value, validate=True)
except binascii.Error as err:
raise serializers.ValidationError("Invalid base64 content.") from err
return value
def save(self, **kwargs):
"""
Process the content field to extract attachment keys and update the document's
"attachments" field for access control.
"""
content = self.validated_data.get("content", "")
extracted_attachments = set(utils.extract_attachments(content))
existing_attachments = (
set(self.instance.attachments or []) if self.instance else set()
)
new_attachments = extracted_attachments - existing_attachments
if new_attachments:
attachments_documents = (
models.Document.objects.filter(
attachments__overlap=list(new_attachments)
)
.only("path", "attachments")
.order_by("path")
)
user = self.context["request"].user
readable_per_se_paths = (
models.Document.objects.readable_per_se(user)
.order_by("path")
.values_list("path", flat=True)
)
readable_attachments_paths = utils.filter_descendants(
[doc.path for doc in attachments_documents],
readable_per_se_paths,
skip_sorting=True,
)
readable_attachments = set()
for document in attachments_documents:
if document.path not in readable_attachments_paths:
continue
readable_attachments.update(set(document.attachments) & new_attachments)
# Update attachments with readable keys
self.validated_data["attachments"] = list(
existing_attachments | readable_attachments
)
return super().save(**kwargs)
class ServerCreateDocumentSerializer(serializers.Serializer):
"""
@@ -359,7 +459,7 @@ class ServerCreateDocumentSerializer(serializers.Serializer):
raise NotImplementedError("Update is not supported for this serializer.")
class LinkDocumentSerializer(BaseResourceSerializer):
class LinkDocumentSerializer(serializers.ModelSerializer):
"""
Serialize link configuration for documents.
We expose it separately from document in order to simplify and secure access control.
@@ -373,6 +473,27 @@ class LinkDocumentSerializer(BaseResourceSerializer):
]
class DocumentDuplicationSerializer(serializers.Serializer):
"""
Serializer for duplicating a document.
Allows specifying whether to keep access permissions.
"""
with_accesses = serializers.BooleanField(default=False)
def create(self, validated_data):
"""
This serializer is not intended to create objects.
"""
raise NotImplementedError("This serializer does not support creation.")
def update(self, instance, validated_data):
"""
This serializer is not intended to update objects.
"""
raise NotImplementedError("This serializer does not support updating.")
# Suppress the warning about not implementing `create` and `update` methods
# since we don't use a model and only rely on the serializer for validation
# pylint: disable=abstract-method
@@ -431,9 +552,12 @@ class FileUploadSerializer(serializers.Serializer):
return attrs
class TemplateSerializer(BaseResourceSerializer):
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 = [
@@ -447,6 +571,13 @@ class TemplateSerializer(BaseResourceSerializer):
]
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):

View File

@@ -11,6 +11,35 @@ import botocore
from rest_framework.throttling import BaseThrottle
def nest_tree(flat_list, steplen):
"""
Convert a flat list of serialized documents into a nested tree making advantage
of the`path` field and its step length.
"""
node_dict = {}
roots = []
# Sort the flat list by path to ensure parent nodes are processed first
flat_list.sort(key=lambda x: x["path"])
for node in flat_list:
node["children"] = [] # Initialize children list
node_dict[node["path"]] = node
# Determine parent path
parent_path = node["path"][:-steplen]
if parent_path in node_dict:
node_dict[parent_path]["children"].append(node)
else:
roots.append(node) # Collect root nodes
if len(roots) > 1:
raise ValueError("More than one root element detected.")
return roots[0] if roots else None
def filter_root_paths(paths, skip_sorting=False):
"""
Filters root paths from a list of paths representing a tree structure.

View File

@@ -2,9 +2,8 @@
# pylint: disable=too-many-lines
import logging
import re
import uuid
from urllib.parse import urlparse
from urllib.parse import unquote, urlparse
from django.conf import settings
from django.contrib.postgres.aggregates import ArrayAgg
@@ -12,39 +11,35 @@ from django.contrib.postgres.fields import ArrayField
from django.contrib.postgres.search import TrigramSimilarity
from django.core.exceptions import ValidationError
from django.core.files.storage import default_storage
from django.db import connection, transaction
from django.db import models as db
from django.db import transaction
from django.db.models.expressions import RawSQL
from django.db.models.functions import Left, Length
from django.http import Http404
from django.http import Http404, StreamingHttpResponse
from django.utils.decorators import method_decorator
from django.utils.text import capfirst
from django.utils.translation import gettext_lazy as _
from django.views.decorators.cache import cache_page
import requests
import rest_framework as drf
from botocore.exceptions import ClientError
from django_filters import rest_framework as drf_filters
from rest_framework import filters, status, viewsets
from rest_framework import response as drf_response
from rest_framework.permissions import AllowAny
from rest_framework.throttling import UserRateThrottle
from core import authentication, enums, models
from core.services.ai_services import AIService
from core.services.collaboration_services import CollaborationService
from core.services.config_services import get_footer_json
from core.utils import extract_attachments, filter_descendants
from . import permissions, serializers, utils
from .filters import DocumentFilter
from .filters import DocumentFilter, ListDocumentFilter
logger = logging.getLogger(__name__)
ATTACHMENTS_FOLDER = "attachments"
UUID_REGEX = (
r"[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}"
)
FILE_EXT_REGEX = r"\.[a-zA-Z]{3,4}"
MEDIA_STORAGE_URL_PATTERN = re.compile(
f"{settings.MEDIA_URL:s}(?P<pk>{UUID_REGEX:s})/"
f"(?P<key>{ATTACHMENTS_FOLDER:s}/{UUID_REGEX:s}{FILE_EXT_REGEX:s})$"
)
COLLABORATION_WS_URL_PATTERN = re.compile(rf"(?:^|&)room=(?P<pk>{UUID_REGEX})(?:&|$)")
# pylint: disable=too-many-ancestors
@@ -135,14 +130,35 @@ class Pagination(drf.pagination.PageNumberPagination):
page_size_query_param = "page_size"
class UserListThrottleBurst(UserRateThrottle):
"""Throttle for the user list endpoint."""
scope = "user_list_burst"
class UserListThrottleSustained(UserRateThrottle):
"""Throttle for the user list endpoint."""
scope = "user_list_sustained"
class UserViewSet(
drf.mixins.UpdateModelMixin, viewsets.GenericViewSet, drf.mixins.ListModelMixin
):
"""User ViewSet"""
permission_classes = [permissions.IsSelf]
queryset = models.User.objects.all()
queryset = models.User.objects.filter(is_active=True)
serializer_class = serializers.UserSerializer
pagination_class = None
throttle_classes = []
def get_throttles(self):
self.throttle_classes = []
if self.action == "list":
self.throttle_classes = [UserListThrottleBurst, UserListThrottleSustained]
return super().get_throttles()
def get_queryset(self):
"""
@@ -157,11 +173,11 @@ class UserViewSet(
return queryset
# Exclude all users already in the given document
if document_id := self.request.GET.get("document_id", ""):
if document_id := self.request.query_params.get("document_id", ""):
queryset = queryset.exclude(documentaccess__document_id=document_id)
if not (query := self.request.GET.get("q", "")):
return queryset
if not (query := self.request.query_params.get("q", "")) or len(query) < 5:
return queryset.none()
# For emails, match emails by Levenstein distance to prevent typing errors
if "@" in query:
@@ -170,7 +186,7 @@ class UserViewSet(
distance=RawSQL("levenshtein(email::text, %s::text)", (query,))
)
.filter(distance__lte=3)
.order_by("distance", "email")
.order_by("distance", "email")[: settings.API_USERS_LIST_LIMIT]
)
# Use trigram similarity for non-email-like queries
@@ -180,7 +196,7 @@ class UserViewSet(
queryset.filter(email__trigram_word_similar=query)
.annotate(similarity=TrigramSimilarity("email", query))
.filter(similarity__gt=0.2)
.order_by("-similarity", "email")
.order_by("-similarity", "email")[: settings.API_USERS_LIST_LIMIT]
)
@drf.decorators.action(
@@ -315,7 +331,6 @@ class DocumentViewSet(
SerializerPerActionMixin,
drf.mixins.CreateModelMixin,
drf.mixins.DestroyModelMixin,
drf.mixins.ListModelMixin,
drf.mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
@@ -368,10 +383,7 @@ class DocumentViewSet(
9. **Media Auth**: Authorize access to document media.
Example: GET /documents/media-auth/
10. **Collaboration Auth**: Authorize access to the collaboration server for a document.
Example: GET /documents/collaboration-auth/
11. **AI Transform**: Apply a transformation action on a piece of text with AI.
10. **AI Transform**: Apply a transformation action on a piece of text with AI.
Example: POST /documents/{id}/ai-transform/
Expected data:
- text (str): The input text.
@@ -379,7 +391,7 @@ class DocumentViewSet(
Returns: JSON response with the processed text.
Throttled by: AIDocumentRateThrottle, AIUserRateThrottle.
12. **AI Translate**: Translate a piece of text with AI.
11. **AI Translate**: Translate a piece of text with AI.
Example: POST /documents/{id}/ai-translate/
Expected data:
- text (str): The input text.
@@ -413,20 +425,21 @@ class DocumentViewSet(
- Implements soft delete logic to retain document tree structures.
"""
filter_backends = [drf_filters.DjangoFilterBackend]
filterset_class = DocumentFilter
metadata_class = DocumentMetadata
ordering = ["-updated_at"]
ordering_fields = ["created_at", "updated_at", "title"]
pagination_class = Pagination
permission_classes = [
permissions.DocumentAccessPermission,
]
queryset = models.Document.objects.all()
serializer_class = serializers.DocumentSerializer
ai_translate_serializer_class = serializers.AITranslateSerializer
children_serializer_class = serializers.ListDocumentSerializer
descendants_serializer_class = serializers.ListDocumentSerializer
list_serializer_class = serializers.ListDocumentSerializer
trashbin_serializer_class = serializers.ListDocumentSerializer
children_serializer_class = serializers.ListDocumentSerializer
ai_translate_serializer_class = serializers.AITranslateSerializer
tree_serializer_class = serializers.ListDocumentSerializer
def annotate_is_favorite(self, queryset):
"""
@@ -499,11 +512,42 @@ class DocumentViewSet(
)
def filter_queryset(self, queryset):
"""Apply annotations and filters sequentially."""
filterset = DocumentFilter(
"""Override to apply annotations to generic views."""
queryset = super().filter_queryset(queryset)
queryset = self.annotate_is_favorite(queryset)
queryset = self.annotate_user_roles(queryset)
return queryset
def get_response_for_queryset(self, queryset):
"""Return paginated response for the queryset if requested."""
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)
def list(self, request, *args, **kwargs):
"""
Returns a DRF response containing the filtered, annotated and ordered document list.
This method applies filtering based on request parameters using `ListDocumentFilter`.
It performs early filtering on model fields, annotates user roles, and removes
descendant documents to keep only the highest ancestors readable by the current user.
Additional annotations (e.g., `is_highest_ancestor_for_user`, favorite status) are
applied before ordering and returning the response.
"""
queryset = (
self.get_queryset()
) # Not calling filter_queryset. We do our own cooking.
filterset = ListDocumentFilter(
self.request.GET, queryset=queryset, request=self.request
)
filterset.is_valid()
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
@@ -512,22 +556,19 @@ class DocumentViewSet(
queryset = self.annotate_user_roles(queryset)
if self.action == "list":
# 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,
)
queryset = queryset.filter(path__in=root_paths)
# 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,
)
queryset = queryset.filter(path__in=root_paths)
# Annotate the queryset with an attribute marking instances as highest ancestor
# in order to save some time while computing abilities in the instance
queryset = queryset.annotate(
is_highest_ancestor_for_user=db.Value(
True, output_field=db.BooleanField()
)
)
# Annotate the queryset with an attribute marking instances as highest ancestor
# in order to save some time while computing abilities on the instance
queryset = queryset.annotate(
is_highest_ancestor_for_user=db.Value(True, output_field=db.BooleanField())
)
# Annotate favorite status and filter if applicable as late as possible
queryset = self.annotate_is_favorite(queryset)
@@ -536,18 +577,11 @@ class DocumentViewSet(
)
# Apply ordering only now that everyting is filtered and annotated
return filters.OrderingFilter().filter_queryset(self.request, queryset, self)
queryset = filters.OrderingFilter().filter_queryset(
self.request, queryset, self
)
def get_response_for_queryset(self, queryset):
"""Return paginated response for the queryset if requested."""
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
result = self.get_paginated_response(serializer.data)
return result
serializer = self.get_serializer(queryset, many=True)
return drf.response.Response(serializer.data)
return self.get_response_for_queryset(queryset)
def retrieve(self, request, *args, **kwargs):
"""
@@ -573,6 +607,14 @@ class DocumentViewSet(
@transaction.atomic
def perform_create(self, serializer):
"""Set the current user as creator and owner of the newly created object."""
# locks the table to ensure safe concurrent access
with connection.cursor() as cursor:
cursor.execute(
f'LOCK TABLE "{models.Document._meta.db_table}" ' # noqa: SLF001
"IN SHARE ROW EXCLUSIVE MODE;"
)
obj = models.Document.add_root(
creator=self.request.user,
**serializer.validated_data,
@@ -591,6 +633,7 @@ class DocumentViewSet(
@drf.decorators.action(
detail=False,
methods=["get"],
permission_classes=[permissions.IsAuthenticated],
)
def favorite_list(self, request, *args, **kwargs):
"""Get list of favorite documents for the current user."""
@@ -600,7 +643,7 @@ class DocumentViewSet(
user=user
).values_list("document_id", flat=True)
queryset = self.get_queryset()
queryset = self.filter_queryset(self.get_queryset())
queryset = queryset.filter(id__in=favorite_documents_ids)
return self.get_response_for_queryset(queryset)
@@ -631,10 +674,19 @@ class DocumentViewSet(
permission_classes=[],
url_path="create-for-owner",
)
@transaction.atomic
def create_for_owner(self, request):
"""
Create a document on behalf of a specified owner (pre-existing user or invited).
"""
# locks the table to ensure safe concurrent access
with connection.cursor() as cursor:
cursor.execute(
f'LOCK TABLE "{models.Document._meta.db_table}" ' # noqa: SLF001
"IN SHARE ROW EXCLUSIVE MODE;"
)
# Deserialize and validate the data
serializer = serializers.ServerCreateDocumentSerializer(data=request.data)
if not serializer.is_valid():
@@ -727,7 +779,6 @@ class DocumentViewSet(
detail=True,
methods=["get", "post"],
ordering=["path"],
url_path="children",
)
def children(self, request, *args, **kwargs):
"""Handle listing and creating children of a document"""
@@ -741,7 +792,12 @@ class DocumentViewSet(
serializer.is_valid(raise_exception=True)
with transaction.atomic():
child_document = document.add_child(
# "select_for_update" locks the table to ensure safe concurrent access
locked_parent = models.Document.objects.select_for_update().get(
pk=document.pk
)
child_document = locked_parent.add_child(
creator=request.user,
**serializer.validated_data,
)
@@ -759,12 +815,194 @@ class DocumentViewSet(
)
# GET: List children
queryset = document.get_children().filter(deleted_at__isnull=True)
queryset = document.get_children().filter(ancestors_deleted_at__isnull=True)
queryset = self.filter_queryset(queryset)
queryset = self.annotate_is_favorite(queryset)
queryset = self.annotate_user_roles(queryset)
filterset = DocumentFilter(request.GET, queryset=queryset)
if not filterset.is_valid():
raise drf.exceptions.ValidationError(filterset.errors)
queryset = filterset.qs
return self.get_response_for_queryset(queryset)
@drf.decorators.action(
detail=True,
methods=["get"],
ordering=["path"],
)
def descendants(self, request, *args, **kwargs):
"""Handle listing descendants of a document"""
document = self.get_object()
queryset = document.get_descendants().filter(ancestors_deleted_at__isnull=True)
queryset = self.filter_queryset(queryset)
filterset = DocumentFilter(request.GET, queryset=queryset)
if not filterset.is_valid():
raise drf.exceptions.ValidationError(filterset.errors)
queryset = filterset.qs
return self.get_response_for_queryset(queryset)
@drf.decorators.action(
detail=True,
methods=["get"],
ordering=["path"],
)
def tree(self, request, pk, *args, **kwargs):
"""
List ancestors tree above the document.
What we need to display is the tree structure opened for the current document.
"""
try:
current_document = self.queryset.only("depth", "path").get(pk=pk)
except models.Document.DoesNotExist as excpt:
raise drf.exceptions.NotFound from excpt
ancestors = (
(current_document.get_ancestors() | self.queryset.filter(pk=pk))
.filter(ancestors_deleted_at__isnull=True)
.order_by("path")
)
# Get the highest readable ancestor
highest_readable = (
ancestors.readable_per_se(request.user).only("depth", "path").first()
)
if highest_readable is None:
raise (
drf.exceptions.PermissionDenied()
if request.user.is_authenticated
else drf.exceptions.NotAuthenticated()
)
paths_links_mapping = {}
ancestors_links = []
children_clause = db.Q()
for ancestor in ancestors:
if ancestor.depth < highest_readable.depth:
continue
children_clause |= db.Q(
path__startswith=ancestor.path, depth=ancestor.depth + 1
)
# Compute cache for ancestors links to avoid many queries while computing
# abilties for his documents in the tree!
ancestors_links.append(
{"link_reach": ancestor.link_reach, "link_role": ancestor.link_role}
)
paths_links_mapping[ancestor.path] = ancestors_links.copy()
children = self.queryset.filter(children_clause, deleted_at__isnull=True)
queryset = ancestors.filter(depth__gte=highest_readable.depth) | children
queryset = queryset.order_by("path")
# Annotate if the current document is the highest ancestor for the user
queryset = queryset.annotate(
is_highest_ancestor_for_user=db.Case(
db.When(
path=db.Value(highest_readable.path),
then=db.Value(True),
),
default=db.Value(False),
output_field=db.BooleanField(),
)
)
queryset = self.annotate_user_roles(queryset)
queryset = self.annotate_is_favorite(queryset)
# Pass ancestors' links definitions to the serializer as a context variable
# in order to allow saving time while computing abilities on the instance
serializer = self.get_serializer(
queryset,
many=True,
context={
"request": request,
"paths_links_mapping": paths_links_mapping,
},
)
return drf.response.Response(
utils.nest_tree(serializer.data, self.queryset.model.steplen)
)
@drf.decorators.action(
detail=True,
methods=["post"],
permission_classes=[permissions.IsAuthenticated, permissions.AccessPermission],
url_path="duplicate",
)
@transaction.atomic
def duplicate(self, request, *args, **kwargs):
"""
Duplicate a document and store the links to attached files in the duplicated
document to allow cross-access.
Optionally duplicates accesses if `with_accesses` is set to true
in the payload.
"""
# Get document while checking permissions
document = self.get_object()
serializer = serializers.DocumentDuplicationSerializer(
data=request.data, partial=True
)
serializer.is_valid(raise_exception=True)
with_accesses = serializer.validated_data.get("with_accesses", False)
base64_yjs_content = document.content
# Duplicate the document instance
link_kwargs = (
{"link_reach": document.link_reach, "link_role": document.link_role}
if with_accesses
else {}
)
extracted_attachments = set(extract_attachments(document.content))
attachments = list(extracted_attachments & set(document.attachments))
duplicated_document = document.add_sibling(
"right",
title=capfirst(_("copy of {title}").format(title=document.title)),
content=base64_yjs_content,
attachments=attachments,
duplicated_from=document,
creator=request.user,
**link_kwargs,
)
# Always add the logged-in user as OWNER
accesses_to_create = [
models.DocumentAccess(
document=duplicated_document,
user=request.user,
role=models.RoleChoices.OWNER,
)
]
# If accesses should be duplicated, add other users' accesses as per original document
if with_accesses:
original_accesses = models.DocumentAccess.objects.filter(
document=document
).exclude(user=request.user)
accesses_to_create.extend(
models.DocumentAccess(
document=duplicated_document,
user_id=access.user_id,
team=access.team,
role=access.role,
)
for access in original_accesses
)
# Bulk create all the duplicated accesses
models.DocumentAccess.objects.bulk_create(accesses_to_create)
return drf_response.Response(
{"id": str(duplicated_document.id)}, status=status.HTTP_201_CREATED
)
@drf.decorators.action(detail=True, methods=["get"], url_path="versions")
def versions_list(self, request, *args, **kwargs):
"""
@@ -806,7 +1044,7 @@ class DocumentViewSet(
@drf.decorators.action(
detail=True,
methods=["get", "delete"],
url_path="versions/(?P<version_id>[0-9a-f-]{36})",
url_path="versions/(?P<version_id>[0-9a-z-]+)",
)
# pylint: disable=unused-argument
def versions_detail(self, request, pk, version_id, *args, **kwargs):
@@ -914,16 +1152,19 @@ class DocumentViewSet(
# Generate a generic yet unique filename to store the image in object storage
file_id = uuid.uuid4()
extension = serializer.validated_data["expected_extension"]
key = f"{document.key_base}/{ATTACHMENTS_FOLDER:s}/{file_id!s}.{extension:s}"
ext = serializer.validated_data["expected_extension"]
# Prepare metadata for storage
extra_args = {
"Metadata": {"owner": str(request.user.id)},
"ContentType": serializer.validated_data["content_type"],
}
file_unsafe = ""
if serializer.validated_data["is_unsafe"]:
extra_args["Metadata"]["is_unsafe"] = "true"
file_unsafe = "-unsafe"
key = f"{document.key_base}/{enums.ATTACHMENTS_FOLDER:s}/{file_id!s}{file_unsafe}.{ext:s}"
file_name = serializer.validated_data["file_name"]
if (
@@ -943,15 +1184,19 @@ class DocumentViewSet(
file, default_storage.bucket_name, key, ExtraArgs=extra_args
)
# Make the attachment readable by document readers
document.attachments.append(key)
document.save()
return drf.response.Response(
{"file": f"{settings.MEDIA_URL:s}{key:s}"},
status=drf.status.HTTP_201_CREATED,
)
def _authorize_subrequest(self, request, pattern):
def _auth_get_original_url(self, request):
"""
Shared method to authorize access based on the original URL of an Nginx subrequest
and user permissions. Returns a dictionary of URL parameters if authorized.
Extracts and parses the original URL from the "HTTP_X_ORIGINAL_URL" header.
Raises PermissionDenied if the header is missing.
The original url is passed by nginx in the "HTTP_X_ORIGINAL_URL" header.
See corresponding ingress configuration in Helm chart and read about the
@@ -962,14 +1207,6 @@ class DocumentViewSet(
to let this request go through (by returning a 200 code) or if we block it (by returning
a 403 error). Note that we return 403 errors without any further details for security
reasons.
Parameters:
- pattern: The regex pattern to extract identifiers from the URL.
Returns:
- A dictionary of URL parameters if the request is authorized.
Raises:
- PermissionDenied if authorization fails.
"""
# Extract the original URL from the request header
original_url = request.META.get("HTTP_X_ORIGINAL_URL")
@@ -977,52 +1214,21 @@ class DocumentViewSet(
logger.debug("Missing HTTP_X_ORIGINAL_URL header in subrequest")
raise drf.exceptions.PermissionDenied()
parsed_url = urlparse(original_url)
match = pattern.search(parsed_url.path)
# If the path does not match the pattern, try to extract the parameters from the query
if not match:
match = pattern.search(parsed_url.query)
if not match:
logger.debug(
"Subrequest URL '%s' did not match pattern '%s'",
parsed_url.path,
pattern,
)
raise drf.exceptions.PermissionDenied()
logger.debug("Original url: '%s'", original_url)
return urlparse(original_url)
def _auth_get_url_params(self, pattern, fragment):
"""
Extracts URL parameters from the given fragment using the specified regex pattern.
Raises PermissionDenied if parameters cannot be extracted.
"""
match = pattern.search(fragment)
try:
url_params = match.groupdict()
return match.groupdict()
except (ValueError, AttributeError) as exc:
logger.debug("Failed to extract parameters from subrequest URL: %s", exc)
raise drf.exceptions.PermissionDenied() from exc
pk = url_params.get("pk")
if not pk:
logger.debug("Document ID (pk) not found in URL parameters: %s", url_params)
raise drf.exceptions.PermissionDenied()
# Fetch the document and check if the user has access
try:
document = models.Document.objects.get(pk=pk)
except models.Document.DoesNotExist as exc:
logger.debug("Document with ID '%s' does not exist", pk)
raise drf.exceptions.PermissionDenied() from exc
user_abilities = document.get_abilities(request.user)
if not user_abilities.get(self.action, False):
logger.debug(
"User '%s' lacks permission for document '%s'", request.user, pk
)
raise drf.exceptions.PermissionDenied()
logger.debug(
"Subrequest authorization successful. Extracted parameters: %s", url_params
)
return url_params, user_abilities, request.user.id
@drf.decorators.action(detail=False, methods=["get"], url_path="media-auth")
def media_auth(self, request, *args, **kwargs):
"""
@@ -1034,36 +1240,42 @@ class DocumentViewSet(
annotation. The request will then be proxied to the object storage backend who will
respond with the file after checking the signature included in headers.
"""
url_params, _, _ = self._authorize_subrequest(
request, MEDIA_STORAGE_URL_PATTERN
parsed_url = self._auth_get_original_url(request)
url_params = self._auth_get_url_params(
enums.MEDIA_STORAGE_URL_PATTERN, parsed_url.path
)
pk, key = url_params.values()
user = request.user
key = f"{url_params['pk']:s}/{url_params['attachment']:s}"
# Look for a document to which the user has access and that includes this attachment
# We must look into all descendants of any document to which the user has access per se
readable_per_se_paths = (
self.queryset.readable_per_se(user)
.order_by("path")
.values_list("path", flat=True)
)
attachments_documents = (
self.queryset.filter(attachments__contains=[key])
.only("path")
.order_by("path")
)
readable_attachments_paths = filter_descendants(
[doc.path for doc in attachments_documents],
readable_per_se_paths,
skip_sorting=True,
)
if not readable_attachments_paths:
logger.debug("User '%s' lacks permission for attachment", user)
raise drf.exceptions.PermissionDenied()
# Generate S3 authorization headers using the extracted URL parameters
request = utils.generate_s3_authorization_headers(f"{pk:s}/{key:s}")
request = utils.generate_s3_authorization_headers(key)
return drf.response.Response("authorized", headers=request.headers, status=200)
@drf.decorators.action(detail=False, methods=["get"], url_path="collaboration-auth")
def collaboration_auth(self, request, *args, **kwargs):
"""
This view is used by an Nginx subrequest to control access to a document's
collaboration server.
"""
_, user_abilities, user_id = self._authorize_subrequest(
request, COLLABORATION_WS_URL_PATTERN
)
can_edit = user_abilities["partial_update"]
# Add the collaboration server secret token to the headers
headers = {
"Authorization": settings.COLLABORATION_SERVER_SECRET,
"X-Can-Edit": str(can_edit),
"X-User-Id": str(user_id),
}
return drf.response.Response("authorized", headers=headers, status=200)
@drf.decorators.action(
detail=True,
methods=["post"],
@@ -1120,15 +1332,70 @@ class DocumentViewSet(
return drf.response.Response(response, status=drf.status.HTTP_200_OK)
@drf.decorators.action(
detail=True,
methods=["get"],
name="",
url_path="cors-proxy",
)
def cors_proxy(self, request, *args, **kwargs):
"""
GET /api/v1.0/documents/<resource_id>/cors-proxy
Act like a proxy to fetch external resources and bypass CORS restrictions.
"""
url = request.query_params.get("url")
if not url:
return drf.response.Response(
{"detail": "Missing 'url' query parameter"},
status=drf.status.HTTP_400_BAD_REQUEST,
)
# Check for permissions.
self.get_object()
url = unquote(url)
try:
response = requests.get(
url,
stream=True,
headers={
"User-Agent": request.headers.get("User-Agent", ""),
"Accept": request.headers.get("Accept", ""),
},
timeout=10,
)
content_type = response.headers.get("Content-Type", "")
if not content_type.startswith("image/"):
return drf.response.Response(
status=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE
)
# Use StreamingHttpResponse with the response's iter_content to properly stream the data
proxy_response = StreamingHttpResponse(
streaming_content=response.iter_content(chunk_size=8192),
content_type=content_type,
headers={
"Content-Disposition": "attachment;",
"Content-Security-Policy": "default-src 'none'; img-src 'none' data:;",
},
status=response.status_code,
)
return proxy_response
except requests.RequestException as e:
logger.error("Proxy request failed: %s", str(e))
return drf_response.Response(
{"error": f"Failed to fetch resource: {e!s}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
class DocumentAccessViewSet(
ResourceAccessViewsetMixin,
drf.mixins.CreateModelMixin,
drf.mixins.DestroyModelMixin,
drf.mixins.ListModelMixin,
drf.mixins.RetrieveModelMixin,
drf.mixins.UpdateModelMixin,
viewsets.GenericViewSet,
viewsets.ModelViewSet,
):
"""
API ViewSet for all interactions with document accesses.
@@ -1160,17 +1427,44 @@ class DocumentAccessViewSet(
queryset = models.DocumentAccess.objects.select_related("user").all()
resource_field_name = "document"
serializer_class = serializers.DocumentAccessSerializer
is_current_user_owner_or_admin = False
def get_queryset(self):
"""Return the queryset according to the action."""
queryset = super().get_queryset()
if self.action == "list":
try:
document = models.Document.objects.get(pk=self.kwargs["resource_id"])
except models.Document.DoesNotExist:
return queryset.none()
roles = set(document.get_roles(self.request.user))
is_owner_or_admin = bool(roles.intersection(set(models.PRIVILEGED_ROLES)))
self.is_current_user_owner_or_admin = is_owner_or_admin
if not is_owner_or_admin:
# Return only the document owner access
queryset = queryset.filter(role__in=models.PRIVILEGED_ROLES)
return queryset
def get_serializer_class(self):
if self.action == "list" and not self.is_current_user_owner_or_admin:
return serializers.DocumentAccessLightSerializer
return super().get_serializer_class()
def perform_create(self, serializer):
"""Add a new access to the document and send an email to the new added user."""
access = serializer.save()
language = self.request.headers.get("Content-Language", "en-us")
access.document.send_invitation_email(
access.user.email,
access.role,
self.request.user,
language,
access.user.language
or self.request.user.language
or settings.LANGUAGE_CODE,
)
def perform_update(self, serializer):
@@ -1396,10 +1690,11 @@ class InvitationViewset(
"""Save invitation to a document then send an email to the invited user."""
invitation = serializer.save()
language = self.request.headers.get("Content-Language", "en-us")
invitation.document.send_invitation_email(
invitation.email, invitation.role, self.request.user, language
invitation.email,
invitation.role,
self.request.user,
self.request.user.language or settings.LANGUAGE_CODE,
)
@@ -1414,9 +1709,13 @@ class ConfigView(drf.views.APIView):
Return a dictionary of public settings.
"""
array_settings = [
"AI_FEATURE_ENABLED",
"COLLABORATION_WS_URL",
"CRISP_WEBSITE_ID",
"ENVIRONMENT",
"FRONTEND_CSS_URL",
"FRONTEND_HOMEPAGE_FEATURE_ENABLED",
"FRONTEND_FOOTER_FEATURE_ENABLED",
"FRONTEND_THEME",
"MEDIA_BASE_URL",
"POSTHOG_KEY",
@@ -1430,3 +1729,22 @@ class ConfigView(drf.views.APIView):
dict_settings[setting] = getattr(settings, setting)
return drf.response.Response(dict_settings)
class FooterView(drf.views.APIView):
"""API ViewSet for sharing the footer JSON."""
permission_classes = [AllowAny]
@method_decorator(cache_page(settings.FRONTEND_FOOTER_VIEW_CACHE_TIMEOUT))
def get(self, request):
"""
GET /api/v1.0/footer/
Return the footer JSON.
"""
json_footer = (
get_footer_json(settings.FRONTEND_URL_JSON_FOOTER)
if settings.FRONTEND_URL_JSON_FOOTER
else {}
)
return drf.response.Response(json_footer)

View File

@@ -1,130 +1,59 @@
"""Authentication Backends for the Impress core app."""
import logging
import os
from django.conf import settings
from django.core.exceptions import SuspiciousOperation
from django.utils.translation import gettext_lazy as _
import requests
from mozilla_django_oidc.auth import (
OIDCAuthenticationBackend as MozillaOIDCAuthenticationBackend,
from lasuite.oidc_login.backends import (
OIDCAuthenticationBackend as LaSuiteOIDCAuthenticationBackend,
)
from core.models import DuplicateEmailError, User
from core.models import DuplicateEmailError
logger = logging.getLogger(__name__)
# Settings renamed warnings
if os.environ.get("USER_OIDC_FIELDS_TO_FULLNAME"):
logger.warning(
"USER_OIDC_FIELDS_TO_FULLNAME has been renamed to "
"OIDC_USERINFO_FULLNAME_FIELDS please update your settings."
)
class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
if os.environ.get("USER_OIDC_FIELD_TO_SHORTNAME"):
logger.warning(
"USER_OIDC_FIELD_TO_SHORTNAME has been renamed to "
"OIDC_USERINFO_SHORTNAME_FIELD please update your settings."
)
class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
"""Custom OpenID Connect (OIDC) Authentication Backend.
This class overrides the default OIDC Authentication Backend to accommodate differences
in the User and Identity models, and handles signed and/or encrypted UserInfo response.
"""
def get_userinfo(self, access_token, id_token, payload):
"""Return user details dictionary.
def get_extra_claims(self, user_info):
"""
Return extra claims from user_info.
Parameters:
- access_token (str): The access token.
- id_token (str): The id token (unused).
- payload (dict): The token payload (unused).
Note: The id_token and payload parameters are unused in this implementation,
but were kept to preserve base method signature.
Note: It handles signed and/or encrypted UserInfo Response. It is required by
Agent Connect, which follows the OIDC standard. It forces us to override the
base method, which deal with 'application/json' response.
Args:
user_info (dict): The user information dictionary.
Returns:
- dict: User details dictionary obtained from the OpenID Connect user endpoint.
dict: A dictionary of extra claims.
"""
user_response = requests.get(
self.OIDC_OP_USER_ENDPOINT,
headers={"Authorization": f"Bearer {access_token}"},
verify=self.get_settings("OIDC_VERIFY_SSL", True),
timeout=self.get_settings("OIDC_TIMEOUT", None),
proxies=self.get_settings("OIDC_PROXY", None),
)
user_response.raise_for_status()
try:
userinfo = user_response.json()
except ValueError:
try:
userinfo = self.verify_token(user_response.text)
except Exception as e:
raise SuspiciousOperation(
_("Invalid response format or token verification failed")
) from e
return userinfo
def verify_claims(self, claims):
"""
Verify the presence of essential claims and the "sub" (which is mandatory as defined
by the OIDC specification) to decide if authentication should be allowed.
"""
essential_claims = settings.USER_OIDC_ESSENTIAL_CLAIMS
missing_claims = [claim for claim in essential_claims if claim not in claims]
if missing_claims:
logger.error("Missing essential claims: %s", missing_claims)
return False
return True
def get_or_create_user(self, access_token, id_token, payload):
"""Return a User based on userinfo. Create a new user if no match is found."""
user_info = self.get_userinfo(access_token, id_token, payload)
if not self.verify_claims(user_info):
raise SuspiciousOperation("Claims verification failed.")
sub = user_info["sub"]
email = user_info.get("email")
# Get user's full name from OIDC fields defined in settings
full_name = self.compute_full_name(user_info)
short_name = user_info.get(settings.USER_OIDC_FIELD_TO_SHORTNAME)
claims = {
"email": email,
"full_name": full_name,
"short_name": short_name,
return {
"full_name": self.compute_full_name(user_info),
"short_name": user_info.get(settings.OIDC_USERINFO_SHORTNAME_FIELD),
}
def get_existing_user(self, sub, email):
"""Fetch existing user by sub or email."""
try:
user = User.objects.get_user_by_sub_or_email(sub, email)
return self.UserModel.objects.get_user_by_sub_or_email(sub, email)
except DuplicateEmailError as err:
raise SuspiciousOperation(err.message) from err
if user:
if not user.is_active:
raise SuspiciousOperation(_("User account is disabled"))
self.update_user_if_needed(user, claims)
elif self.get_settings("OIDC_CREATE_USER", True):
user = User.objects.create(sub=sub, password="!", **claims) # noqa: S106
return user
def compute_full_name(self, user_info):
"""Compute user's full name based on OIDC fields in settings."""
name_fields = settings.USER_OIDC_FIELDS_TO_FULLNAME
full_name = " ".join(
user_info[field] for field in name_fields if user_info.get(field)
)
return full_name or None
def update_user_if_needed(self, user, claims):
"""Update user claims if they have changed."""
has_changed = any(
value and value != getattr(user, key) for key, value in claims.items()
)
if has_changed:
updated_claims = {key: value for key, value in claims.items() if value}
self.UserModel.objects.filter(id=user.id).update(**updated_claims)

View File

@@ -1,18 +0,0 @@
"""Authentication URLs for the People core app."""
from django.urls import path
from mozilla_django_oidc.urls import urlpatterns as mozzila_oidc_urls
from .views import OIDCLogoutCallbackView, OIDCLogoutView
urlpatterns = [
# Override the default 'logout/' path from Mozilla Django OIDC with our custom view.
path("logout/", OIDCLogoutView.as_view(), name="oidc_logout_custom"),
path(
"logout-callback/",
OIDCLogoutCallbackView.as_view(),
name="oidc_logout_callback",
),
*mozzila_oidc_urls,
]

View File

@@ -1,137 +0,0 @@
"""Authentication Views for the People core app."""
from urllib.parse import urlencode
from django.contrib import auth
from django.core.exceptions import SuspiciousOperation
from django.http import HttpResponseRedirect
from django.urls import reverse
from django.utils import crypto
from mozilla_django_oidc.utils import (
absolutify,
)
from mozilla_django_oidc.views import (
OIDCLogoutView as MozillaOIDCOIDCLogoutView,
)
class OIDCLogoutView(MozillaOIDCOIDCLogoutView):
"""Custom logout view for handling OpenID Connect (OIDC) logout flow.
Adds support for handling logout callbacks from the identity provider (OP)
by initiating the logout flow if the user has an active session.
The Django session is retained during the logout process to persist the 'state' OIDC parameter.
This parameter is crucial for maintaining the integrity of the logout flow between this call
and the subsequent callback.
"""
@staticmethod
def persist_state(request, state):
"""Persist the given 'state' parameter in the session's 'oidc_states' dictionary
This method is used to store the OIDC state parameter in the session, according to the
structure expected by Mozilla Django OIDC's 'add_state_and_verifier_and_nonce_to_session'
utility function.
"""
if "oidc_states" not in request.session or not isinstance(
request.session["oidc_states"], dict
):
request.session["oidc_states"] = {}
request.session["oidc_states"][state] = {}
request.session.save()
def construct_oidc_logout_url(self, request):
"""Create the redirect URL for interfacing with the OIDC provider.
Retrieves the necessary parameters from the session and constructs the URL
required to initiate logout with the OpenID Connect provider.
If no ID token is found in the session, the logout flow will not be initiated,
and the method will return the default redirect URL.
The 'state' parameter is generated randomly and persisted in the session to ensure
its integrity during the subsequent callback.
"""
oidc_logout_endpoint = self.get_settings("OIDC_OP_LOGOUT_ENDPOINT")
if not oidc_logout_endpoint:
return self.redirect_url
reverse_url = reverse("oidc_logout_callback")
id_token = request.session.get("oidc_id_token", None)
if not id_token:
return self.redirect_url
query = {
"id_token_hint": id_token,
"state": crypto.get_random_string(self.get_settings("OIDC_STATE_SIZE", 32)),
"post_logout_redirect_uri": absolutify(request, reverse_url),
}
self.persist_state(request, query["state"])
return f"{oidc_logout_endpoint}?{urlencode(query)}"
def post(self, request):
"""Handle user logout.
If the user is not authenticated, redirects to the default logout URL.
Otherwise, constructs the OIDC logout URL and redirects the user to start
the logout process.
If the user is redirected to the default logout URL, ensure her Django session
is terminated.
"""
logout_url = self.redirect_url
if request.user.is_authenticated:
logout_url = self.construct_oidc_logout_url(request)
# If the user is not redirected to the OIDC provider, ensure logout
if logout_url == self.redirect_url:
auth.logout(request)
return HttpResponseRedirect(logout_url)
class OIDCLogoutCallbackView(MozillaOIDCOIDCLogoutView):
"""Custom view for handling the logout callback from the OpenID Connect (OIDC) provider.
Handles the callback after logout from the identity provider (OP).
Verifies the state parameter and performs necessary logout actions.
The Django session is maintained during the logout process to ensure the integrity
of the logout flow initiated in the previous step.
"""
http_method_names = ["get"]
def get(self, request):
"""Handle the logout callback.
If the user is not authenticated, redirects to the default logout URL.
Otherwise, verifies the state parameter and performs necessary logout actions.
"""
if not request.user.is_authenticated:
return HttpResponseRedirect(self.redirect_url)
state = request.GET.get("state")
if state not in request.session.get("oidc_states", {}):
msg = "OIDC callback state not found in session `oidc_states`!"
raise SuspiciousOperation(msg)
del request.session["oidc_states"][state]
request.session.save()
auth.logout(request)
return HttpResponseRedirect(self.redirect_url)

View File

@@ -2,10 +2,26 @@
Core application enums declaration
"""
from django.conf import global_settings
import re
from django.conf import global_settings, settings
from django.db import models
from django.utils.translation import gettext_lazy as _
ATTACHMENTS_FOLDER = "attachments"
UUID_REGEX = (
r"[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}"
)
FILE_EXT_REGEX = r"\.[a-zA-Z0-9]{1,10}"
MEDIA_STORAGE_URL_PATTERN = re.compile(
f"{settings.MEDIA_URL:s}(?P<pk>{UUID_REGEX:s})/"
f"(?P<attachment>{ATTACHMENTS_FOLDER:s}/{UUID_REGEX:s}(?:-unsafe)?{FILE_EXT_REGEX:s})$"
)
MEDIA_STORAGE_URL_EXTRACT = re.compile(
f"{settings.MEDIA_URL:s}({UUID_REGEX}/{ATTACHMENTS_FOLDER}/{UUID_REGEX}{FILE_EXT_REGEX})"
)
# In Django's code base, `LANGUAGES` is set by default with all supported languages.
# We can use it for the choice of languages which should not be limited to the few languages
# active in the app.

View File

@@ -13,6 +13,22 @@ from core import models
fake = Faker()
YDOC_HELLO_WORLD_BASE64 = (
"AR717vLVDgAHAQ5kb2N1bWVudC1zdG9yZQMKYmxvY2tHcm91cAcA9e7y1Q4AAw5ibG9ja0NvbnRh"
"aW5lcgcA9e7y1Q4BAwdoZWFkaW5nBwD17vLVDgIGBgD17vLVDgMGaXRhbGljAnt9hPXu8tUOBAVI"
"ZWxsb4b17vLVDgkGaXRhbGljBG51bGwoAPXu8tUOAg10ZXh0QWxpZ25tZW50AXcEbGVmdCgA9e7y"
"1Q4CBWxldmVsAX0BKAD17vLVDgECaWQBdyQwNGQ2MjM0MS04MzI2LTQyMzYtYTA4My00ODdlMjZm"
"YWQyMzAoAPXu8tUOAQl0ZXh0Q29sb3IBdwdkZWZhdWx0KAD17vLVDgEPYmFja2dyb3VuZENvbG9y"
"AXcHZGVmYXVsdIf17vLVDgEDDmJsb2NrQ29udGFpbmVyBwD17vLVDhADDmJ1bGxldExpc3RJdGVt"
"BwD17vLVDhEGBAD17vLVDhIBd4b17vLVDhMEYm9sZAJ7fYT17vLVDhQCb3KG9e7y1Q4WBGJvbGQE"
"bnVsbIT17vLVDhcCbGQoAPXu8tUOEQ10ZXh0QWxpZ25tZW50AXcEbGVmdCgA9e7y1Q4QAmlkAXck"
"ZDM1MWUwNjgtM2U1NS00MjI2LThlYTUtYWJiMjYzMTk4ZTJhKAD17vLVDhAJdGV4dENvbG9yAXcH"
"ZGVmYXVsdCgA9e7y1Q4QD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHSH9e7y1Q4QAw5ibG9ja0Nv"
"bnRhaW5lcgcA9e7y1Q4eAwlwYXJhZ3JhcGgoAPXu8tUOHw10ZXh0QWxpZ25tZW50AXcEbGVmdCgA"
"9e7y1Q4eAmlkAXckODk3MDBjMDctZTBlMS00ZmUwLWFjYTItODQ5MzIwOWE3ZTQyKAD17vLVDh4J"
"dGV4dENvbG9yAXcHZGVmYXVsdCgA9e7y1Q4eD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQA"
)
class UserFactory(factory.django.DjangoModelFactory):
"""A factory to random users for testing purposes."""
@@ -75,7 +91,7 @@ class DocumentFactory(factory.django.DjangoModelFactory):
title = factory.Sequence(lambda n: f"document{n}")
excerpt = factory.Sequence(lambda n: f"excerpt{n}")
content = factory.Sequence(lambda n: f"content{n}")
content = YDOC_HELLO_WORLD_BASE64
creator = factory.SubFactory(UserFactory)
deleted_at = None
link_reach = factory.fuzzy.FuzzyChoice(

View File

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

View File

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

View File

@@ -0,0 +1,10 @@
from django.contrib.postgres.operations import UnaccentExtension
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("core", "0020_remove_is_public_add_field_attachments_and_duplicated_from"),
]
operations = [UnaccentExtension()]

View File

@@ -6,12 +6,14 @@ Declare and configure the models for the impress core application
import hashlib
import smtplib
import uuid
from collections import defaultdict
from datetime import timedelta
from logging import getLogger
from django.conf import settings
from django.contrib.auth import models as auth_models
from django.contrib.auth.base_user import AbstractBaseUser
from django.contrib.postgres.fields import ArrayField
from django.contrib.sites.models import Site
from django.core import mail, validators
from django.core.cache import cache
@@ -22,14 +24,14 @@ from django.db import models, transaction
from django.db.models.functions import Left, Length
from django.template.loader import render_to_string
from django.utils import timezone
from django.utils.functional import cached_property, lazy
from django.utils.functional import cached_property
from django.utils.translation import get_language, override
from django.utils.translation import gettext_lazy as _
from botocore.exceptions import ClientError
from rest_framework.exceptions import ValidationError
from timezone_field import TimeZoneField
from treebeard.mp_tree import MP_Node
from treebeard.mp_tree import MP_Node, MP_NodeManager, MP_NodeQuerySet
logger = getLogger(__name__)
@@ -80,6 +82,55 @@ class LinkReachChoices(models.TextChoices):
) # Any authenticated user can access the document
PUBLIC = "public", _("Public") # Even anonymous users can access the document
@classmethod
def get_select_options(cls, ancestors_links):
"""
Determines the valid select options for link reach and link role depending on the
list of ancestors' link reach/role.
Args:
ancestors_links: List of dictionaries, each with 'link_reach' and 'link_role' keys
representing the reach and role of ancestors links.
Returns:
Dictionary mapping possible reach levels to their corresponding possible roles.
"""
# If no ancestors, return all options
if not ancestors_links:
return dict.fromkeys(cls.values, LinkRoleChoices.values)
# Initialize result with all possible reaches and role options as sets
result = {reach: set(LinkRoleChoices.values) for reach in cls.values}
# Group roles by reach level
reach_roles = defaultdict(set)
for link in ancestors_links:
reach_roles[link["link_reach"]].add(link["link_role"])
# Apply constraints based on ancestor links
if LinkRoleChoices.EDITOR in reach_roles[cls.RESTRICTED]:
result[cls.RESTRICTED].discard(LinkRoleChoices.READER)
if LinkRoleChoices.EDITOR in reach_roles[cls.AUTHENTICATED]:
result[cls.AUTHENTICATED].discard(LinkRoleChoices.READER)
result.pop(cls.RESTRICTED, None)
elif LinkRoleChoices.READER in reach_roles[cls.AUTHENTICATED]:
result[cls.RESTRICTED].discard(LinkRoleChoices.READER)
if LinkRoleChoices.EDITOR in reach_roles[cls.PUBLIC]:
result[cls.PUBLIC].discard(LinkRoleChoices.READER)
result.pop(cls.AUTHENTICATED, None)
result.pop(cls.RESTRICTED, None)
elif LinkRoleChoices.READER in reach_roles[cls.PUBLIC]:
result[cls.AUTHENTICATED].discard(LinkRoleChoices.READER)
result.get(cls.RESTRICTED, set()).discard(LinkRoleChoices.READER)
# Convert roles sets to lists while maintaining the order from LinkRoleChoices
for reach, roles in result.items():
result[reach] = [role for role in LinkRoleChoices.values if role in roles]
return result
class DuplicateEmailError(Exception):
"""Raised when an email is already associated with a pre-existing user."""
@@ -193,10 +244,12 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
language = models.CharField(
max_length=10,
choices=lazy(lambda: settings.LANGUAGES, tuple)(),
default=settings.LANGUAGE_CODE,
choices=settings.LANGUAGES,
default=None,
verbose_name=_("language"),
help_text=_("The language in which the user wants to see the interface."),
null=True,
blank=True,
)
timezone = TimeZoneField(
choices_display="WITH_GMT_OFFSET",
@@ -311,10 +364,9 @@ class BaseAccess(BaseModel):
class Meta:
abstract = True
def _get_abilities(self, resource, user):
def _get_roles(self, resource, user):
"""
Compute and return abilities for a given user taking into account
the current state of the object.
Get the roles a user has on a resource.
"""
roles = []
if user.is_authenticated:
@@ -329,6 +381,15 @@ class BaseAccess(BaseModel):
except (self._meta.model.DoesNotExist, IndexError):
roles = []
return roles
def _get_abilities(self, resource, user):
"""
Compute and return abilities for a given user taking into account
the current state of the object.
"""
roles = self._get_roles(resource, user)
is_owner_or_admin = bool(
set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
)
@@ -367,6 +428,42 @@ class BaseAccess(BaseModel):
}
class DocumentQuerySet(MP_NodeQuerySet):
"""
Custom queryset for the Document model, providing additional methods
to filter documents based on user permissions.
"""
def readable_per_se(self, user):
"""
Filters the queryset to return documents on which the given user has
direct access, team access or link access. This will not return all the
documents that a user can read because it can be obtained via an ancestor.
:param user: The user for whom readable documents are to be fetched.
:return: A queryset of documents for which the user has direct access,
team access or link access.
"""
if user.is_authenticated:
return self.filter(
models.Q(accesses__user=user)
| models.Q(accesses__team__in=user.teams)
| ~models.Q(link_reach=LinkReachChoices.RESTRICTED)
)
return self.filter(link_reach=LinkReachChoices.PUBLIC)
class DocumentManager(MP_NodeManager.from_queryset(DocumentQuerySet)):
"""
Custom manager for the Document model, enabling the use of the custom
queryset methods directly from the model manager.
"""
def get_queryset(self):
"""Sets the custom queryset as the default."""
return self._queryset_class(self.model).order_by("path")
class Document(MP_Node, BaseModel):
"""Pad document carrying the content."""
@@ -389,6 +486,21 @@ class Document(MP_Node, BaseModel):
)
deleted_at = models.DateTimeField(null=True, blank=True)
ancestors_deleted_at = models.DateTimeField(null=True, blank=True)
duplicated_from = models.ForeignKey(
"self",
on_delete=models.SET_NULL,
related_name="duplicates",
editable=False,
blank=True,
null=True,
)
attachments = ArrayField(
models.CharField(max_length=255),
default=list,
editable=False,
blank=True,
null=True,
)
_content = None
@@ -399,6 +511,8 @@ class Document(MP_Node, BaseModel):
path = models.CharField(max_length=7 * 36, unique=True, db_collation="C")
objects = DocumentManager()
class Meta:
db_table = "impress_document"
ordering = ("path",)
@@ -483,9 +597,13 @@ class Document(MP_Node, BaseModel):
def get_content_response(self, version_id=""):
"""Get the content in a specific version of the document"""
return default_storage.connection.meta.client.get_object(
Bucket=default_storage.bucket_name, Key=self.file_key, VersionId=version_id
)
params = {
"Bucket": default_storage.bucket_name,
"Key": self.file_key,
}
if version_id:
params["VersionId"] = version_id
return default_storage.connection.meta.client.get_object(**params)
def get_versions_slice(self, from_version_id="", min_datetime=None, page_size=None):
"""Get document versions from object storage with pagination and starting conditions"""
@@ -555,24 +673,47 @@ class Document(MP_Node, BaseModel):
"""Generate a unique cache key for each document."""
return f"document_{self.id!s}_nb_accesses"
@property
def nb_accesses(self):
"""Calculate the number of accesses."""
def get_nb_accesses(self):
"""
Calculate the number of accesses:
- directly attached to the document
- attached to any of the document's ancestors
"""
cache_key = self.get_nb_accesses_cache_key()
nb_accesses = cache.get(cache_key)
if nb_accesses is None:
nb_accesses = DocumentAccess.objects.filter(
document__path=Left(models.Value(self.path), Length("document__path")),
).count()
nb_accesses = (
DocumentAccess.objects.filter(document=self).count(),
DocumentAccess.objects.filter(
document__path=Left(
models.Value(self.path), Length("document__path")
),
document__ancestors_deleted_at__isnull=True,
).count(),
)
cache.set(cache_key, nb_accesses)
return nb_accesses
@property
def nb_accesses_direct(self):
"""Returns the number of accesses related to the document or one of its ancestors."""
return self.get_nb_accesses()[0]
@property
def nb_accesses_ancestors(self):
"""Returns the number of accesses related to the document or one of its ancestors."""
return self.get_nb_accesses()[1]
def invalidate_nb_accesses_cache(self):
"""
Invalidate the cache for number of accesses, including on affected descendants.
Args:
path: can optionally be passed as argument (useful when invalidating cache for a
document we just deleted)
"""
for document in Document.objects.filter(path__startswith=self.path).only("id"):
cache_key = document.get_nb_accesses_cache_key()
cache.delete(cache_key)
@@ -596,25 +737,53 @@ class Document(MP_Node, BaseModel):
roles = []
return roles
@cached_property
def links_definitions(self):
def get_links_definitions(self, ancestors_links):
"""Get links reach/role definitions for the current document and its ancestors."""
links_definitions = {self.link_reach: {self.link_role}}
# Ancestors links definitions are only interesting if the document is not the highest
# ancestor to which the current user has access. Look for the annotation:
if self.depth > 1 and not getattr(self, "is_highest_ancestor_for_user", False):
for ancestor in self.get_ancestors().values("link_reach", "link_role"):
links_definitions.setdefault(ancestor["link_reach"], set()).add(
ancestor["link_role"]
)
links_definitions = defaultdict(set)
links_definitions[self.link_reach].add(self.link_role)
return links_definitions
# Merge ancestor link definitions
for ancestor in ancestors_links:
links_definitions[ancestor["link_reach"]].add(ancestor["link_role"])
def get_abilities(self, user):
return dict(links_definitions) # Convert defaultdict back to a normal dict
def compute_ancestors_links(self, user):
"""
Compute the ancestors links for the current document up to the highest readable ancestor.
"""
ancestors = (
(self.get_ancestors() | self._meta.model.objects.filter(pk=self.pk))
.filter(ancestors_deleted_at__isnull=True)
.order_by("path")
)
highest_readable = ancestors.readable_per_se(user).only("depth").first()
if highest_readable is None:
return []
ancestors_links = []
paths_links_mapping = {}
for ancestor in ancestors.filter(depth__gte=highest_readable.depth):
ancestors_links.append(
{"link_reach": ancestor.link_reach, "link_role": ancestor.link_role}
)
paths_links_mapping[ancestor.path] = ancestors_links.copy()
ancestors_links = paths_links_mapping.get(self.path[: -self.steplen], [])
return ancestors_links
def get_abilities(self, user, ancestors_links=None):
"""
Compute and return abilities for a given user on the document.
"""
if self.depth <= 1 or getattr(self, "is_highest_ancestor_for_user", False):
ancestors_links = []
elif ancestors_links is None:
ancestors_links = self.compute_ancestors_links(user=user)
roles = set(
self.get_roles(user)
) # at this point only roles based on specific access
@@ -634,9 +803,7 @@ class Document(MP_Node, BaseModel):
) and not is_deleted
# Add roles provided by the document link, taking into account its ancestors
# Add roles provided by the document link
links_definitions = self.links_definitions
links_definitions = self.get_links_definitions(ancestors_links)
public_roles = links_definitions.get(LinkReachChoices.PUBLIC, set())
authenticated_roles = (
links_definitions.get(LinkReachChoices.AUTHENTICATED, set())
@@ -671,7 +838,10 @@ class Document(MP_Node, BaseModel):
"children_list": can_get,
"children_create": can_update and user.is_authenticated,
"collaboration_auth": can_get,
"cors_proxy": can_get,
"descendants": can_get,
"destroy": is_owner,
"duplicate": can_get,
"favorite": can_get and user.is_authenticated,
"link_configuration": is_owner_or_admin,
"invite_owner": is_owner,
@@ -680,6 +850,8 @@ class Document(MP_Node, BaseModel):
"restore": is_owner,
"retrieve": can_get,
"media_auth": can_get,
"link_select_options": LinkReachChoices.get_select_options(ancestors_links),
"tree": can_get,
"update": can_update,
"versions_destroy": is_owner_or_admin,
"versions_list": has_access_role,
@@ -697,6 +869,7 @@ class Document(MP_Node, BaseModel):
"document": self,
"domain": domain,
"link": f"{domain}/docs/{self.id}/",
"document_title": self.title or str(_("Untitled Document")),
"logo_img": settings.EMAIL_LOGO_IMG,
}
)
@@ -738,8 +911,12 @@ class Document(MP_Node, BaseModel):
'{name} invited you with the role "{role}" on the following document:'
).format(name=sender_name_email, role=role.lower()),
}
subject = _("{name} shared a document with you: {title}").format(
name=sender_name, title=self.title
subject = (
context["title"]
if not self.title
else _("{name} shared a document with you: {title}").format(
name=sender_name, title=self.title
)
)
self.send_email(subject, [email], context, language)
@@ -750,19 +927,26 @@ class Document(MP_Node, BaseModel):
Soft delete the document, marking the deletion on descendants.
We still keep the .delete() method untouched for programmatic purposes.
"""
if self.deleted_at or self.ancestors_deleted_at:
if (
self._meta.model.objects.filter(
models.Q(deleted_at__isnull=False)
| models.Q(ancestors_deleted_at__isnull=False),
pk=self.pk,
).exists()
or self.get_ancestors().filter(deleted_at__isnull=False).exists()
):
raise RuntimeError(
"This document is already deleted or has deleted ancestors."
)
# Check if any ancestors are deleted
if self.get_ancestors().filter(deleted_at__isnull=False).exists():
raise RuntimeError(
"Cannot delete this document because one or more ancestors are already deleted."
)
self.ancestors_deleted_at = self.deleted_at = timezone.now()
self.save()
self.invalidate_nb_accesses_cache()
if self.depth > 1:
self._meta.model.objects.filter(pk=self.get_parent().pk).update(
numchild=models.F("numchild") - 1
)
# Mark all descendants as soft deleted
self.get_descendants().filter(ancestors_deleted_at__isnull=True).update(
@@ -773,20 +957,19 @@ class Document(MP_Node, BaseModel):
def restore(self):
"""Cancelling a soft delete with checks."""
# This should not happen
if self.deleted_at is None:
raise ValidationError({"deleted_at": [_("This document is not deleted.")]})
if self._meta.model.objects.filter(
pk=self.pk, deleted_at__isnull=True
).exists():
raise RuntimeError("This document is not deleted.")
if self.deleted_at < get_trashbin_cutoff():
raise ValidationError(
{
"deleted_at": [
_(
"This document was permanently deleted and cannot be restored."
)
]
}
raise RuntimeError(
"This document was permanently deleted and cannot be restored."
)
# save the current deleted_at value to exclude it from the descendants update
current_deleted_at = self.deleted_at
# Restore the current document
self.deleted_at = None
@@ -794,26 +977,23 @@ class Document(MP_Node, BaseModel):
ancestors_deleted_at = (
self.get_ancestors()
.filter(deleted_at__isnull=False)
.order_by("deleted_at")
.values_list("deleted_at", flat=True)
.first()
)
self.ancestors_deleted_at = min(ancestors_deleted_at, default=None)
self.save()
self.ancestors_deleted_at = ancestors_deleted_at
self.save(update_fields=["deleted_at", "ancestors_deleted_at"])
self.invalidate_nb_accesses_cache()
# Update descendants excluding those who were deleted prior to the deletion of the
# current document (the ancestor_deleted_at date for those should already by good)
# The number of deleted descendants should not be too big so we can handcraft a union
# clause for them:
deleted_descendants_paths = (
self.get_descendants()
.filter(deleted_at__isnull=False)
.values_list("path", flat=True)
)
exclude_condition = models.Q(
*(models.Q(path__startswith=path) for path in deleted_descendants_paths)
)
self.get_descendants().exclude(exclude_condition).update(
ancestors_deleted_at=self.ancestors_deleted_at
)
self.get_descendants().exclude(
models.Q(deleted_at__isnull=False)
| models.Q(ancestors_deleted_at__lt=current_deleted_at)
).update(ancestors_deleted_at=self.ancestors_deleted_at)
if self.depth > 1:
self._meta.model.objects.filter(pk=self.get_parent().pk).update(
numchild=models.F("numchild") + 1
)
class LinkTrace(BaseModel):
@@ -931,7 +1111,41 @@ class DocumentAccess(BaseAccess):
"""
Compute and return abilities for a given user on the document access.
"""
return self._get_abilities(self.document, user)
roles = self._get_roles(self.document, user)
is_owner_or_admin = bool(set(roles).intersection(set(PRIVILEGED_ROLES)))
if self.role == RoleChoices.OWNER:
can_delete = (
RoleChoices.OWNER in roles
and self.document.accesses.filter(role=RoleChoices.OWNER).count() > 1
)
set_role_to = (
[RoleChoices.ADMIN, RoleChoices.EDITOR, RoleChoices.READER]
if can_delete
else []
)
else:
can_delete = is_owner_or_admin
set_role_to = []
if RoleChoices.OWNER in roles:
set_role_to.append(RoleChoices.OWNER)
if is_owner_or_admin:
set_role_to.extend(
[RoleChoices.ADMIN, RoleChoices.EDITOR, RoleChoices.READER]
)
# Remove the current role as we don't want to propose it as an option
try:
set_role_to.remove(self.role)
except ValueError:
pass
return {
"destroy": can_delete,
"update": bool(set_role_to) and is_owner_or_admin,
"partial_update": bool(set_role_to) and is_owner_or_admin,
"retrieve": self.user and self.user.id == user.id or is_owner_or_admin,
"set_role_to": set_role_to,
}
class Template(BaseModel):

View File

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

View File

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

View File

@@ -2,14 +2,14 @@
import random
import re
from logging import Logger
from unittest import mock
from django.core.exceptions import SuspiciousOperation
from django.test.utils import override_settings
import pytest
import responses
from cryptography.fernet import Fernet
from lasuite.oidc_login.backends import get_oidc_refresh_token
from core import models
from core.authentication.backends import OIDCAuthenticationBackend
@@ -57,7 +57,7 @@ def test_authentication_getter_existing_user_via_email(
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with django_assert_num_queries(2):
with django_assert_num_queries(3): # user by sub, user by mail, update sub
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
@@ -288,7 +288,7 @@ def test_authentication_getter_new_user_no_email(monkeypatch):
assert user.email is None
assert user.full_name is None
assert user.short_name is None
assert user.password == "!"
assert user.has_usable_password() is False
assert models.User.objects.count() == 1
@@ -315,7 +315,7 @@ def test_authentication_getter_new_user_with_email(monkeypatch):
assert user.email == email
assert user.full_name == "John Doe"
assert user.short_name == "John"
assert user.password == "!"
assert user.has_usable_password() is False
assert models.User.objects.count() == 1
@@ -345,11 +345,15 @@ def test_authentication_get_userinfo_json_response():
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
@responses.activate
def test_authentication_get_userinfo_token_response(monkeypatch):
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
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
@@ -371,21 +375,25 @@ def test_authentication_get_userinfo_token_response(monkeypatch):
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
@responses.activate
def test_authentication_get_userinfo_invalid_response():
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
responses.GET,
re.compile(r".*/userinfo"),
body="fake.jwt.token",
status=200,
content_type="application/jwt",
)
oidc_backend = OIDCAuthenticationBackend()
with pytest.raises(
SuspiciousOperation,
match="Invalid response format or token verification failed",
match="User info response was not valid JWT",
):
oidc_backend.get_userinfo("fake_access_token", None, None)
@@ -450,100 +458,54 @@ def test_authentication_getter_existing_disabled_user_via_email(
assert models.User.objects.count() == 1
# Essential claims
def test_authentication_verify_claims_default(django_assert_num_queries, monkeypatch):
"""The sub claim should be mandatory by default."""
klass = OIDCAuthenticationBackend()
def get_userinfo_mocked(*args):
return {
"test": "123",
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with (
django_assert_num_queries(0),
pytest.raises(
KeyError,
match="sub",
),
):
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
assert models.User.objects.exists() is False
@pytest.mark.parametrize(
"essential_claims, missing_claims",
[
(["email", "sub"], ["email"]),
(["Email", "sub"], ["Email"]), # Case sensitivity
],
)
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
@mock.patch.object(Logger, "error")
def test_authentication_verify_claims_essential_missing(
mock_logger,
essential_claims,
missing_claims,
django_assert_num_queries,
monkeypatch,
@responses.activate
def test_authentication_session_tokens(
django_assert_num_queries, monkeypatch, rf, settings
):
"""Ensure SuspiciousOperation is raised if essential claims are missing."""
"""
Test that the session contains oidc_refresh_token and oidc_access_token after authentication.
"""
settings.OIDC_OP_TOKEN_ENDPOINT = "http://oidc.endpoint.test/token"
settings.OIDC_OP_USER_ENDPOINT = "http://oidc.endpoint.test/userinfo"
settings.OIDC_OP_JWKS_ENDPOINT = "http://oidc.endpoint.test/jwks"
settings.OIDC_STORE_ACCESS_TOKEN = True
settings.OIDC_STORE_REFRESH_TOKEN = True
settings.OIDC_STORE_REFRESH_TOKEN_KEY = Fernet.generate_key()
klass = OIDCAuthenticationBackend()
request = rf.get("/some-url", {"state": "test-state", "code": "test-code"})
request.session = {}
def get_userinfo_mocked(*args):
return {
"sub": "123",
"last_name": "Doe",
}
def verify_token_mocked(*args, **kwargs):
return {"sub": "123", "email": "test@example.com"}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
monkeypatch.setattr(OIDCAuthenticationBackend, "verify_token", verify_token_mocked)
with (
django_assert_num_queries(0),
pytest.raises(
SuspiciousOperation,
match="Claims verification failed",
),
override_settings(USER_OIDC_ESSENTIAL_CLAIMS=essential_claims),
):
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
responses.add(
responses.POST,
re.compile(settings.OIDC_OP_TOKEN_ENDPOINT),
json={
"access_token": "test-access-token",
"refresh_token": "test-refresh-token",
},
status=200,
)
assert models.User.objects.exists() is False
mock_logger.assert_called_once_with("Missing essential claims: %s", missing_claims)
@override_settings(
OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo",
USER_OIDC_ESSENTIAL_CLAIMS=["email", "last_name"],
)
def test_authentication_verify_claims_success(django_assert_num_queries, monkeypatch):
"""Ensure user is authenticated when all essential claims are present."""
klass = OIDCAuthenticationBackend()
def get_userinfo_mocked(*args):
return {
"email": "john.doe@example.com",
"last_name": "Doe",
"sub": "123",
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
responses.add(
responses.GET,
re.compile(settings.OIDC_OP_USER_ENDPOINT),
json={"sub": "123", "email": "test@example.com"},
status=200,
)
with django_assert_num_queries(6):
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
user = klass.authenticate(
request,
code="test-code",
nonce="test-nonce",
code_verifier="test-code-verifier",
)
assert models.User.objects.filter(id=user.id).exists()
assert user.sub == "123"
assert user.full_name == "Doe"
assert user.short_name is None
assert user.email == "john.doe@example.com"
assert user is not None
assert request.session["oidc_access_token"] == "test-access-token"
assert get_oidc_refresh_token(request.session) == "test-refresh-token"

View File

@@ -1,10 +0,0 @@
"""Unit tests for the Authentication URLs."""
from core.authentication.urls import urlpatterns
def test_urls_override_default_mozilla_django_oidc():
"""Custom URL patterns should override default ones from Mozilla Django OIDC."""
url_names = [u.name for u in urlpatterns]
assert url_names.index("oidc_logout_custom") < url_names.index("oidc_logout")

View File

@@ -1,231 +0,0 @@
"""Unit tests for the Authentication Views."""
from unittest import mock
from urllib.parse import parse_qs, urlparse
from django.contrib.auth.models import AnonymousUser
from django.contrib.sessions.middleware import SessionMiddleware
from django.core.exceptions import SuspiciousOperation
from django.test import RequestFactory
from django.test.utils import override_settings
from django.urls import reverse
from django.utils import crypto
import pytest
from rest_framework.test import APIClient
from core import factories
from core.authentication.views import OIDCLogoutCallbackView, OIDCLogoutView
pytestmark = pytest.mark.django_db
@override_settings(LOGOUT_REDIRECT_URL="/example-logout")
def test_view_logout_anonymous():
"""Anonymous users calling the logout url,
should be redirected to the specified LOGOUT_REDIRECT_URL."""
url = reverse("oidc_logout_custom")
response = APIClient().get(url)
assert response.status_code == 302
assert response.url == "/example-logout"
@mock.patch.object(
OIDCLogoutView, "construct_oidc_logout_url", return_value="/example-logout"
)
def test_view_logout(mocked_oidc_logout_url):
"""Authenticated users should be redirected to OIDC provider for logout."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
url = reverse("oidc_logout_custom")
response = client.get(url)
mocked_oidc_logout_url.assert_called_once()
assert response.status_code == 302
assert response.url == "/example-logout"
@override_settings(LOGOUT_REDIRECT_URL="/default-redirect-logout")
@mock.patch.object(
OIDCLogoutView, "construct_oidc_logout_url", return_value="/default-redirect-logout"
)
def test_view_logout_no_oidc_provider(mocked_oidc_logout_url):
"""Authenticated users should be logged out when no OIDC provider is available."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
url = reverse("oidc_logout_custom")
with mock.patch("mozilla_django_oidc.views.auth.logout") as mock_logout:
response = client.get(url)
mocked_oidc_logout_url.assert_called_once()
mock_logout.assert_called_once()
assert response.status_code == 302
assert response.url == "/default-redirect-logout"
@override_settings(LOGOUT_REDIRECT_URL="/example-logout")
def test_view_logout_callback_anonymous():
"""Anonymous users calling the logout callback url,
should be redirected to the specified LOGOUT_REDIRECT_URL."""
url = reverse("oidc_logout_callback")
response = APIClient().get(url)
assert response.status_code == 302
assert response.url == "/example-logout"
@pytest.mark.parametrize(
"initial_oidc_states",
[{}, {"other_state": "foo"}],
)
def test_view_logout_persist_state(initial_oidc_states):
"""State value should be persisted in session's data."""
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
if initial_oidc_states:
request.session["oidc_states"] = initial_oidc_states
request.session.save()
mocked_state = "mock_state"
OIDCLogoutView().persist_state(request, mocked_state)
assert "oidc_states" in request.session
assert request.session["oidc_states"] == {
"mock_state": {},
**initial_oidc_states,
}
@override_settings(OIDC_OP_LOGOUT_ENDPOINT="/example-logout")
@mock.patch.object(OIDCLogoutView, "persist_state")
@mock.patch.object(crypto, "get_random_string", return_value="mocked_state")
def test_view_logout_construct_oidc_logout_url(
mocked_get_random_string, mocked_persist_state
):
"""Should construct the logout URL to initiate the logout flow with the OIDC provider."""
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
request.session["oidc_id_token"] = "mocked_oidc_id_token"
request.session.save()
redirect_url = OIDCLogoutView().construct_oidc_logout_url(request)
mocked_persist_state.assert_called_once()
mocked_get_random_string.assert_called_once()
params = parse_qs(urlparse(redirect_url).query)
assert params["id_token_hint"][0] == "mocked_oidc_id_token"
assert params["state"][0] == "mocked_state"
url = reverse("oidc_logout_callback")
assert url in params["post_logout_redirect_uri"][0]
@override_settings(LOGOUT_REDIRECT_URL="/")
def test_view_logout_construct_oidc_logout_url_none_id_token():
"""If no ID token is available in the session,
the user should be redirected to the final URL."""
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
redirect_url = OIDCLogoutView().construct_oidc_logout_url(request)
assert redirect_url == "/"
@pytest.mark.parametrize(
"initial_state",
[None, {"other_state": "foo"}],
)
def test_view_logout_callback_wrong_state(initial_state):
"""Should raise an error if OIDC state doesn't match session data."""
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
if initial_state:
request.session["oidc_states"] = initial_state
request.session.save()
callback_view = OIDCLogoutCallbackView.as_view()
with pytest.raises(SuspiciousOperation) as excinfo:
callback_view(request)
assert (
str(excinfo.value) == "OIDC callback state not found in session `oidc_states`!"
)
@override_settings(LOGOUT_REDIRECT_URL="/example-logout")
def test_view_logout_callback():
"""If state matches, callback should clear OIDC state and redirects."""
user = factories.UserFactory()
request = RequestFactory().get("/logout-callback/", data={"state": "mocked_state"})
request.user = user
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
mocked_state = "mocked_state"
request.session["oidc_states"] = {mocked_state: {}}
request.session.save()
callback_view = OIDCLogoutCallbackView.as_view()
with mock.patch("mozilla_django_oidc.views.auth.logout") as mock_logout:
def clear_user(request):
# Assert state is cleared prior to logout
assert request.session["oidc_states"] == {}
request.user = AnonymousUser()
mock_logout.side_effect = clear_user
response = callback_view(request)
mock_logout.assert_called_once()
assert response.status_code == 302
assert response.url == "/example-logout"

View File

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

View File

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

View File

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

View File

@@ -370,7 +370,7 @@ def test_api_document_invitations_create_privileged_members(
Only owners and administrators should be able to invite new users.
Only owners can invite owners.
"""
user = factories.UserFactory()
user = factories.UserFactory(language="en-us")
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=inviting)
@@ -422,11 +422,12 @@ def test_api_document_invitations_create_privileged_members(
}
def test_api_document_invitations_create_email_from_content_language():
def test_api_document_invitations_create_email_from_senders_language():
"""
The email generated is from the language set in the Content-Language header
When inviting on a document a user who does not exist yet in our database,
the invitation email should be sent in the language of the sending user.
"""
user = factories.UserFactory()
user = factories.UserFactory(language="fr-fr")
document = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
@@ -444,7 +445,6 @@ def test_api_document_invitations_create_email_from_content_language():
f"/api/v1.0/documents/{document.id!s}/invitations/",
invitation_values,
format="json",
headers={"Content-Language": "fr-fr"},
)
assert response.status_code == 201
@@ -464,50 +464,11 @@ def test_api_document_invitations_create_email_from_content_language():
)
def test_api_document_invitations_create_email_from_content_language_not_supported():
"""
If the language from the Content-Language is not supported
it will display the default language, English.
"""
user = factories.UserFactory()
document = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
invitation_values = {
"email": "guest@example.com",
"role": "reader",
}
assert len(mail.outbox) == 0
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/invitations/",
invitation_values,
format="json",
headers={"Content-Language": "not-supported"},
)
assert response.status_code == 201
assert response.json()["email"] == "guest@example.com"
assert models.Invitation.objects.count() == 1
assert len(mail.outbox) == 1
email = mail.outbox[0]
assert email.to == ["guest@example.com"]
email_content = " ".join(email.body.split())
assert f"{user.full_name} shared a document with you!" in email_content
def test_api_document_invitations_create_email_full_name_empty():
"""
If the full name of the user is empty, it will display the email address.
"""
user = factories.UserFactory(full_name="")
user = factories.UserFactory(full_name="", language="en-us")
document = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
@@ -599,9 +560,11 @@ def test_api_document_invitations_create_cannot_duplicate_invitation():
)
assert response.status_code == 400
assert response.json() == [
"Document invitation with this Email address and Document already exists."
]
assert response.json() == {
"__all__": [
"Document invitation with this Email address and Document already exists."
],
}
def test_api_document_invitations_create_cannot_invite_existing_users():

View File

@@ -5,7 +5,6 @@ Test AI transform API endpoint for users in impress's core app.
import random
from unittest.mock import MagicMock, patch
from django.core.cache import cache
from django.test import override_settings
import pytest
@@ -17,12 +16,6 @@ from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
@pytest.fixture(autouse=True)
def clear_cache():
"""Fixture to clear the cache before each test."""
cache.clear()
@pytest.fixture
def ai_settings():
"""Fixture to set AI settings."""
@@ -71,9 +64,8 @@ def test_api_documents_ai_transform_anonymous_success(mock_create):
"""
document = factories.DocumentFactory(link_reach="public", link_role="editor")
answer = '{"answer": "Salut"}'
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=answer))]
choices=[MagicMock(message=MagicMock(content="Salut"))]
)
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
@@ -83,17 +75,15 @@ def test_api_documents_ai_transform_anonymous_success(mock_create):
assert response.json() == {"answer": "Salut"}
mock_create.assert_called_once_with(
model="llama",
response_format={"type": "json_object"},
messages=[
{
"role": "system",
"content": (
"Summarize the markdown text, preserving language and markdown formatting. "
'Return JSON: {"answer": "your markdown summary"}. Do not provide any other '
"information."
"Do not provide any other information. Preserve the language."
),
},
{"role": "user", "content": '{"markdown_input": "Hello"}'},
{"role": "user", "content": "Hello"},
],
)
@@ -170,9 +160,8 @@ def test_api_documents_ai_transform_authenticated_success(mock_create, reach, ro
document = factories.DocumentFactory(link_reach=reach, link_role=role)
answer = '{"answer": "Salut"}'
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=answer))]
choices=[MagicMock(message=MagicMock(content="Salut"))]
)
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
@@ -182,16 +171,15 @@ def test_api_documents_ai_transform_authenticated_success(mock_create, reach, ro
assert response.json() == {"answer": "Salut"}
mock_create.assert_called_once_with(
model="llama",
response_format={"type": "json_object"},
messages=[
{
"role": "system",
"content": (
'Answer the prompt in markdown format. Return JSON: {"answer": '
'"Your markdown answer"}. Do not provide any other information.'
"Answer the prompt in markdown format. Preserve the language and markdown "
"formatting. Do not provide any other information. Preserve the language."
),
},
{"role": "user", "content": '{"markdown_input": "Hello"}'},
{"role": "user", "content": "Hello"},
],
)
@@ -246,9 +234,8 @@ def test_api_documents_ai_transform_success(mock_create, via, role, mock_user_te
document=document, team="lasuite", role=role
)
answer = '{"answer": "Salut"}'
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=answer))]
choices=[MagicMock(message=MagicMock(content="Salut"))]
)
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
@@ -258,16 +245,15 @@ def test_api_documents_ai_transform_success(mock_create, via, role, mock_user_te
assert response.json() == {"answer": "Salut"}
mock_create.assert_called_once_with(
model="llama",
response_format={"type": "json_object"},
messages=[
{
"role": "system",
"content": (
'Answer the prompt in markdown format. Return JSON: {"answer": '
'"Your markdown answer"}. Do not provide any other information.'
"Answer the prompt in markdown format. Preserve the language and markdown "
"formatting. Do not provide any other information. Preserve the language."
),
},
{"role": "user", "content": '{"markdown_input": "Hello"}'},
{"role": "user", "content": "Hello"},
],
)
@@ -315,9 +301,8 @@ def test_api_documents_ai_transform_throttling_document(mock_create):
client = APIClient()
document = factories.DocumentFactory(link_reach="public", link_role="editor")
answer = '{"answer": "Salut"}'
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=answer))]
choices=[MagicMock(message=MagicMock(content="Salut"))]
)
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
@@ -350,9 +335,8 @@ def test_api_documents_ai_transform_throttling_user(mock_create):
client = APIClient()
client.force_login(user)
answer = '{"answer": "Salut"}'
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=answer))]
choices=[MagicMock(message=MagicMock(content="Salut"))]
)
for _ in range(3):

View File

@@ -5,7 +5,6 @@ Test AI translate API endpoint for users in impress's core app.
import random
from unittest.mock import MagicMock, patch
from django.core.cache import cache
from django.test import override_settings
import pytest
@@ -17,12 +16,6 @@ from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
@pytest.fixture(autouse=True)
def clear_cache():
"""Fixture to clear the cache before each test."""
cache.clear()
@pytest.fixture
def ai_settings():
"""Fixture to set AI settings."""
@@ -91,29 +84,28 @@ def test_api_documents_ai_translate_anonymous_success(mock_create):
"""
document = factories.DocumentFactory(link_reach="public", link_role="editor")
answer = '{"answer": "Salut"}'
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=answer))]
choices=[MagicMock(message=MagicMock(content="Ola"))]
)
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
response = APIClient().post(url, {"text": "Hello", "language": "es"})
assert response.status_code == 200
assert response.json() == {"answer": "Salut"}
assert response.json() == {"answer": "Ola"}
mock_create.assert_called_once_with(
model="llama",
response_format={"type": "json_object"},
messages=[
{
"role": "system",
"content": (
"Translate the markdown text to Spanish, preserving markdown formatting. "
'Return JSON: {"answer": "your translated markdown text in Spanish"}. '
"Keep the same html stucture and formatting. "
"Translate the content in the html to the specified language Spanish. "
"Check the translation for accuracy and make any necessary corrections. "
"Do not provide any other information."
),
},
{"role": "user", "content": '{"markdown_input": "Hello"}'},
{"role": "user", "content": "Hello"},
],
)
@@ -190,9 +182,8 @@ def test_api_documents_ai_translate_authenticated_success(mock_create, reach, ro
document = factories.DocumentFactory(link_reach=reach, link_role=role)
answer = '{"answer": "Salut"}'
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=answer))]
choices=[MagicMock(message=MagicMock(content="Salut"))]
)
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
@@ -202,18 +193,18 @@ def test_api_documents_ai_translate_authenticated_success(mock_create, reach, ro
assert response.json() == {"answer": "Salut"}
mock_create.assert_called_once_with(
model="llama",
response_format={"type": "json_object"},
messages=[
{
"role": "system",
"content": (
"Translate the markdown text to Colombian Spanish, "
"preserving markdown formatting. Return JSON: "
'{"answer": "your translated markdown text in Colombian Spanish"}. '
"Keep the same html stucture and formatting. "
"Translate the content in the html to the "
"specified language Colombian Spanish. "
"Check the translation for accuracy and make any necessary corrections. "
"Do not provide any other information."
),
},
{"role": "user", "content": '{"markdown_input": "Hello"}'},
{"role": "user", "content": "Hello"},
],
)
@@ -268,9 +259,8 @@ def test_api_documents_ai_translate_success(mock_create, via, role, mock_user_te
document=document, team="lasuite", role=role
)
answer = '{"answer": "Salut"}'
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=answer))]
choices=[MagicMock(message=MagicMock(content="Salut"))]
)
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
@@ -280,18 +270,18 @@ def test_api_documents_ai_translate_success(mock_create, via, role, mock_user_te
assert response.json() == {"answer": "Salut"}
mock_create.assert_called_once_with(
model="llama",
response_format={"type": "json_object"},
messages=[
{
"role": "system",
"content": (
"Translate the markdown text to Colombian Spanish, "
"preserving markdown formatting. Return JSON: "
'{"answer": "your translated markdown text in Colombian Spanish"}. '
"Keep the same html stucture and formatting. "
"Translate the content in the html to the "
"specified language Colombian Spanish. "
"Check the translation for accuracy and make any necessary corrections. "
"Do not provide any other information."
),
},
{"role": "user", "content": '{"markdown_input": "Hello"}'},
{"role": "user", "content": "Hello"},
],
)
@@ -339,9 +329,8 @@ def test_api_documents_ai_translate_throttling_document(mock_create):
client = APIClient()
document = factories.DocumentFactory(link_reach="public", link_role="editor")
answer = '{"answer": "Salut"}'
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=answer))]
choices=[MagicMock(message=MagicMock(content="Salut"))]
)
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
@@ -374,9 +363,8 @@ def test_api_documents_ai_translate_throttling_user(mock_create):
client = APIClient()
client.force_login(user)
answer = '{"answer": "Salut"}'
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=answer))]
choices=[MagicMock(message=MagicMock(content="Salut"))]
)
for _ in range(3):

View File

@@ -67,10 +67,12 @@ def test_api_documents_attachment_upload_anonymous_success():
file_path = response.json()["file"]
match = pattern.search(file_path)
file_id = match.group(1)
# Validate that file_id is a valid UUID
uuid.UUID(file_id)
document.refresh_from_db()
assert document.attachments == [f"{document.id!s}/attachments/{file_id!s}.png"]
# Now, check the metadata of the uploaded file
key = file_path.replace("/media", "")
file_head = default_storage.connection.meta.client.head_object(
@@ -112,6 +114,9 @@ def test_api_documents_attachment_upload_authenticated_forbidden(reach, role):
"detail": "You do not have permission to perform this action."
}
document.refresh_from_db()
assert document.attachments == []
@pytest.mark.parametrize(
"reach, role",
@@ -122,8 +127,8 @@ def test_api_documents_attachment_upload_authenticated_forbidden(reach, role):
)
def test_api_documents_attachment_upload_authenticated_success(reach, role):
"""
Autenticated who are not related to a document should be able to upload a file
if the link reach and role permit it.
Autenticated users who are not related to a document should be able to upload
a file when the link reach and role permit it.
"""
user = factories.UserFactory()
@@ -145,6 +150,9 @@ def test_api_documents_attachment_upload_authenticated_success(reach, role):
# Validate that file_id is a valid UUID
uuid.UUID(file_id)
document.refresh_from_db()
assert document.attachments == [f"{document.id!s}/attachments/{file_id!s}.png"]
@pytest.mark.parametrize("via", VIA)
def test_api_documents_attachment_upload_reader(via, mock_user_teams):
@@ -175,6 +183,9 @@ def test_api_documents_attachment_upload_reader(via, mock_user_teams):
"detail": "You do not have permission to perform this action."
}
document.refresh_from_db()
assert document.attachments == []
@pytest.mark.parametrize("role", ["editor", "administrator", "owner"])
@pytest.mark.parametrize("via", VIA)
@@ -211,6 +222,9 @@ def test_api_documents_attachment_upload_success(via, role, mock_user_teams):
# Validate that file_id is a valid UUID
uuid.UUID(file_id)
document.refresh_from_db()
assert document.attachments == [f"{document.id!s}/attachments/{file_id!s}.png"]
# Now, check the metadata of the uploaded file
key = file_path.replace("/media", "")
file_head = default_storage.connection.meta.client.head_object(
@@ -236,6 +250,9 @@ def test_api_documents_attachment_upload_invalid(client):
assert response.status_code == 400
assert response.json() == {"file": ["No file was submitted."]}
document.refresh_from_db()
assert document.attachments == []
def test_api_documents_attachment_upload_size_limit_exceeded(settings):
"""The uploaded file should not exceeed the maximum size in settings."""
@@ -258,6 +275,9 @@ def test_api_documents_attachment_upload_size_limit_exceeded(settings):
assert response.status_code == 400
assert response.json() == {"file": ["File size exceeds the maximum limit of 1 MB."]}
document.refresh_from_db()
assert document.attachments == []
@pytest.mark.parametrize(
"name,content,extension,content_type",
@@ -293,7 +313,14 @@ def test_api_documents_attachment_upload_fix_extension(
match = pattern.search(file_path)
file_id = match.group(1)
document.refresh_from_db()
assert document.attachments == [
f"{document.id!s}/attachments/{file_id!s}.{extension:s}"
]
assert "-unsafe" in file_id
# Validate that file_id is a valid UUID
file_id = file_id.replace("-unsafe", "")
uuid.UUID(file_id)
# Now, check the metadata of the uploaded file
@@ -321,6 +348,9 @@ def test_api_documents_attachment_upload_empty_file():
assert response.status_code == 400
assert response.json() == {"file": ["The submitted file is empty."]}
document.refresh_from_db()
assert document.attachments == []
def test_api_documents_attachment_upload_unsafe():
"""A file with an unsafe mime type should be tagged as such."""
@@ -343,7 +373,12 @@ def test_api_documents_attachment_upload_unsafe():
match = pattern.search(file_path)
file_id = match.group(1)
document.refresh_from_db()
assert document.attachments == [f"{document.id!s}/attachments/{file_id!s}.exe"]
assert "-unsafe" in file_id
# Validate that file_id is a valid UUID
file_id = file_id.replace("-unsafe", "")
uuid.UUID(file_id)
# Now, check the metadata of the uploaded file

View File

@@ -1,7 +1,8 @@
"""
Tests for Documents API endpoint in impress's core app: create
Tests for Documents API endpoint in impress's core app: children create
"""
from concurrent.futures import ThreadPoolExecutor
from uuid import uuid4
import pytest
@@ -249,3 +250,41 @@ def test_api_documents_children_create_force_id_existing():
assert response.json() == {
"id": ["A document with this ID already exists. You cannot override it."]
}
@pytest.mark.django_db(transaction=True)
def test_api_documents_create_document_children_race_condition():
"""
It should be possible to create several documents at the same time
without causing any race conditions or data integrity issues.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
factories.UserDocumentAccessFactory(user=user, document=document, role="owner")
def create_document():
return client.post(
f"/api/v1.0/documents/{document.id}/children/",
{
"title": "my child",
},
)
with ThreadPoolExecutor(max_workers=2) as executor:
future1 = executor.submit(create_document)
future2 = executor.submit(create_document)
response1 = future1.result()
response2 = future2.result()
assert response1.status_code == 201
assert response2.status_code == 201
document.refresh_from_db()
assert document.numchild == 2

View File

@@ -1,5 +1,5 @@
"""
Tests for Documents API endpoint in impress's core app: retrieve
Tests for Documents API endpoint in impress's core app: children list
"""
import random
@@ -15,7 +15,7 @@ pytestmark = pytest.mark.django_db
def test_api_documents_children_list_anonymous_public_standalone():
"""Anonymous users should be allowed to retrieve the children of a public documents."""
"""Anonymous users should be allowed to retrieve the children of a public document."""
document = factories.DocumentFactory(link_reach="public")
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
factories.UserDocumentAccessFactory(document=child1)
@@ -39,7 +39,8 @@ def test_api_documents_children_list_anonymous_public_standalone():
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 0,
"nb_accesses": 1,
"nb_accesses_ancestors": 1,
"nb_accesses_direct": 1,
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
@@ -56,7 +57,8 @@ def test_api_documents_children_list_anonymous_public_standalone():
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
"nb_accesses": 0,
"nb_accesses_ancestors": 0,
"nb_accesses_direct": 0,
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
@@ -100,7 +102,8 @@ def test_api_documents_children_list_anonymous_public_parent():
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 0,
"nb_accesses": 1,
"nb_accesses_ancestors": 1,
"nb_accesses_direct": 1,
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
@@ -117,7 +120,8 @@ def test_api_documents_children_list_anonymous_public_parent():
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
"nb_accesses": 0,
"nb_accesses_ancestors": 0,
"nb_accesses_direct": 0,
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
@@ -179,7 +183,8 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 0,
"nb_accesses": 1,
"nb_accesses_ancestors": 1,
"nb_accesses_direct": 1,
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
@@ -196,7 +201,8 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
"nb_accesses": 0,
"nb_accesses_ancestors": 0,
"nb_accesses_direct": 0,
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
@@ -244,7 +250,8 @@ def test_api_documents_children_list_authenticated_public_or_authenticated_paren
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 0,
"nb_accesses": 1,
"nb_accesses_ancestors": 1,
"nb_accesses_direct": 1,
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
@@ -261,7 +268,8 @@ def test_api_documents_children_list_authenticated_public_or_authenticated_paren
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
"nb_accesses": 0,
"nb_accesses_ancestors": 0,
"nb_accesses_direct": 0,
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
@@ -331,7 +339,8 @@ def test_api_documents_children_list_authenticated_related_direct():
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 0,
"nb_accesses": 3,
"nb_accesses_ancestors": 3,
"nb_accesses_direct": 1,
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
@@ -348,7 +357,8 @@ def test_api_documents_children_list_authenticated_related_direct():
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
"nb_accesses": 2,
"nb_accesses_ancestors": 2,
"nb_accesses_direct": 0,
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
@@ -399,7 +409,8 @@ def test_api_documents_children_list_authenticated_related_parent():
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 0,
"nb_accesses": 2,
"nb_accesses_ancestors": 2,
"nb_accesses_direct": 1,
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
@@ -416,7 +427,8 @@ def test_api_documents_children_list_authenticated_related_parent():
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
"nb_accesses": 1,
"nb_accesses_ancestors": 1,
"nb_accesses_direct": 0,
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
@@ -514,7 +526,8 @@ def test_api_documents_children_list_authenticated_related_team_members(
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 0,
"nb_accesses": 1,
"nb_accesses_ancestors": 1,
"nb_accesses_direct": 0,
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
@@ -531,7 +544,8 @@ def test_api_documents_children_list_authenticated_related_team_members(
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
"nb_accesses": 1,
"nb_accesses_ancestors": 1,
"nb_accesses_direct": 0,
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),

View File

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

View File

@@ -2,6 +2,7 @@
Tests for Documents API endpoint in impress's core app: create
"""
from concurrent.futures import ThreadPoolExecutor
from uuid import uuid4
import pytest
@@ -51,6 +52,36 @@ def test_api_documents_create_authenticated_success():
assert document.accesses.filter(role="owner", user=user).exists()
@pytest.mark.django_db(transaction=True)
def test_api_documents_create_document_race_condition():
"""
It should be possible to create several documents at the same time
without causing any race conditions or data integrity issues.
"""
def create_document(title):
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
return client.post(
"/api/v1.0/documents/",
{
"title": title,
},
format="json",
)
with ThreadPoolExecutor(max_workers=2) as executor:
future1 = executor.submit(create_document, "my document 1")
future2 = executor.submit(create_document, "my document 2")
response1 = future1.result()
response2 = future2.result()
assert response1.status_code == 201
assert response2.status_code == 201
def test_api_documents_create_authenticated_title_null():
"""It should be possible to create several documents with a null title."""
user = factories.UserFactory()

View File

@@ -4,6 +4,7 @@ Tests for Documents API endpoint in impress's core app: create
# pylint: disable=W0621
from concurrent.futures import ThreadPoolExecutor
from unittest.mock import patch
from django.core import mail
@@ -425,6 +426,36 @@ def test_api_documents_create_for_owner_new_user_no_sub_no_fallback_allow_duplic
assert document.creator == user
@pytest.mark.django_db(transaction=True)
def test_api_documents_create_document_race_condition():
"""
It should be possible to create several documents at the same time
without causing any race conditions or data integrity issues.
"""
def create_document(title):
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
return client.post(
"/api/v1.0/documents/",
{
"title": title,
},
format="json",
)
with ThreadPoolExecutor(max_workers=2) as executor:
future1 = executor.submit(create_document, "my document 1")
future2 = executor.submit(create_document, "my document 2")
response1 = future1.result()
response2 = future2.result()
assert response1.status_code == 201
assert response2.status_code == 201
@patch.object(ServerCreateDocumentSerializer, "_send_email_notification")
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"], LANGUAGE_CODE="de-de")
def test_api_documents_create_for_owner_with_default_language(

View File

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

View File

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

View File

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

View File

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

View File

@@ -70,7 +70,8 @@ def test_api_documents_list_format():
"is_favorite": True,
"link_reach": document.link_reach,
"link_role": document.link_role,
"nb_accesses": 3,
"nb_accesses_ancestors": 3,
"nb_accesses_direct": 3,
"numchild": 0,
"path": document.path,
"title": document.title,
@@ -147,7 +148,7 @@ def test_api_documents_list_authenticated_direct(django_assert_num_queries):
str(child4_with_access.id),
}
with django_assert_num_queries(8):
with django_assert_num_queries(12):
response = client.get("/api/v1.0/documents/")
# nb_accesses should now be cached
@@ -185,7 +186,7 @@ def test_api_documents_list_authenticated_via_team(
expected_ids = {str(document.id) for document in documents_team1 + documents_team2}
with django_assert_num_queries(9):
with django_assert_num_queries(14):
response = client.get("/api/v1.0/documents/")
# nb_accesses should now be cached
@@ -218,7 +219,7 @@ def test_api_documents_list_authenticated_link_reach_restricted(
other_document = factories.DocumentFactory(link_reach="public")
models.LinkTrace.objects.create(document=other_document, user=user)
with django_assert_num_queries(5):
with django_assert_num_queries(6):
response = client.get("/api/v1.0/documents/")
# nb_accesses should now be cached
@@ -267,7 +268,7 @@ def test_api_documents_list_authenticated_link_reach_public_or_authenticated(
expected_ids = {str(document1.id), str(document2.id), str(visible_child.id)}
with django_assert_num_queries(7):
with django_assert_num_queries(10):
response = client.get("/api/v1.0/documents/")
# nb_accesses should now be cached
@@ -328,6 +329,35 @@ def test_api_documents_list_pagination(
assert document_ids == []
def test_api_documents_list_pagination_force_page_size():
"""Page size can be set via querystring."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document_ids = [
str(access.document_id)
for access in factories.UserDocumentAccessFactory.create_batch(3, user=user)
]
# Force page size
response = client.get(
"/api/v1.0/documents/?page_size=2",
)
assert response.status_code == 200
content = response.json()
assert content["count"] == 3
assert content["next"] == "http://testserver/api/v1.0/documents/?page=2&page_size=2"
assert content["previous"] is None
assert len(content["results"]) == 2
for item in content["results"]:
document_ids.remove(item["id"])
def test_api_documents_list_authenticated_distinct():
"""A document with several related users should only be listed once."""
user = factories.UserFactory()
@@ -362,7 +392,7 @@ def test_api_documents_list_favorites_no_extra_queries(django_assert_num_queries
factories.DocumentFactory.create_batch(2, users=[user])
url = "/api/v1.0/documents/"
with django_assert_num_queries(9):
with django_assert_num_queries(14):
response = client.get(url)
# nb_accesses should now be cached

View File

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

View File

@@ -34,16 +34,25 @@ def test_api_documents_retrieve_anonymous_public_standalone():
"children_create": False,
"children_list": True,
"collaboration_auth": True,
"cors_proxy": True,
"descendants": True,
"destroy": False,
"duplicate": True,
# Anonymous user can't favorite a document even with read access
"favorite": False,
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"restricted": ["reader", "editor"],
},
"media_auth": True,
"move": False,
"partial_update": document.link_role == "editor",
"restore": False,
"retrieve": True,
"tree": True,
"update": document.link_role == "editor",
"versions_destroy": False,
"versions_list": False,
@@ -57,7 +66,8 @@ def test_api_documents_retrieve_anonymous_public_standalone():
"is_favorite": False,
"link_reach": "public",
"link_role": document.link_role,
"nb_accesses": 0,
"nb_accesses_ancestors": 0,
"nb_accesses_direct": 0,
"numchild": 0,
"path": document.path,
"title": document.title,
@@ -79,6 +89,7 @@ def test_api_documents_retrieve_anonymous_public_parent():
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/")
assert response.status_code == 200
links = document.get_ancestors().values("link_reach", "link_role")
assert response.json() == {
"id": str(document.id),
"abilities": {
@@ -90,16 +101,21 @@ def test_api_documents_retrieve_anonymous_public_parent():
"children_create": False,
"children_list": True,
"collaboration_auth": True,
"descendants": True,
"cors_proxy": True,
"destroy": False,
"duplicate": True,
# Anonymous user can't favorite a document even with read access
"favorite": False,
"invite_owner": False,
"link_configuration": False,
"link_select_options": models.LinkReachChoices.get_select_options(links),
"media_auth": True,
"move": False,
"partial_update": grand_parent.link_role == "editor",
"restore": False,
"retrieve": True,
"tree": True,
"update": grand_parent.link_role == "editor",
"versions_destroy": False,
"versions_list": False,
@@ -113,7 +129,8 @@ def test_api_documents_retrieve_anonymous_public_parent():
"is_favorite": False,
"link_reach": document.link_reach,
"link_role": document.link_role,
"nb_accesses": 0,
"nb_accesses_ancestors": 0,
"nb_accesses_direct": 0,
"numchild": 0,
"path": document.path,
"title": document.title,
@@ -180,15 +197,24 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
"children_create": document.link_role == "editor",
"children_list": True,
"collaboration_auth": True,
"descendants": True,
"cors_proxy": True,
"destroy": False,
"duplicate": True,
"favorite": True,
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"restricted": ["reader", "editor"],
},
"media_auth": True,
"move": False,
"partial_update": document.link_role == "editor",
"restore": False,
"retrieve": True,
"tree": True,
"update": document.link_role == "editor",
"versions_destroy": False,
"versions_list": False,
@@ -202,7 +228,8 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
"is_favorite": False,
"link_reach": reach,
"link_role": document.link_role,
"nb_accesses": 0,
"nb_accesses_ancestors": 0,
"nb_accesses_direct": 0,
"numchild": 0,
"path": document.path,
"title": document.title,
@@ -232,6 +259,7 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
assert response.status_code == 200
links = document.get_ancestors().values("link_reach", "link_role")
assert response.json() == {
"id": str(document.id),
"abilities": {
@@ -243,15 +271,20 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
"children_create": grand_parent.link_role == "editor",
"children_list": True,
"collaboration_auth": True,
"descendants": True,
"cors_proxy": True,
"destroy": False,
"duplicate": True,
"favorite": True,
"invite_owner": False,
"link_configuration": False,
"link_select_options": models.LinkReachChoices.get_select_options(links),
"move": False,
"media_auth": True,
"partial_update": grand_parent.link_role == "editor",
"restore": False,
"retrieve": True,
"tree": True,
"update": grand_parent.link_role == "editor",
"versions_destroy": False,
"versions_list": False,
@@ -265,7 +298,8 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
"is_favorite": False,
"link_reach": document.link_reach,
"link_role": document.link_role,
"nb_accesses": 0,
"nb_accesses_ancestors": 0,
"nb_accesses_direct": 0,
"numchild": 0,
"path": document.path,
"title": document.title,
@@ -374,7 +408,8 @@ def test_api_documents_retrieve_authenticated_related_direct():
"is_favorite": False,
"link_reach": document.link_reach,
"link_role": document.link_role,
"nb_accesses": 2,
"nb_accesses_ancestors": 2,
"nb_accesses_direct": 2,
"numchild": 0,
"path": document.path,
"title": document.title,
@@ -404,6 +439,7 @@ def test_api_documents_retrieve_authenticated_related_parent():
f"/api/v1.0/documents/{document.id!s}/",
)
assert response.status_code == 200
links = document.get_ancestors().values("link_reach", "link_role")
assert response.json() == {
"id": str(document.id),
"abilities": {
@@ -415,15 +451,20 @@ def test_api_documents_retrieve_authenticated_related_parent():
"children_create": access.role != "reader",
"children_list": True,
"collaboration_auth": True,
"descendants": True,
"cors_proxy": True,
"destroy": access.role == "owner",
"duplicate": True,
"favorite": True,
"invite_owner": access.role == "owner",
"link_configuration": access.role in ["administrator", "owner"],
"link_select_options": models.LinkReachChoices.get_select_options(links),
"media_auth": True,
"move": access.role in ["administrator", "owner"],
"partial_update": access.role != "reader",
"restore": access.role == "owner",
"retrieve": True,
"tree": True,
"update": access.role != "reader",
"versions_destroy": access.role in ["administrator", "owner"],
"versions_list": True,
@@ -437,7 +478,8 @@ def test_api_documents_retrieve_authenticated_related_parent():
"is_favorite": False,
"link_reach": "restricted",
"link_role": document.link_role,
"nb_accesses": 2,
"nb_accesses_ancestors": 2,
"nb_accesses_direct": 0,
"numchild": 0,
"path": document.path,
"title": document.title,
@@ -465,7 +507,8 @@ def test_api_documents_retrieve_authenticated_related_nb_accesses():
f"/api/v1.0/documents/{document.id!s}/",
)
assert response.status_code == 200
assert response.json()["nb_accesses"] == 3
assert response.json()["nb_accesses_ancestors"] == 3
assert response.json()["nb_accesses_direct"] == 1
factories.UserDocumentAccessFactory(document=grand_parent)
@@ -473,7 +516,8 @@ def test_api_documents_retrieve_authenticated_related_nb_accesses():
f"/api/v1.0/documents/{document.id!s}/",
)
assert response.status_code == 200
assert response.json()["nb_accesses"] == 4
assert response.json()["nb_accesses_ancestors"] == 4
assert response.json()["nb_accesses_direct"] == 1
def test_api_documents_retrieve_authenticated_related_child():
@@ -554,12 +598,10 @@ def test_api_documents_retrieve_authenticated_related_team_members(
mock_user_teams.return_value = teams
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="restricted")
factories.TeamDocumentAccessFactory(
document=document, team="readers", role="reader"
)
@@ -588,7 +630,8 @@ def test_api_documents_retrieve_authenticated_related_team_members(
"is_favorite": False,
"link_reach": "restricted",
"link_role": document.link_role,
"nb_accesses": 5,
"nb_accesses_ancestors": 5,
"nb_accesses_direct": 5,
"numchild": 0,
"path": document.path,
"title": document.title,
@@ -649,7 +692,8 @@ def test_api_documents_retrieve_authenticated_related_team_administrators(
"is_favorite": False,
"link_reach": "restricted",
"link_role": document.link_role,
"nb_accesses": 5,
"nb_accesses_ancestors": 5,
"nb_accesses_direct": 5,
"numchild": 0,
"path": document.path,
"title": document.title,
@@ -710,7 +754,8 @@ def test_api_documents_retrieve_authenticated_related_team_owners(
"is_favorite": False,
"link_reach": "restricted",
"link_role": document.link_role,
"nb_accesses": 5,
"nb_accesses_ancestors": 5,
"nb_accesses_direct": 5,
"numchild": 0,
"path": document.path,
"title": document.title,
@@ -719,7 +764,7 @@ def test_api_documents_retrieve_authenticated_related_team_owners(
}
def test_api_documents_retrieve_user_roles(django_assert_num_queries):
def test_api_documents_retrieve_user_roles(django_assert_max_num_queries):
"""
Roles should be annotated on querysets taking into account all documents ancestors.
"""
@@ -744,7 +789,7 @@ def test_api_documents_retrieve_user_roles(django_assert_num_queries):
)
expected_roles = {access.role for access in accesses}
with django_assert_num_queries(10):
with django_assert_max_num_queries(14):
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
assert response.status_code == 200
@@ -761,7 +806,7 @@ def test_api_documents_retrieve_numqueries_with_link_trace(django_assert_num_que
document = factories.DocumentFactory(users=[user], link_traces=[user])
with django_assert_num_queries(4):
with django_assert_num_queries(5):
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
with django_assert_num_queries(3):

View File

@@ -78,15 +78,24 @@ def test_api_documents_trashbin_format():
"children_create": True,
"children_list": True,
"collaboration_auth": True,
"descendants": True,
"cors_proxy": True,
"destroy": True,
"duplicate": True,
"favorite": True,
"invite_owner": True,
"link_configuration": True,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"restricted": ["reader", "editor"],
},
"media_auth": True,
"move": False, # Can't move a deleted document
"partial_update": True,
"restore": True,
"retrieve": True,
"tree": True,
"update": True,
"versions_destroy": True,
"versions_list": True,
@@ -98,7 +107,8 @@ def test_api_documents_trashbin_format():
"excerpt": document.excerpt,
"link_reach": document.link_reach,
"link_role": document.link_role,
"nb_accesses": 3,
"nb_accesses_ancestors": 0,
"nb_accesses_direct": 3,
"numchild": 0,
"path": document.path,
"title": document.title,
@@ -147,7 +157,7 @@ def test_api_documents_trashbin_authenticated_direct(django_assert_num_queries):
expected_ids = {str(document1.id), str(document2.id), str(document3.id)}
with django_assert_num_queries(7):
with django_assert_num_queries(10):
response = client.get("/api/v1.0/documents/trashbin/")
with django_assert_num_queries(4):
@@ -189,7 +199,7 @@ def test_api_documents_trashbin_authenticated_via_team(
expected_ids = {str(deleted_document_team1.id), str(deleted_document_team2.id)}
with django_assert_num_queries(5):
with django_assert_num_queries(7):
response = client.get("/api/v1.0/documents/trashbin/")
with django_assert_num_queries(3):

File diff suppressed because it is too large Load Diff

View File

@@ -275,7 +275,8 @@ def test_api_documents_update_authenticated_editor_administrator_or_owner(
"depth",
"link_reach",
"link_role",
"nb_accesses",
"nb_accesses_ancestors",
"nb_accesses_direct",
"numchild",
"path",
]:
@@ -327,3 +328,22 @@ def test_api_documents_update_administrator_or_owner_of_another(via, mock_user_t
other_document.refresh_from_db()
other_document_values = serializers.DocumentSerializer(instance=other_document).data
assert other_document_values == old_document_values
def test_api_documents_update_invalid_content():
"""
Updating a document with a non base64 encoded content should raise a validation error.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[[user, "owner"]])
response = client.put(
f"/api/v1.0/documents/{document.id!s}/",
{"content": "invalid content"},
format="json",
)
assert response.status_code == 400
assert response.json() == {"content": ["Invalid base64 content."]}

View File

@@ -0,0 +1,154 @@
"""
Test extract-attachments on document update in docs core app.
"""
import base64
from uuid import uuid4
import pycrdt
import pytest
from rest_framework.test import APIClient
from core import factories
pytestmark = pytest.mark.django_db
def get_ydoc_with_mages(image_keys):
"""Return a ydoc from text for testing purposes."""
ydoc = pycrdt.Doc()
fragment = pycrdt.XmlFragment(
[
pycrdt.XmlElement("img", {"src": f"http://localhost/media/{key:s}"})
for key in image_keys
]
)
ydoc["document-store"] = fragment
update = ydoc.get_update()
return base64.b64encode(update).decode("utf-8")
def test_api_documents_update_new_attachment_keys_anonymous(django_assert_num_queries):
"""
When an anonymous user updates a document, the attachment keys extracted from the
updated content should be added to the list of "attachments" ot the document if these
attachments are already readable by anonymous users.
"""
image_keys = [f"{uuid4()!s}/attachments/{uuid4()!s}.png" for _ in range(4)]
document = factories.DocumentFactory(
content=get_ydoc_with_mages(image_keys[:1]),
attachments=[image_keys[0]],
link_reach="public",
link_role="editor",
)
factories.DocumentFactory(attachments=[image_keys[1]], link_reach="public")
factories.DocumentFactory(attachments=[image_keys[2]], link_reach="authenticated")
factories.DocumentFactory(attachments=[image_keys[3]], link_reach="restricted")
expected_keys = {image_keys[i] for i in [0, 1]}
with django_assert_num_queries(9):
response = APIClient().put(
f"/api/v1.0/documents/{document.id!s}/",
{"content": get_ydoc_with_mages(image_keys)},
format="json",
)
assert response.status_code == 200
document.refresh_from_db()
assert set(document.attachments) == expected_keys
# Check that the db query to check attachments readability for extracted
# keys is not done if the content changes but no new keys are found
with django_assert_num_queries(5):
response = APIClient().put(
f"/api/v1.0/documents/{document.id!s}/",
{"content": get_ydoc_with_mages(image_keys[:2])},
format="json",
)
assert response.status_code == 200
document.refresh_from_db()
assert len(document.attachments) == 2
assert set(document.attachments) == expected_keys
def test_api_documents_update_new_attachment_keys_authenticated(
django_assert_num_queries,
):
"""
When an authenticated user updates a document, the attachment keys extracted from the
updated content should be added to the list of "attachments" ot the document if these
attachments are already readable by the editing user.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
image_keys = [f"{uuid4()!s}/attachments/{uuid4()!s}.png" for _ in range(5)]
document = factories.DocumentFactory(
content=get_ydoc_with_mages(image_keys[:1]),
attachments=[image_keys[0]],
users=[(user, "editor")],
)
factories.DocumentFactory(attachments=[image_keys[1]], link_reach="public")
factories.DocumentFactory(attachments=[image_keys[2]], link_reach="authenticated")
factories.DocumentFactory(attachments=[image_keys[3]], link_reach="restricted")
factories.DocumentFactory(attachments=[image_keys[4]], users=[user])
expected_keys = {image_keys[i] for i in [0, 1, 2, 4]}
with django_assert_num_queries(10):
response = client.put(
f"/api/v1.0/documents/{document.id!s}/",
{"content": get_ydoc_with_mages(image_keys)},
format="json",
)
assert response.status_code == 200
document.refresh_from_db()
assert set(document.attachments) == expected_keys
# Check that the db query to check attachments readability for extracted
# keys is not done if the content changes but no new keys are found
with django_assert_num_queries(6):
response = client.put(
f"/api/v1.0/documents/{document.id!s}/",
{"content": get_ydoc_with_mages(image_keys[:2])},
format="json",
)
assert response.status_code == 200
document.refresh_from_db()
assert len(document.attachments) == 4
assert set(document.attachments) == expected_keys
def test_api_documents_update_new_attachment_keys_duplicate():
"""
Duplicate keys in the content should not result in duplicates in the document's attachments.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
image_key1 = f"{uuid4()!s}/attachments/{uuid4()!s}.png"
image_key2 = f"{uuid4()!s}/attachments/{uuid4()!s}.png"
document = factories.DocumentFactory(
content=get_ydoc_with_mages([image_key1]),
attachments=[image_key1],
users=[(user, "editor")],
)
factories.DocumentFactory(attachments=[image_key2], users=[user])
response = client.put(
f"/api/v1.0/documents/{document.id!s}/",
{"content": get_ydoc_with_mages([image_key1, image_key2, image_key2])},
format="json",
)
assert response.status_code == 200
document.refresh_from_db()
assert len(document.attachments) == 2
assert set(document.attachments) == {image_key1, image_key2}

View File

@@ -1,35 +0,0 @@
import pytest
from core import factories
@pytest.mark.django_db
def test_update_blank_title_migration(migrator):
"""
Test that the migration fixes the titles of documents that are
"Untitled document", "Unbenanntes Dokument" or "Document sans titre"
"""
migrator.apply_initial_migration(("core", "0017_add_fields_for_soft_delete"))
english_doc = factories.DocumentFactory(title="Untitled document")
german_doc = factories.DocumentFactory(title="Unbenanntes Dokument")
french_doc = factories.DocumentFactory(title="Document sans titre")
other_doc = factories.DocumentFactory(title="My document")
assert english_doc.title == "Untitled document"
assert german_doc.title == "Unbenanntes Dokument"
assert french_doc.title == "Document sans titre"
assert other_doc.title == "My document"
# Apply the migration
migrator.apply_tested_migration(("core", "0018_update_blank_title"))
english_doc.refresh_from_db()
german_doc.refresh_from_db()
french_doc.refresh_from_db()
other_doc.refresh_from_db()
assert english_doc.title == None
assert german_doc.title == None
assert french_doc.title == None
assert other_doc.title == "My document"

View File

@@ -0,0 +1,47 @@
import pytest
from core import models
@pytest.mark.django_db
def test_update_blank_title_migration(migrator):
"""
Test that the migration fixes the titles of documents that are
"Untitled document", "Unbenanntes Dokument" or "Document sans titre"
"""
old_state = migrator.apply_initial_migration(
("core", "0017_add_fields_for_soft_delete")
)
OldDocument = old_state.apps.get_model("core", "Document")
old_english_doc = OldDocument.objects.create(
title="Untitled document", depth=1, path="0000001"
)
old_german_doc = OldDocument.objects.create(
title="Unbenanntes Dokument", depth=1, path="0000002"
)
old_french_doc = OldDocument.objects.create(
title="Document sans titre", depth=1, path="0000003"
)
old_other_doc = OldDocument.objects.create(
title="My document", depth=1, path="0000004"
)
assert old_english_doc.title == "Untitled document"
assert old_german_doc.title == "Unbenanntes Dokument"
assert old_french_doc.title == "Document sans titre"
assert old_other_doc.title == "My document"
# Apply the migration
new_state = migrator.apply_tested_migration(("core", "0018_update_blank_title"))
NewDocument = new_state.apps.get_model("core", "Document")
new_english_doc = NewDocument.objects.get(pk=old_english_doc.pk)
new_german_doc = NewDocument.objects.get(pk=old_german_doc.pk)
new_french_doc = NewDocument.objects.get(pk=old_french_doc.pk)
new_other_doc = NewDocument.objects.get(pk=old_other_doc.pk)
assert new_english_doc.title == None
assert new_german_doc.title == None
assert new_french_doc.title == None
assert new_other_doc.title == "My document"

View File

@@ -0,0 +1,54 @@
import base64
import uuid
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
import pycrdt
import pytest
from core import models
@pytest.mark.django_db
def test_populate_attachments_on_all_documents(migrator):
"""Test that the migration populates attachments on existing documents."""
old_state = migrator.apply_initial_migration(
("core", "0019_alter_user_language_default_to_null")
)
OldDocument = old_state.apps.get_model("core", "Document")
old_doc_without_attachments = OldDocument.objects.create(
title="Doc without attachments", depth=1, path="0000002"
)
old_doc_with_attachments = OldDocument.objects.create(
title="Doc with attachments", depth=1, path="0000001"
)
# Create document content with an image
file_key = f"{old_doc_with_attachments.id!s}/file"
image_key = f"{old_doc_with_attachments.id!s}/attachments/{uuid.uuid4()!s}.png"
ydoc = pycrdt.Doc()
fragment = pycrdt.XmlFragment(
[pycrdt.XmlElement("img", {"src": f"http://localhost/media/{image_key:s}"})]
)
ydoc["document-store"] = fragment
update = ydoc.get_update()
base64_content = base64.b64encode(update).decode("utf-8")
bytes_content = base64_content.encode("utf-8")
content_file = ContentFile(bytes_content)
default_storage.save(file_key, content_file)
# Apply the migration
new_state = migrator.apply_tested_migration(
("core", "0020_remove_is_public_add_field_attachments_and_duplicated_from")
)
NewDocument = new_state.apps.get_model("core", "Document")
new_doc_with_attachments = NewDocument.objects.get(pk=old_doc_with_attachments.pk)
new_doc_without_attachments = NewDocument.objects.get(
pk=old_doc_without_attachments.pk
)
assert new_doc_without_attachments.attachments == []
assert new_doc_with_attachments.attachments == [image_key]

View File

@@ -33,7 +33,7 @@ def test_openapi_client_schema():
)
assert output.getvalue() == ""
response = Client().get("/v1.0/swagger.json")
response = Client().get("/api/v1.0/swagger.json")
assert response.status_code == 200
with open(

View File

@@ -18,6 +18,9 @@ pytestmark = pytest.mark.django_db
@override_settings(
COLLABORATION_WS_URL="http://testcollab/",
CRISP_WEBSITE_ID="123",
FRONTEND_CSS_URL="http://testcss/",
FRONTEND_HOMEPAGE_FEATURE_ENABLED=True,
FRONTEND_FOOTER_FEATURE_ENABLED=True,
FRONTEND_THEME="test-theme",
MEDIA_BASE_URL="http://testserver/",
POSTHOG_KEY={"id": "132456", "host": "https://eu.i.posthog-test.com"},
@@ -38,10 +41,20 @@ def test_api_config(is_authenticated):
"COLLABORATION_WS_URL": "http://testcollab/",
"CRISP_WEBSITE_ID": "123",
"ENVIRONMENT": "test",
"FRONTEND_CSS_URL": "http://testcss/",
"FRONTEND_HOMEPAGE_FEATURE_ENABLED": True,
"FRONTEND_FOOTER_FEATURE_ENABLED": True,
"FRONTEND_THEME": "test-theme",
"LANGUAGES": [["en-us", "English"], ["fr-fr", "French"], ["de-de", "German"]],
"LANGUAGES": [
["en-us", "English"],
["fr-fr", "Français"],
["de-de", "Deutsch"],
["nl-nl", "Nederlands"],
["es-es", "Español"],
],
"LANGUAGE_CODE": "en-us",
"MEDIA_BASE_URL": "http://testserver/",
"POSTHOG_KEY": {"id": "132456", "host": "https://eu.i.posthog-test.com"},
"SENTRY_DSN": "https://sentry.test/123",
"AI_FEATURE_ENABLED": False,
}

View File

@@ -0,0 +1,81 @@
"""Test the footer API."""
import responses
from rest_framework.test import APIClient
def test_api_footer_without_settings_configured(settings):
"""Test the footer API without settings configured."""
settings.FRONTEND_URL_JSON_FOOTER = None
client = APIClient()
response = client.get("/api/v1.0/footer/")
assert response.status_code == 200
assert response.json() == {}
@responses.activate
def test_api_footer_with_invalid_request(settings):
"""Test the footer API with an invalid request."""
settings.FRONTEND_URL_JSON_FOOTER = "https://invalid-request.com"
footer_response = responses.get(settings.FRONTEND_URL_JSON_FOOTER, status=404)
client = APIClient()
response = client.get("/api/v1.0/footer/")
assert response.status_code == 200
assert response.json() == {}
assert footer_response.call_count == 1
@responses.activate
def test_api_footer_with_invalid_json(settings):
"""Test the footer API with an invalid JSON response."""
settings.FRONTEND_URL_JSON_FOOTER = "https://valid-request.com"
footer_response = responses.get(
settings.FRONTEND_URL_JSON_FOOTER, status=200, body="invalid json"
)
client = APIClient()
response = client.get("/api/v1.0/footer/")
assert response.status_code == 200
assert response.json() == {}
assert footer_response.call_count == 1
@responses.activate
def test_api_footer_with_valid_json(settings):
"""Test the footer API with an invalid JSON response."""
settings.FRONTEND_URL_JSON_FOOTER = "https://valid-request.com"
footer_response = responses.get(
settings.FRONTEND_URL_JSON_FOOTER, status=200, json={"foo": "bar"}
)
client = APIClient()
response = client.get("/api/v1.0/footer/")
assert response.status_code == 200
assert response.json() == {"foo": "bar"}
assert footer_response.call_count == 1
@responses.activate
def test_api_footer_with_valid_json_and_cache(settings):
"""Test the footer API with an invalid JSON response."""
settings.FRONTEND_URL_JSON_FOOTER = "https://valid-request.com"
footer_response = responses.get(
settings.FRONTEND_URL_JSON_FOOTER, status=200, json={"foo": "bar"}
)
client = APIClient()
response = client.get("/api/v1.0/footer/")
assert response.status_code == 200
assert response.json() == {"foo": "bar"}
assert footer_response.call_count == 1
response = client.get("/api/v1.0/footer/")
assert response.status_code == 200
assert response.json() == {"foo": "bar"}
# The cache should have been used
assert footer_response.call_count == 1

View File

@@ -24,7 +24,7 @@ def test_api_users_list_anonymous():
def test_api_users_list_authenticated():
"""
Authenticated users should be able to list users.
Authenticated users should not be able to list users without a query.
"""
user = factories.UserFactory()
@@ -37,7 +37,7 @@ def test_api_users_list_authenticated():
)
assert response.status_code == 200
content = response.json()
assert len(content["results"]) == 3
assert content == []
def test_api_users_list_query_email():
@@ -58,24 +58,76 @@ def test_api_users_list_query_email():
"/api/v1.0/users/?q=david.bowman@work.com",
)
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()["results"]]
user_ids = [user["id"] for user in response.json()]
assert user_ids == [str(dave.id)]
response = client.get(
"/api/v1.0/users/?q=davig.bovman@worm.com",
)
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()["results"]]
user_ids = [user["id"] for user in response.json()]
assert user_ids == [str(dave.id)]
response = client.get(
"/api/v1.0/users/?q=davig.bovman@worm.cop",
)
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()["results"]]
user_ids = [user["id"] for user in response.json()]
assert user_ids == []
def test_api_users_list_limit(settings):
"""
Authenticated users should be able to list users and the number of results
should be limited to 10.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Use a base name with a length equal 5 to test that the limit is applied
base_name = "alice"
for i in range(15):
factories.UserFactory(email=f"{base_name}.{i}@example.com")
response = client.get(
"/api/v1.0/users/?q=alice",
)
assert response.status_code == 200
assert len(response.json()) == 5
# if the limit is changed, all users should be returned
settings.API_USERS_LIST_LIMIT = 100
response = client.get(
"/api/v1.0/users/?q=alice",
)
assert response.status_code == 200
assert len(response.json()) == 15
def test_api_users_list_throttling_authenticated(settings):
"""
Authenticated users should be throttled.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["user_list_burst"] = "3/minute"
for _i in range(3):
response = client.get(
"/api/v1.0/users/?q=alice",
)
assert response.status_code == 200
response = client.get(
"/api/v1.0/users/?q=alice",
)
assert response.status_code == 429
def test_api_users_list_query_email_matching():
"""While filtering by email, results should be filtered and sorted by Levenstein distance."""
user = factories.UserFactory()
@@ -94,13 +146,13 @@ def test_api_users_list_query_email_matching():
"/api/v1.0/users/?q=alice.johnson@example.gouv.fr",
)
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()["results"]]
user_ids = [user["id"] for user in response.json()]
assert user_ids == [str(user1.id), str(user2.id), str(user3.id), str(user4.id)]
response = client.get("/api/v1.0/users/?q=alicia.johnnson@example.gouv.fr")
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()["results"]]
user_ids = [user["id"] for user in response.json()]
assert user_ids == [str(user4.id), str(user2.id), str(user1.id), str(user5.id)]
@@ -126,10 +178,50 @@ def test_api_users_list_query_email_exclude_doc_user():
)
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()["results"]]
user_ids = [user["id"] for user in response.json()]
assert user_ids == [str(nicole_fool.id)]
def test_api_users_list_query_short_queries():
"""
Queries shorter than 5 characters should return an empty result set.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.UserFactory(email="john.doe@example.com")
factories.UserFactory(email="john.lennon@example.com")
response = client.get("/api/v1.0/users/?q=jo")
assert response.status_code == 200
assert response.json() == []
response = client.get("/api/v1.0/users/?q=john")
assert response.status_code == 200
assert response.json() == []
response = client.get("/api/v1.0/users/?q=john.")
assert response.status_code == 200
assert len(response.json()) == 2
def test_api_users_list_query_inactive():
"""Inactive users should not be listed."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.UserFactory(email="john.doe@example.com", is_active=False)
lennon = factories.UserFactory(email="john.lennon@example.com")
response = client.get("/api/v1.0/users/?q=john.")
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()]
assert user_ids == [str(lennon.id)]
def test_api_users_retrieve_me_anonymous():
"""Anonymous users should not be allowed to list users."""
factories.UserFactory.create_batch(2)
@@ -158,6 +250,7 @@ def test_api_users_retrieve_me_authenticated():
"id": str(user.id),
"email": user.email,
"full_name": user.full_name,
"language": user.language,
"short_name": user.short_name,
}

View File

@@ -4,10 +4,8 @@ Test throttling on documents for the AI endpoint.
from unittest.mock import patch
from django.core.cache import cache
from django.test import override_settings
import pytest
from rest_framework.response import Response
from rest_framework.test import APIRequestFactory
from rest_framework.views import APIView
@@ -25,12 +23,6 @@ class DocumentAPIView(APIView):
return Response({"message": "Success"})
@pytest.fixture(autouse=True)
def clear_cache():
"""Fixture to clear the cache before each test."""
cache.clear()
@override_settings(AI_DOCUMENT_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10})
@patch("time.time")
def test_api_utils_ai_document_rate_throttle_minute_limit(mock_time):

View File

@@ -5,7 +5,6 @@ Test throttling on users for the AI endpoint.
from unittest.mock import patch
from uuid import uuid4
from django.core.cache import cache
from django.test import override_settings
import pytest
@@ -29,12 +28,6 @@ class DocumentAPIView(APIView):
return Response({"message": "Success"})
@pytest.fixture(autouse=True)
def clear_cache():
"""Fixture to clear the cache before each test."""
cache.clear()
@override_settings(AI_USER_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10})
@patch("time.time")
def test_api_utils_ai_user_rate_throttle_minute_limit(mock_time):

View File

@@ -0,0 +1,107 @@
"""Unit tests for the nest_tree utility function."""
import pytest
from core.api.utils import nest_tree
def test_api_utils_nest_tree_empty_list():
"""Test that an empty list returns an empty nested structure."""
# pylint: disable=use-implicit-booleaness-not-comparison
assert nest_tree([], 4) is None
def test_api_utils_nest_tree_single_document():
"""Test that a single document is returned as the only root element."""
documents = [{"id": "1", "path": "0001"}]
expected = {"id": "1", "path": "0001", "children": []}
assert nest_tree(documents, 4) == expected
def test_api_utils_nest_tree_multiple_root_documents():
"""Test that multiple root-level documents are correctly added to the root."""
documents = [
{"id": "1", "path": "0001"},
{"id": "2", "path": "0002"},
]
with pytest.raises(
ValueError,
match="More than one root element detected.",
):
nest_tree(documents, 4)
def test_api_utils_nest_tree_nested_structure():
"""Test that documents are correctly nested based on path levels."""
documents = [
{"id": "1", "path": "0001"},
{"id": "2", "path": "00010001"},
{"id": "3", "path": "000100010001"},
{"id": "4", "path": "00010002"},
]
expected = {
"id": "1",
"path": "0001",
"children": [
{
"id": "2",
"path": "00010001",
"children": [{"id": "3", "path": "000100010001", "children": []}],
},
{"id": "4", "path": "00010002", "children": []},
],
}
assert nest_tree(documents, 4) == expected
def test_api_utils_nest_tree_siblings_at_same_path():
"""
Test that sibling documents with the same path are correctly grouped under the same parent.
"""
documents = [
{"id": "1", "path": "0001"},
{"id": "2", "path": "00010001"},
{"id": "3", "path": "00010002"},
]
expected = {
"id": "1",
"path": "0001",
"children": [
{"id": "2", "path": "00010001", "children": []},
{"id": "3", "path": "00010002", "children": []},
],
}
assert nest_tree(documents, 4) == expected
def test_api_utils_nest_tree_decreasing_path_resets_parent():
"""Test that a document at a lower path resets the parent assignment correctly."""
documents = [
{"id": "1", "path": "0001"},
{"id": "6", "path": "00010001"},
{"id": "2", "path": "00010002"}, # unordered
{"id": "5", "path": "000100010001"},
{"id": "3", "path": "000100010002"},
{"id": "4", "path": "00010003"},
]
expected = {
"id": "1",
"path": "0001",
"children": [
{
"id": "6",
"path": "00010001",
"children": [
{"id": "5", "path": "000100010001", "children": []},
{"id": "3", "path": "000100010002", "children": []},
],
},
{
"id": "2",
"path": "00010002",
"children": [],
},
{"id": "4", "path": "00010003", "children": []},
],
}
assert nest_tree(documents, 4) == expected

View File

@@ -7,7 +7,7 @@ from django.core.exceptions import ValidationError
import pytest
from core import factories
from core import factories, models
pytestmark = pytest.mark.django_db
@@ -294,7 +294,7 @@ def test_models_document_access_get_abilities_for_editor_of_owner():
abilities = access.get_abilities(user)
assert abilities == {
"destroy": False,
"retrieve": True,
"retrieve": False,
"update": False,
"partial_update": False,
"set_role_to": [],
@@ -311,7 +311,7 @@ def test_models_document_access_get_abilities_for_editor_of_administrator():
abilities = access.get_abilities(user)
assert abilities == {
"destroy": False,
"retrieve": True,
"retrieve": False,
"update": False,
"partial_update": False,
"set_role_to": [],
@@ -333,7 +333,7 @@ def test_models_document_access_get_abilities_for_editor_of_editor_user(
assert abilities == {
"destroy": False,
"retrieve": True,
"retrieve": False,
"update": False,
"partial_update": False,
"set_role_to": [],
@@ -353,7 +353,7 @@ def test_models_document_access_get_abilities_for_reader_of_owner():
abilities = access.get_abilities(user)
assert abilities == {
"destroy": False,
"retrieve": True,
"retrieve": False,
"update": False,
"partial_update": False,
"set_role_to": [],
@@ -370,7 +370,7 @@ def test_models_document_access_get_abilities_for_reader_of_administrator():
abilities = access.get_abilities(user)
assert abilities == {
"destroy": False,
"retrieve": True,
"retrieve": False,
"update": False,
"partial_update": False,
"set_role_to": [],
@@ -392,7 +392,7 @@ def test_models_document_access_get_abilities_for_reader_of_reader_user(
assert abilities == {
"destroy": False,
"retrieve": True,
"retrieve": False,
"update": False,
"partial_update": False,
"set_role_to": [],
@@ -412,8 +412,16 @@ def test_models_document_access_get_abilities_preset_role(django_assert_num_quer
assert abilities == {
"destroy": False,
"retrieve": True,
"retrieve": False,
"update": False,
"partial_update": False,
"set_role_to": [],
}
@pytest.mark.parametrize("role", models.RoleChoices)
def test_models_document_access_get_abilities_retrieve_own_access(role):
"""Check abilities of self access for the owner of a document."""
access = factories.UserDocumentAccessFactory(role=role)
abilities = access.get_abilities(access.user)
assert abilities["retrieve"] is True

View File

@@ -1,6 +1,7 @@
"""
Unit tests for the Document model
"""
# pylint: disable=too-many-lines
import random
import smtplib
@@ -157,15 +158,24 @@ def test_models_documents_get_abilities_forbidden(
"children_create": False,
"children_list": False,
"collaboration_auth": False,
"descendants": False,
"cors_proxy": False,
"destroy": False,
"duplicate": False,
"favorite": False,
"invite_owner": False,
"media_auth": False,
"move": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"restricted": ["reader", "editor"],
},
"partial_update": False,
"restore": False,
"retrieve": False,
"tree": False,
"update": False,
"versions_destroy": False,
"versions_list": False,
@@ -208,15 +218,24 @@ def test_models_documents_get_abilities_reader(
"children_create": False,
"children_list": True,
"collaboration_auth": True,
"descendants": True,
"cors_proxy": True,
"destroy": False,
"duplicate": True,
"favorite": is_authenticated,
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"restricted": ["reader", "editor"],
},
"media_auth": True,
"move": False,
"partial_update": False,
"restore": False,
"retrieve": True,
"tree": True,
"update": False,
"versions_destroy": False,
"versions_list": False,
@@ -225,9 +244,14 @@ def test_models_documents_get_abilities_reader(
nb_queries = 1 if is_authenticated else 0
with django_assert_num_queries(nb_queries):
assert document.get_abilities(user) == expected_abilities
document.soft_delete()
document.refresh_from_db()
assert all(value is False for value in document.get_abilities(user).values())
assert all(
value is False
for key, value in document.get_abilities(user).items()
if key != "link_select_options"
)
@pytest.mark.parametrize(
@@ -256,15 +280,24 @@ def test_models_documents_get_abilities_editor(
"children_create": is_authenticated,
"children_list": True,
"collaboration_auth": True,
"descendants": True,
"cors_proxy": True,
"destroy": False,
"duplicate": True,
"favorite": is_authenticated,
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"restricted": ["reader", "editor"],
},
"media_auth": True,
"move": False,
"partial_update": True,
"restore": False,
"retrieve": True,
"tree": True,
"update": True,
"versions_destroy": False,
"versions_list": False,
@@ -275,7 +308,11 @@ def test_models_documents_get_abilities_editor(
assert document.get_abilities(user) == expected_abilities
document.soft_delete()
document.refresh_from_db()
assert all(value is False for value in document.get_abilities(user).values())
assert all(
value is False
for key, value in document.get_abilities(user).items()
if key != "link_select_options"
)
@override_settings(
@@ -294,15 +331,24 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
"children_create": True,
"children_list": True,
"collaboration_auth": True,
"descendants": True,
"cors_proxy": True,
"destroy": True,
"duplicate": True,
"favorite": True,
"invite_owner": True,
"link_configuration": True,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"restricted": ["reader", "editor"],
},
"media_auth": True,
"move": True,
"partial_update": True,
"restore": True,
"retrieve": True,
"tree": True,
"update": True,
"versions_destroy": True,
"versions_list": True,
@@ -333,15 +379,24 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries)
"children_create": True,
"children_list": True,
"collaboration_auth": True,
"descendants": True,
"cors_proxy": True,
"destroy": False,
"duplicate": True,
"favorite": True,
"invite_owner": False,
"link_configuration": True,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"restricted": ["reader", "editor"],
},
"media_auth": True,
"move": True,
"partial_update": True,
"restore": False,
"retrieve": True,
"tree": True,
"update": True,
"versions_destroy": True,
"versions_list": True,
@@ -352,7 +407,11 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries)
document.soft_delete()
document.refresh_from_db()
assert all(value is False for value in document.get_abilities(user).values())
assert all(
value is False
for key, value in document.get_abilities(user).items()
if key != "link_select_options"
)
@override_settings(
@@ -371,15 +430,24 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
"children_create": True,
"children_list": True,
"collaboration_auth": True,
"descendants": True,
"cors_proxy": True,
"destroy": False,
"duplicate": True,
"favorite": True,
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"restricted": ["reader", "editor"],
},
"media_auth": True,
"move": False,
"partial_update": True,
"restore": False,
"retrieve": True,
"tree": True,
"update": True,
"versions_destroy": False,
"versions_list": True,
@@ -390,7 +458,11 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
document.soft_delete()
document.refresh_from_db()
assert all(value is False for value in document.get_abilities(user).values())
assert all(
value is False
for key, value in document.get_abilities(user).items()
if key != "link_select_options"
)
@pytest.mark.parametrize("ai_access_setting", ["public", "authenticated", "restricted"])
@@ -416,15 +488,24 @@ def test_models_documents_get_abilities_reader_user(
"children_create": access_from_link,
"children_list": True,
"collaboration_auth": True,
"descendants": True,
"cors_proxy": True,
"destroy": False,
"duplicate": True,
"favorite": True,
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"restricted": ["reader", "editor"],
},
"media_auth": True,
"move": False,
"partial_update": access_from_link,
"restore": False,
"retrieve": True,
"tree": True,
"update": access_from_link,
"versions_destroy": False,
"versions_list": True,
@@ -437,7 +518,11 @@ def test_models_documents_get_abilities_reader_user(
document.soft_delete()
document.refresh_from_db()
assert all(value is False for value in document.get_abilities(user).values())
assert all(
value is False
for key, value in document.get_abilities(user).items()
if key != "link_select_options"
)
def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
@@ -459,15 +544,24 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
"children_create": False,
"children_list": True,
"collaboration_auth": True,
"descendants": True,
"cors_proxy": True,
"destroy": False,
"duplicate": True,
"favorite": True,
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"restricted": ["reader", "editor"],
},
"media_auth": True,
"move": False,
"partial_update": False,
"restore": False,
"retrieve": True,
"tree": True,
"update": False,
"versions_destroy": False,
"versions_list": True,
@@ -636,6 +730,37 @@ def test_models_documents__email_invitation__success():
assert f"docs/{document.id}/" in email_content
def test_models_documents__email_invitation__success_empty_title():
"""
The email invitation is sent successfully.
"""
document = factories.DocumentFactory(title=None)
# pylint: disable-next=no-member
assert len(mail.outbox) == 0
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
assert len(mail.outbox) == 1
# pylint: disable-next=no-member
email = mail.outbox[0]
assert email.to == ["guest@example.com"]
email_content = " ".join(email.body.split())
assert "Test sender shared a document with you!" in email.subject
assert (
"Test Sender (sender@example.com) invited you with the role &quot;editor&quot; "
"on the following document: Untitled Document" in email_content
)
assert f"docs/{document.id}/" in email_content
def test_models_documents__email_invitation__success_fr():
"""
The email invitation is sent successfully in french.
@@ -666,7 +791,7 @@ def test_models_documents__email_invitation__success_fr():
assert (
f"Test Sender2 (sender2@example.com) vous a invité avec le rôle &quot;propriétaire&quot; "
f"sur le document suivant: {document.title}" in email_content
f"sur le document suivant : {document.title}" in email_content
)
assert f"docs/{document.id}/" in email_content
@@ -711,40 +836,89 @@ def test_models_documents__email_invitation__failed(mock_logger, _mock_send_mail
# Document number of accesses
def test_models_documents_nb_accesses_cache_is_set_and_retrieved(
def test_models_documents_nb_accesses_cache_is_set_and_retrieved_ancestors(
django_assert_num_queries,
):
"""Test that nb_accesses is cached after the first computation."""
document = factories.DocumentFactory()
"""Test that nb_accesses is cached when calling nb_accesses_ancestors."""
parent = factories.DocumentFactory()
document = factories.DocumentFactory(parent=parent)
key = f"document_{document.id!s}_nb_accesses"
nb_accesses = random.randint(1, 4)
factories.UserDocumentAccessFactory.create_batch(nb_accesses, document=document)
nb_accesses_parent = random.randint(1, 4)
factories.UserDocumentAccessFactory.create_batch(
nb_accesses_parent, document=parent
)
nb_accesses_direct = random.randint(1, 4)
factories.UserDocumentAccessFactory.create_batch(
nb_accesses_direct, document=document
)
factories.UserDocumentAccessFactory() # An unrelated access should not be counted
# Initially, the nb_accesses should not be cached
assert cache.get(key) is None
# Compute the nb_accesses for the first time (this should set the cache)
with django_assert_num_queries(1):
assert document.nb_accesses == nb_accesses
nb_accesses_ancestors = nb_accesses_parent + nb_accesses_direct
with django_assert_num_queries(2):
assert document.nb_accesses_ancestors == nb_accesses_ancestors
# Ensure that the nb_accesses is now cached
with django_assert_num_queries(0):
assert document.nb_accesses == nb_accesses
assert cache.get(key) == nb_accesses
assert document.nb_accesses_ancestors == nb_accesses_ancestors
assert cache.get(key) == (nb_accesses_direct, nb_accesses_ancestors)
# The cache value should be invalidated when a document access is created
models.DocumentAccess.objects.create(
document=document, user=factories.UserFactory(), role="reader"
)
assert cache.get(key) is None # Cache should be invalidated
with django_assert_num_queries(1):
new_nb_accesses = document.nb_accesses
assert new_nb_accesses == nb_accesses + 1
assert cache.get(key) == new_nb_accesses # Cache should now contain the new value
with django_assert_num_queries(2):
assert document.nb_accesses_ancestors == nb_accesses_ancestors + 1
assert cache.get(key) == (nb_accesses_direct + 1, nb_accesses_ancestors + 1)
def test_models_documents_nb_accesses_cache_is_set_and_retrieved_direct(
django_assert_num_queries,
):
"""Test that nb_accesses is cached when calling nb_accesses_direct."""
parent = factories.DocumentFactory()
document = factories.DocumentFactory(parent=parent)
key = f"document_{document.id!s}_nb_accesses"
nb_accesses_parent = random.randint(1, 4)
factories.UserDocumentAccessFactory.create_batch(
nb_accesses_parent, document=parent
)
nb_accesses_direct = random.randint(1, 4)
factories.UserDocumentAccessFactory.create_batch(
nb_accesses_direct, document=document
)
factories.UserDocumentAccessFactory() # An unrelated access should not be counted
# Initially, the nb_accesses should not be cached
assert cache.get(key) is None
# Compute the nb_accesses for the first time (this should set the cache)
nb_accesses_ancestors = nb_accesses_parent + nb_accesses_direct
with django_assert_num_queries(2):
assert document.nb_accesses_direct == nb_accesses_direct
# Ensure that the nb_accesses is now cached
with django_assert_num_queries(0):
assert document.nb_accesses_direct == nb_accesses_direct
assert cache.get(key) == (nb_accesses_direct, nb_accesses_ancestors)
# The cache value should be invalidated when a document access is created
models.DocumentAccess.objects.create(
document=document, user=factories.UserFactory(), role="reader"
)
assert cache.get(key) is None # Cache should be invalidated
with django_assert_num_queries(2):
assert document.nb_accesses_direct == nb_accesses_direct + 1
assert cache.get(key) == (nb_accesses_direct + 1, nb_accesses_ancestors + 1)
@pytest.mark.parametrize("field", ["nb_accesses_ancestors", "nb_accesses_direct"])
def test_models_documents_nb_accesses_cache_is_invalidated_on_access_removal(
field,
django_assert_num_queries,
):
"""Test that the cache is invalidated when a document access is deleted."""
@@ -753,15 +927,425 @@ def test_models_documents_nb_accesses_cache_is_invalidated_on_access_removal(
access = factories.UserDocumentAccessFactory(document=document)
# Initially, the nb_accesses should be cached
assert document.nb_accesses == 1
assert cache.get(key) == 1
assert getattr(document, field) == 1
assert cache.get(key) == (1, 1)
# Remove the access and check if cache is invalidated
access.delete()
assert cache.get(key) is None # Cache should be invalidated
# Recompute the nb_accesses (this should trigger a cache set)
with django_assert_num_queries(1):
new_nb_accesses = document.nb_accesses
with django_assert_num_queries(2):
new_nb_accesses = getattr(document, field)
assert new_nb_accesses == 0
assert cache.get(key) == 0 # Cache should now contain the new value
assert cache.get(key) == (0, 0) # Cache should now contain the new value
@pytest.mark.parametrize("field", ["nb_accesses_ancestors", "nb_accesses_direct"])
def test_models_documents_nb_accesses_cache_is_invalidated_on_document_soft_delete_restore(
field,
django_assert_num_queries,
):
"""Test that the cache is invalidated when a document access is deleted."""
document = factories.DocumentFactory()
key = f"document_{document.id!s}_nb_accesses"
factories.UserDocumentAccessFactory(document=document)
# Initially, the nb_accesses should be cached
assert getattr(document, field) == 1
assert cache.get(key) == (1, 1)
# Soft delete the document and check if cache is invalidated
document.soft_delete()
assert cache.get(key) is None # Cache should be invalidated
# Recompute the nb_accesses (this should trigger a cache set)
with django_assert_num_queries(2):
new_nb_accesses = getattr(document, field)
assert new_nb_accesses == (1 if field == "nb_accesses_direct" else 0)
assert cache.get(key) == (1, 0) # Cache should now contain the new value
document.restore()
# Recompute the nb_accesses (this should trigger a cache set)
with django_assert_num_queries(2):
new_nb_accesses = getattr(document, field)
assert new_nb_accesses == 1
assert cache.get(key) == (1, 1) # Cache should now contain the new value
def test_models_documents_numchild_deleted_from_instance():
"""the "numchild" field should not include documents deleted from the instance."""
document = factories.DocumentFactory()
child1, _child2 = factories.DocumentFactory.create_batch(2, parent=document)
assert document.numchild == 2
child1.delete()
document.refresh_from_db()
assert document.numchild == 1
def test_models_documents_numchild_deleted_from_queryset():
"""the "numchild" field should not include documents deleted from a queryset."""
document = factories.DocumentFactory()
child1, _child2 = factories.DocumentFactory.create_batch(2, parent=document)
assert document.numchild == 2
models.Document.objects.filter(pk=child1.pk).delete()
document.refresh_from_db()
assert document.numchild == 1
def test_models_documents_numchild_soft_deleted_and_restore():
"""the "numchild" field should not include soft deleted documents."""
document = factories.DocumentFactory()
child1, _child2 = factories.DocumentFactory.create_batch(2, parent=document)
assert document.numchild == 2
child1.soft_delete()
document.refresh_from_db()
assert document.numchild == 1
child1.restore()
document.refresh_from_db()
assert document.numchild == 2
def test_models_documents_soft_delete_tempering_with_instance():
"""
Soft deleting should fail if the document is already deleted in database even though the
instance "deleted_at" attributes where tempered with.
"""
document = factories.DocumentFactory()
document.soft_delete()
document.deleted_at = None
document.ancestors_deleted_at = None
with pytest.raises(
RuntimeError, match="This document is already deleted or has deleted ancestors."
):
document.soft_delete()
def test_models_documents_restore_tempering_with_instance():
"""
Soft deleting should fail if the document is already deleted in database even though the
instance "deleted_at" attributes where tempered with.
"""
document = factories.DocumentFactory()
if random.choice([False, True]):
document.deleted_at = timezone.now()
else:
document.ancestors_deleted_at = timezone.now()
with pytest.raises(RuntimeError, match="This document is not deleted."):
document.restore()
def test_models_documents_restore(django_assert_num_queries):
"""The restore method should restore a soft-deleted document."""
document = factories.DocumentFactory()
document.soft_delete()
document.refresh_from_db()
assert document.deleted_at is not None
assert document.ancestors_deleted_at == document.deleted_at
with django_assert_num_queries(8):
document.restore()
document.refresh_from_db()
assert document.deleted_at is None
assert document.ancestors_deleted_at == document.deleted_at
def test_models_documents_restore_complex(django_assert_num_queries):
"""The restore method should restore a soft-deleted document and its ancestors."""
grand_parent = factories.DocumentFactory()
parent = factories.DocumentFactory(parent=grand_parent)
document = factories.DocumentFactory(parent=parent)
child1 = factories.DocumentFactory(parent=document)
child2 = factories.DocumentFactory(parent=document)
# Soft delete first the document
document.soft_delete()
document.refresh_from_db()
child1.refresh_from_db()
child2.refresh_from_db()
assert document.deleted_at is not None
assert document.ancestors_deleted_at == document.deleted_at
assert child1.ancestors_deleted_at == document.deleted_at
assert child2.ancestors_deleted_at == document.deleted_at
# Soft delete the grand parent
grand_parent.soft_delete()
grand_parent.refresh_from_db()
parent.refresh_from_db()
assert grand_parent.deleted_at is not None
assert grand_parent.ancestors_deleted_at == grand_parent.deleted_at
assert parent.ancestors_deleted_at == grand_parent.deleted_at
# item, child1 and child2 should not be affected
document.refresh_from_db()
child1.refresh_from_db()
child2.refresh_from_db()
assert document.deleted_at is not None
assert document.ancestors_deleted_at == document.deleted_at
assert child1.ancestors_deleted_at == document.deleted_at
assert child2.ancestors_deleted_at == document.deleted_at
# Restore the item
with django_assert_num_queries(11):
document.restore()
document.refresh_from_db()
child1.refresh_from_db()
child2.refresh_from_db()
grand_parent.refresh_from_db()
assert document.deleted_at is None
assert document.ancestors_deleted_at == grand_parent.deleted_at
# child 1 and child 2 should now have the same ancestors_deleted_at as the grand parent
assert child1.ancestors_deleted_at == grand_parent.deleted_at
assert child2.ancestors_deleted_at == grand_parent.deleted_at
def test_models_documents_restore_complex_bis(django_assert_num_queries):
"""The restore method should restore a soft-deleted item and its ancestors."""
grand_parent = factories.DocumentFactory()
parent = factories.DocumentFactory(parent=grand_parent)
document = factories.DocumentFactory(parent=parent)
child1 = factories.DocumentFactory(parent=document)
child2 = factories.DocumentFactory(parent=document)
# Soft delete first the document
document.soft_delete()
document.refresh_from_db()
child1.refresh_from_db()
child2.refresh_from_db()
assert document.deleted_at is not None
assert document.ancestors_deleted_at == document.deleted_at
assert child1.ancestors_deleted_at == document.deleted_at
assert child2.ancestors_deleted_at == document.deleted_at
# Soft delete the grand parent
grand_parent.soft_delete()
grand_parent.refresh_from_db()
parent.refresh_from_db()
assert grand_parent.deleted_at is not None
assert grand_parent.ancestors_deleted_at == grand_parent.deleted_at
assert parent.ancestors_deleted_at == grand_parent.deleted_at
# item, child1 and child2 should not be affected
document.refresh_from_db()
child1.refresh_from_db()
child2.refresh_from_db()
assert document.deleted_at is not None
assert document.ancestors_deleted_at == document.deleted_at
assert child1.ancestors_deleted_at == document.deleted_at
assert child2.ancestors_deleted_at == document.deleted_at
# Restoring the grand parent should not restore the document
# as it was deleted before the grand parent
with django_assert_num_queries(9):
grand_parent.restore()
grand_parent.refresh_from_db()
parent.refresh_from_db()
document.refresh_from_db()
child1.refresh_from_db()
child2.refresh_from_db()
assert grand_parent.deleted_at is None
assert grand_parent.ancestors_deleted_at is None
assert parent.deleted_at is None
assert parent.ancestors_deleted_at is None
assert document.deleted_at is not None
assert document.ancestors_deleted_at == document.deleted_at
assert child1.ancestors_deleted_at == document.deleted_at
assert child2.ancestors_deleted_at == document.deleted_at
@pytest.mark.parametrize(
"ancestors_links, select_options",
[
# One ancestor
(
[{"link_reach": "public", "link_role": "reader"}],
{
"restricted": ["editor"],
"authenticated": ["editor"],
"public": ["reader", "editor"],
},
),
([{"link_reach": "public", "link_role": "editor"}], {"public": ["editor"]}),
(
[{"link_reach": "authenticated", "link_role": "reader"}],
{
"restricted": ["editor"],
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
},
),
(
[{"link_reach": "authenticated", "link_role": "editor"}],
{"authenticated": ["editor"], "public": ["reader", "editor"]},
),
(
[{"link_reach": "restricted", "link_role": "reader"}],
{
"restricted": ["reader", "editor"],
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
},
),
(
[{"link_reach": "restricted", "link_role": "editor"}],
{
"restricted": ["editor"],
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
},
),
# Multiple ancestors with different roles
(
[
{"link_reach": "public", "link_role": "reader"},
{"link_reach": "public", "link_role": "editor"},
],
{"public": ["editor"]},
),
(
[
{"link_reach": "authenticated", "link_role": "reader"},
{"link_reach": "authenticated", "link_role": "editor"},
],
{"authenticated": ["editor"], "public": ["reader", "editor"]},
),
(
[
{"link_reach": "restricted", "link_role": "reader"},
{"link_reach": "restricted", "link_role": "editor"},
],
{
"restricted": ["editor"],
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
},
),
# Multiple ancestors with different reaches
(
[
{"link_reach": "authenticated", "link_role": "reader"},
{"link_reach": "public", "link_role": "reader"},
],
{
"restricted": ["editor"],
"authenticated": ["editor"],
"public": ["reader", "editor"],
},
),
(
[
{"link_reach": "restricted", "link_role": "reader"},
{"link_reach": "authenticated", "link_role": "reader"},
{"link_reach": "public", "link_role": "reader"},
],
{
"restricted": ["editor"],
"authenticated": ["editor"],
"public": ["reader", "editor"],
},
),
# Multiple ancestors with mixed reaches and roles
(
[
{"link_reach": "authenticated", "link_role": "editor"},
{"link_reach": "public", "link_role": "reader"},
],
{"authenticated": ["editor"], "public": ["reader", "editor"]},
),
(
[
{"link_reach": "authenticated", "link_role": "reader"},
{"link_reach": "public", "link_role": "editor"},
],
{"public": ["editor"]},
),
(
[
{"link_reach": "restricted", "link_role": "editor"},
{"link_reach": "authenticated", "link_role": "reader"},
],
{
"restricted": ["editor"],
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
},
),
(
[
{"link_reach": "restricted", "link_role": "reader"},
{"link_reach": "authenticated", "link_role": "editor"},
],
{"authenticated": ["editor"], "public": ["reader", "editor"]},
),
# No ancestors (edge case)
(
[],
{
"public": ["reader", "editor"],
"authenticated": ["reader", "editor"],
"restricted": ["reader", "editor"],
},
),
],
)
def test_models_documents_get_select_options(ancestors_links, select_options):
"""Validate that the "get_select_options" method operates as expected."""
assert models.LinkReachChoices.get_select_options(ancestors_links) == select_options
def test_models_documents_compute_ancestors_links_no_highest_readable():
"""Test the compute_ancestors_links method."""
document = factories.DocumentFactory(link_reach="public")
assert document.compute_ancestors_links(user=AnonymousUser()) == []
def test_models_documents_compute_ancestors_links_highest_readable(
django_assert_num_queries,
):
"""Test the compute_ancestors_links method."""
user = factories.UserFactory()
other_user = factories.UserFactory()
root = factories.DocumentFactory(
link_reach="restricted", link_role="reader", users=[user]
)
factories.DocumentFactory(
parent=root, link_reach="public", link_role="reader", users=[user]
)
child2 = factories.DocumentFactory(
parent=root,
link_reach="authenticated",
link_role="editor",
users=[user, other_user],
)
child3 = factories.DocumentFactory(
parent=child2,
link_reach="authenticated",
link_role="reader",
users=[user, other_user],
)
with django_assert_num_queries(2):
assert child3.compute_ancestors_links(user=user) == [
{"link_reach": root.link_reach, "link_role": root.link_role},
{"link_reach": child2.link_reach, "link_role": child2.link_role},
]
with django_assert_num_queries(2):
assert child3.compute_ancestors_links(user=other_user) == [
{"link_reach": child2.link_reach, "link_role": child2.link_role},
]

View File

@@ -2,7 +2,6 @@
Test ai API endpoints in the impress core app.
"""
import json
from unittest.mock import MagicMock, patch
from django.core.exceptions import ImproperlyConfigured
@@ -58,9 +57,8 @@ def test_api_ai__client_error(mock_create):
def test_api_ai__client_invalid_response(mock_create):
"""Fail when the client response is invalid"""
answer = {"no_answer": "This is an invalid response"}
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=json.dumps(answer)))]
choices=[MagicMock(message=MagicMock(content=None))]
)
with pytest.raises(
@@ -77,49 +75,10 @@ def test_api_ai__client_invalid_response(mock_create):
def test_api_ai__success(mock_create):
"""The AI request should work as expect when called with valid arguments."""
answer = '{"answer": "Salut"}'
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=answer))]
choices=[MagicMock(message=MagicMock(content="Salut"))]
)
response = AIService().transform("hello", "prompt")
assert response == {"answer": "Salut"}
@override_settings(
AI_BASE_URL="http://example.com", AI_API_KEY="test-key", AI_MODEL="test-model"
)
@patch("openai.resources.chat.completions.Completions.create")
def test_api_ai__success_sanitize(mock_create):
"""The AI response should be sanitized"""
answer = '{"answer": "Salut\\n \tle \nmonde"}'
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=answer))]
)
response = AIService().transform("hello", "prompt")
assert response == {"answer": "Salut\n \tle \nmonde"}
@override_settings(
AI_BASE_URL="http://example.com", AI_API_KEY="test-key", AI_MODEL="test-model"
)
@patch("openai.resources.chat.completions.Completions.create")
def test_api_ai__success_when_sanitize_fails(mock_create):
"""The AI request should work as expected even with badly formatted response."""
# pylint: disable=C0303
answer = """{
"answer" :
"Salut le monde"
}"""
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=answer))]
)
response = AIService().transform("hello", "prompt")
assert response == {"answer": "Salut le monde"}

View File

@@ -0,0 +1,77 @@
"""Test util base64_yjs_to_text."""
import base64
import uuid
import pycrdt
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
# the text \n# *Hello* \n- w**or**ld
TEST_BASE64_STRING = (
"AR717vLVDgAHAQ5kb2N1bWVudC1zdG9yZQMKYmxvY2tHcm91cAcA9e7y1Q4AAw5ibG9ja0NvbnRh"
"aW5lcgcA9e7y1Q4BAwdoZWFkaW5nBwD17vLVDgIGBgD17vLVDgMGaXRhbGljAnt9hPXu8tUOBAVI"
"ZWxsb4b17vLVDgkGaXRhbGljBG51bGwoAPXu8tUOAg10ZXh0QWxpZ25tZW50AXcEbGVmdCgA9e7y"
"1Q4CBWxldmVsAX0BKAD17vLVDgECaWQBdyQwNGQ2MjM0MS04MzI2LTQyMzYtYTA4My00ODdlMjZm"
"YWQyMzAoAPXu8tUOAQl0ZXh0Q29sb3IBdwdkZWZhdWx0KAD17vLVDgEPYmFja2dyb3VuZENvbG9y"
"AXcHZGVmYXVsdIf17vLVDgEDDmJsb2NrQ29udGFpbmVyBwD17vLVDhADDmJ1bGxldExpc3RJdGVt"
"BwD17vLVDhEGBAD17vLVDhIBd4b17vLVDhMEYm9sZAJ7fYT17vLVDhQCb3KG9e7y1Q4WBGJvbGQE"
"bnVsbIT17vLVDhcCbGQoAPXu8tUOEQ10ZXh0QWxpZ25tZW50AXcEbGVmdCgA9e7y1Q4QAmlkAXck"
"ZDM1MWUwNjgtM2U1NS00MjI2LThlYTUtYWJiMjYzMTk4ZTJhKAD17vLVDhAJdGV4dENvbG9yAXcH"
"ZGVmYXVsdCgA9e7y1Q4QD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHSH9e7y1Q4QAw5ibG9ja0Nv"
"bnRhaW5lcgcA9e7y1Q4eAwlwYXJhZ3JhcGgoAPXu8tUOHw10ZXh0QWxpZ25tZW50AXcEbGVmdCgA"
"9e7y1Q4eAmlkAXckODk3MDBjMDctZTBlMS00ZmUwLWFjYTItODQ5MzIwOWE3ZTQyKAD17vLVDh4J"
"dGV4dENvbG9yAXcHZGVmYXVsdCgA9e7y1Q4eD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQA"
)
def test_utils_base64_yjs_to_text():
"""Test extract text from saved yjs document"""
assert utils.base64_yjs_to_text(TEST_BASE64_STRING) == "Hello w or ld"
def test_utils_base64_yjs_to_xml():
"""Test extract xml from saved yjs document"""
content = utils.base64_yjs_to_xml(TEST_BASE64_STRING)
assert (
'<heading textAlignment="left" level="1"><italic>Hello</italic></heading>'
in content
or '<heading level="1" textAlignment="left"><italic>Hello</italic></heading>'
in content
)
assert (
'<bulletListItem textAlignment="left">w<bold>or</bold>ld</bulletListItem>'
in content
)
def test_utils_extract_attachments():
"""
All attachment keys in the document content should be extracted.
"""
document_id = uuid.uuid4()
image_key1 = f"{document_id!s}/attachments/{uuid.uuid4()!s}.png"
image_url1 = f"http://localhost/media/{image_key1:s}"
image_key2 = f"{uuid.uuid4()!s}/attachments/{uuid.uuid4()!s}.png"
image_url2 = f"http://localhost/{image_key2:s}"
image_key3 = f"{uuid.uuid4()!s}/attachments/{uuid.uuid4()!s}.png"
image_url3 = f"http://localhost/media/{image_key3:s}"
ydoc = pycrdt.Doc()
frag = pycrdt.XmlFragment(
[
pycrdt.XmlElement("img", {"src": image_url1}),
pycrdt.XmlElement("img", {"src": image_url2}),
pycrdt.XmlElement("p", {}, [pycrdt.XmlText(image_url3)]),
]
)
ydoc["document-store"] = frag
update = ydoc.get_update()
base64_string = base64.b64encode(update).decode("utf-8")
# image_key2 is missing the "/media/" part and shouldn't get extracted
assert utils.extract_attachments(base64_string) == [image_key1, image_key3]

View File

@@ -0,0 +1,163 @@
"""
Unit tests for the filter_root_paths utility function.
"""
from core.utils import filter_descendants
def test_utils_filter_descendants_success():
"""
The `filter_descendants` function should correctly identify descendant paths
from a given list of paths and root paths.
This test verifies that the function returns only the paths that have a prefix
matching one of the root paths.
"""
paths = [
"0001",
"00010001",
"000100010001",
"000100010002",
"000100020001",
"000100020002",
"0002",
"00020001",
"00020002",
"00030001",
"000300010001",
"00030002",
"0004",
"000400010003",
"0004000100030001",
"000400010004",
]
root_paths = [
"0001",
"0002",
"000400010003",
]
filtered_paths = filter_descendants(paths, root_paths, skip_sorting=True)
assert filtered_paths == [
"0001",
"00010001",
"000100010001",
"000100010002",
"000100020001",
"000100020002",
"0002",
"00020001",
"00020002",
"000400010003",
"0004000100030001",
]
def test_utils_filter_descendants_sorting():
"""
The `filter_descendants` function should handle unsorted input when sorting is enabled.
This test verifies that the function sorts the input if sorting is not skipped
and still correctly identifies accessible descendant paths.
"""
paths = [
"000300010001",
"000100010002",
"0001",
"00010001",
"000100010001",
"000100020002",
"000100020001",
"0002",
"00020001",
"00020002",
"00030001",
"00030002",
"0004000100030001",
"0004",
"000400010003",
"000400010004",
]
root_paths = [
"0002",
"000400010003",
"0001",
]
filtered_paths = filter_descendants(paths, root_paths)
assert filtered_paths == [
"0001",
"00010001",
"000100010001",
"000100010002",
"000100020001",
"000100020002",
"0002",
"00020001",
"00020002",
"000400010003",
"0004000100030001",
]
filtered_paths = filter_descendants(paths, root_paths, skip_sorting=True)
assert filtered_paths == [
"0001",
"00010001",
"000100010001",
"000100010002",
"000100020001",
"000100020002",
"0002",
"00020001",
"00020002",
"000400010003",
"0004000100030001",
]
def test_utils_filter_descendants_empty():
"""
The function should return an empty list if one or both inputs are empty.
"""
assert not filter_descendants([], ["0001"])
assert not filter_descendants(["0001"], [])
assert not filter_descendants([], [])
def test_utils_filter_descendants_no_match():
"""
The function should return an empty list if no path starts with any root path.
"""
paths = ["0001", "0002", "0003"]
root_paths = ["0004", "0005"]
assert not filter_descendants(paths, root_paths, skip_sorting=True)
def test_utils_filter_descendants_exact_match():
"""
The function should include paths that exactly match a root path.
"""
paths = ["0001", "0002", "0003"]
root_paths = ["0001", "0002"]
assert filter_descendants(paths, root_paths, skip_sorting=True) == ["0001", "0002"]
def test_utils_filter_descendants_single_root_matches_all():
"""
A single root path should match all its descendants.
"""
paths = ["0001", "00010001", "000100010001", "00010002"]
root_paths = ["0001"]
assert filter_descendants(paths, root_paths) == [
"0001",
"00010001",
"000100010001",
"00010002",
]
def test_utils_filter_descendants_path_shorter_than_root():
"""
A path shorter than any root path should not match.
"""
paths = ["0001", "0002"]
root_paths = ["00010001"]
assert not filter_descendants(paths, root_paths)

View File

@@ -3,10 +3,10 @@
from django.conf import settings
from django.urls import include, path, re_path
from lasuite.oidc_login.urls import urlpatterns as oidc_urls
from rest_framework.routers import DefaultRouter
from core.api import viewsets
from core.authentication.urls import urlpatterns as oidc_urls
# - Main endpoints
router = DefaultRouter()
@@ -56,4 +56,5 @@ urlpatterns = [
),
),
path(f"api/{settings.API_VERSION}/config/", viewsets.ConfigView.as_view()),
path(f"api/{settings.API_VERSION}/footer/", viewsets.FooterView.as_view()),
]

76
src/backend/core/utils.py Normal file
View File

@@ -0,0 +1,76 @@
"""Utils for the core app."""
import base64
import re
import pycrdt
from bs4 import BeautifulSoup
from core import enums
def filter_descendants(paths, root_paths, skip_sorting=False):
"""
Filters paths to keep only those that are descendants of any path in root_paths.
A path is considered a descendant of a root path if it starts with the root path.
If `skip_sorting` is not set to True, the function will sort both lists before
processing because both `paths` and `root_paths` need to be in lexicographic order
before going through the algorithm.
Args:
paths (iterable of str): List of paths to be filtered.
root_paths (iterable of str): List of paths to check as potential prefixes.
skip_sorting (bool): If True, assumes both `paths` and `root_paths` are already sorted.
Returns:
list of str: A list of sorted paths that are descendants of any path in `root_paths`.
"""
results = []
i = 0
n = len(root_paths)
if not skip_sorting:
paths.sort()
root_paths.sort()
for path in paths:
# Try to find a matching prefix in the sorted accessible paths
while i < n:
if path.startswith(root_paths[i]):
results.append(path)
break
if root_paths[i] < path:
i += 1
else:
# If paths[i] > path, no need to keep searching
break
return results
def base64_yjs_to_xml(base64_string):
"""Extract xml from base64 yjs document."""
decoded_bytes = base64.b64decode(base64_string)
# uint8_array = bytearray(decoded_bytes)
doc = pycrdt.Doc()
doc.apply_update(decoded_bytes)
return str(doc.get("document-store", type=pycrdt.XmlFragment))
def base64_yjs_to_text(base64_string):
"""Extract text from base64 yjs document."""
blocknote_structure = base64_yjs_to_xml(base64_string)
soup = BeautifulSoup(blocknote_structure, "lxml-xml")
return soup.get_text(separator=" ", strip=True)
def extract_attachments(content):
"""Helper method to extract media paths from a document's content."""
if not content:
return []
xml_content = base64_yjs_to_xml(content)
return re.findall(enums.MEDIA_STORAGE_URL_EXTRACT, xml_content)

View File

@@ -1,2 +1,2 @@
<img width="200" src="https://impress-staging.beta.numerique.gouv.fr/assets/logo-gouv.png" />
<img width="200" src="http://localhost:3000/assets/logo-gouv.png" />
<br/>

View File

@@ -7,17 +7,12 @@ NB_OBJECTS = {
}
DEV_USERS = [
{"username": "impress", "email": "impress@impress.world", "language": "en-us"},
{"username": "user-e2e-webkit", "email": "user@webkit.e2e", "language": "en-us"},
{"username": "user-e2e-firefox", "email": "user@firefox.e2e", "language": "en-us"},
{
"username": "impress",
"email": "impress@impress.world",
"username": "user-e2e-chromium",
"email": "user@chromium.e2e",
"language": "en-us",
},
{
"username": "user-e2e-webkit",
"email": "user@webkit.e2e",
},
{
"username": "user-e2e-firefox",
"email": "user@firefox.e2e",
},
{"username": "user-e2e-chromium", "email": "user@chromium.e2e"},
]

View File

@@ -179,7 +179,8 @@ def create_demo(stdout):
is_superuser=False,
is_active=True,
is_staff=False,
language=random.choice(settings.LANGUAGES)[0],
language=dev_user["language"]
or random.choice(settings.LANGUAGES)[0],
)
)

View File

@@ -19,10 +19,11 @@ from django.utils.translation import gettext_lazy as _
import sentry_sdk
from configurations import Configuration, values
from sentry_sdk.integrations.django import DjangoIntegration
from sentry_sdk.integrations.logging import ignore_logger
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DATA_DIR = os.path.join("/", "data")
DATA_DIR = os.getenv("DATA_DIR", os.path.join("/", "data"))
def get_release():
@@ -210,7 +211,6 @@ class Base(Configuration):
"application/x-ms-regedit",
"application/x-msdownload",
"application/xml",
"image/svg+xml",
]
# Document versions
@@ -221,7 +221,9 @@ class Base(Configuration):
# Languages
LANGUAGE_CODE = values.Value("en-us")
LANGUAGE_COOKIE_NAME = "docs_language" # cookie & language is set from frontend
# cookie & language is set from frontend
LANGUAGE_COOKIE_NAME = "docs_language"
LANGUAGE_COOKIE_PATH = "/"
DRF_NESTED_MULTIPART_PARSER = {
# output of parser is converted to querydict
@@ -233,9 +235,11 @@ class Base(Configuration):
# fallback/default languages throughout the app.
LANGUAGES = values.SingleNestedTupleValue(
(
("en-us", _("English")),
("fr-fr", _("French")),
("de-de", _("German")),
("en-us", "English"),
("fr-fr", "Français"),
("de-de", "Deutsch"),
("nl-nl", "Nederlands"),
("es-es", "Español"),
)
)
@@ -329,11 +333,29 @@ class Base(Configuration):
"rest_framework.parsers.JSONParser",
"nested_multipart_parser.drf.DrfNestedParser",
],
"DEFAULT_RENDERER_CLASSES": [
# 🔒️ Disable BrowsableAPIRenderer which provides forms allowing a user to
# see all the data in the database (ie a serializer with a ForeignKey field
# will generate a form with a field with all possible values of the FK).
"rest_framework.renderers.JSONRenderer",
],
"EXCEPTION_HANDLER": "core.api.exception_handler",
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
"PAGE_SIZE": 20,
"DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.URLPathVersioning",
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
"DEFAULT_THROTTLE_RATES": {
"user_list_sustained": values.Value(
default="180/hour",
environ_name="API_USERS_LIST_THROTTLE_RATE_SUSTAINED",
environ_prefix=None,
),
"user_list_burst": values.Value(
default="30/minute",
environ_name="API_USERS_LIST_THROTTLE_RATE_BURST",
environ_prefix=None,
),
},
}
SPECTACULAR_SETTINGS = {
@@ -395,6 +417,27 @@ class Base(Configuration):
FRONTEND_THEME = values.Value(
None, environ_name="FRONTEND_THEME", environ_prefix=None
)
FRONTEND_HOMEPAGE_FEATURE_ENABLED = values.BooleanValue(
default=False,
environ_name="FRONTEND_HOMEPAGE_FEATURE_ENABLED",
environ_prefix=None,
)
FRONTEND_URL_JSON_FOOTER = values.Value(
None, environ_name="FRONTEND_URL_JSON_FOOTER", environ_prefix=None
)
FRONTEND_FOOTER_FEATURE_ENABLED = values.BooleanValue(
default=False,
environ_name="FRONTEND_FOOTER_FEATURE_ENABLED",
environ_prefix=None,
)
FRONTEND_FOOTER_VIEW_CACHE_TIMEOUT = values.Value(
60 * 60 * 24,
environ_name="FRONTEND_FOOTER_VIEW_CACHE_TIMEOUT",
environ_prefix=None,
)
FRONTEND_CSS_URL = values.Value(
None, environ_name="FRONTEND_CSS_URL", environ_prefix=None
)
# Posthog
POSTHOG_KEY = values.DictValue(
@@ -484,6 +527,28 @@ class Base(Configuration):
environ_name="OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION",
environ_prefix=None,
)
OIDC_USE_PKCE = values.BooleanValue(
default=False, environ_name="OIDC_USE_PKCE", environ_prefix=None
)
OIDC_PKCE_CODE_CHALLENGE_METHOD = values.Value(
default="S256",
environ_name="OIDC_PKCE_CODE_CHALLENGE_METHOD",
environ_prefix=None,
)
OIDC_PKCE_CODE_VERIFIER_SIZE = values.IntegerValue(
default=64, environ_name="OIDC_PKCE_CODE_VERIFIER_SIZE", environ_prefix=None
)
OIDC_STORE_ACCESS_TOKEN = values.BooleanValue(
default=False, environ_name="OIDC_STORE_ACCESS_TOKEN", environ_prefix=None
)
OIDC_STORE_REFRESH_TOKEN = values.BooleanValue(
default=False, environ_name="OIDC_STORE_REFRESH_TOKEN", environ_prefix=None
)
OIDC_STORE_REFRESH_TOKEN_KEY = values.Value(
default=None,
environ_name="OIDC_STORE_REFRESH_TOKEN_KEY",
environ_prefix=None,
)
# WARNING: Enabling this setting allows multiple user accounts to share the same email
# address. This may cause security issues and is not recommended for production use when
@@ -497,14 +562,23 @@ class Base(Configuration):
USER_OIDC_ESSENTIAL_CLAIMS = values.ListValue(
default=[], environ_name="USER_OIDC_ESSENTIAL_CLAIMS", environ_prefix=None
)
USER_OIDC_FIELDS_TO_FULLNAME = values.ListValue(
default=["first_name", "last_name"],
environ_name="USER_OIDC_FIELDS_TO_FULLNAME",
OIDC_USERINFO_FULLNAME_FIELDS = values.ListValue(
default=values.ListValue( # retrocompatibility
default=["first_name", "last_name"],
environ_name="USER_OIDC_FIELDS_TO_FULLNAME",
environ_prefix=None,
),
environ_name="OIDC_USERINFO_FULLNAME_FIELDS",
environ_prefix=None,
)
USER_OIDC_FIELD_TO_SHORTNAME = values.Value(
default="first_name",
environ_name="USER_OIDC_FIELD_TO_SHORTNAME",
OIDC_USERINFO_SHORTNAME_FIELD = values.Value(
default=values.Value( # retrocompatibility
default="first_name",
environ_name="USER_OIDC_FIELD_TO_SHORTNAME",
environ_prefix=None,
),
environ_name="OIDC_USERINFO_SHORTNAME_FIELD",
environ_prefix=None,
)
@@ -513,6 +587,9 @@ class Base(Configuration):
)
# AI service
AI_FEATURE_ENABLED = values.BooleanValue(
default=False, environ_name="AI_FEATURE_ENABLED", environ_prefix=None
)
AI_API_KEY = values.Value(None, environ_name="AI_API_KEY", environ_prefix=None)
AI_BASE_URL = values.Value(None, environ_name="AI_BASE_URL", environ_prefix=None)
AI_MODEL = values.Value(None, environ_name="AI_MODEL", environ_prefix=None)
@@ -571,14 +648,16 @@ class Base(Configuration):
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"simple": {
"format": "{asctime} {name} {levelname} {message}",
"style": "{",
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"level": values.Value(
"ERROR",
environ_name="LOGGING_LEVEL_HANDLERS_CONSOLE",
environ_prefix=None,
),
"formatter": "simple",
},
},
# Override root logger to send it to console
@@ -601,6 +680,12 @@ class Base(Configuration):
},
}
API_USERS_LIST_LIMIT = values.PositiveIntegerValue(
default=5,
environ_name="API_USERS_LIST_LIMIT",
environ_prefix=None,
)
# pylint: disable=invalid-name
@property
def ENVIRONMENT(self):
@@ -647,8 +732,10 @@ class Base(Configuration):
release=get_release(),
integrations=[DjangoIntegration()],
)
with sentry_sdk.configure_scope() as scope:
scope.set_extra("application", "backend")
sentry_sdk.set_tag("application", "backend")
# Ignore the logs added by the DockerflowMiddleware
ignore_logger("request.summary")
if (
cls.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION
@@ -696,6 +783,28 @@ class Development(Base):
SESSION_COOKIE_NAME = "impress_sessionid"
USE_SWAGGER = True
SESSION_CACHE_ALIAS = "session"
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.dummy.DummyCache",
},
"session": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": values.Value(
"redis://redis:6379/2",
environ_name="REDIS_URL",
environ_prefix=None,
),
"TIMEOUT": values.IntegerValue(
30, # timeout in seconds
environ_name="CACHES_DEFAULT_TIMEOUT",
environ_prefix=None,
),
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
},
},
}
def __init__(self):
# pylint: disable=invalid-name

View File

@@ -28,7 +28,7 @@ if settings.DEBUG:
if settings.USE_SWAGGER or settings.DEBUG:
urlpatterns += [
path(
f"{settings.API_VERSION}/swagger.json",
f"api/{settings.API_VERSION}/swagger.json",
SpectacularJSONAPIView.as_view(
api_version=settings.API_VERSION,
urlconf="core.urls",
@@ -36,12 +36,12 @@ if settings.USE_SWAGGER or settings.DEBUG:
name="client-api-schema",
),
path(
f"{settings.API_VERSION}//swagger/",
f"api/{settings.API_VERSION}/swagger/",
SpectacularSwaggerView.as_view(url_name="client-api-schema"),
name="swagger-ui-schema",
),
re_path(
f"{settings.API_VERSION}//redoc/",
f"api/{settings.API_VERSION}/redoc/",
SpectacularRedocView.as_view(url_name="client-api-schema"),
name="redoc-schema",
),

View File

@@ -0,0 +1,390 @@
msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-04-29 11:48+0000\n"
"PO-Revision-Date: 2025-05-05 07:07\n"
"Last-Translator: \n"
"Language-Team: Breton\n"
"Language: br_FR\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=5; plural=(n%10==1 && (n%100!=11 || n%100!=71 || n%100!=91) ? 0 : n%10==2 && (n%100!=12 || n%100!=72 || n%100!=92) ? 1 : ((n%10>=3 && n%10<=4) || n%10==9) && ((n%100 < 10 || n%100 > 19) || (n%100 < 70 || n%100 > 79) || (n%100 < 90 || n%100 > 99)) ? 2 : (n!=0 && n%1;\n"
"X-Crowdin-Project: lasuite-docs\n"
"X-Crowdin-Project-ID: 754523\n"
"X-Crowdin-Language: br-FR\n"
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:37 core/admin.py:37
msgid "Personal info"
msgstr "Titouroù personel"
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
msgid "Permissions"
msgstr "Aotreoù"
#: build/lib/core/admin.py:62 core/admin.py:62
msgid "Important dates"
msgstr "Deiziadoù a-bouez"
#: build/lib/core/admin.py:148 core/admin.py:148
msgid "Tree structure"
msgstr "Gwezennadur"
#: build/lib/core/api/filters.py:47 core/api/filters.py:47
msgid "Title"
msgstr "Titl"
#: build/lib/core/api/filters.py:61 core/api/filters.py:61
msgid "Creator is me"
msgstr "Me eo an aozer"
#: build/lib/core/api/filters.py:64 core/api/filters.py:64
msgid "Favorite"
msgstr "Sinedoù"
#: build/lib/core/api/serializers.py:446 core/api/serializers.py:446
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:450 core/api/serializers.py:450
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:586 core/api/serializers.py:586
msgid "Body"
msgstr "Korf"
#: build/lib/core/api/serializers.py:589 core/api/serializers.py:589
msgid "Body type"
msgstr "Doare korf"
#: build/lib/core/api/serializers.py:595 core/api/serializers.py:595
msgid "Format"
msgstr "Stumm"
#: build/lib/core/api/viewsets.py:966 core/api/viewsets.py:966
#, python-brace-format
msgid "copy of {title}"
msgstr "eilenn {title}"
#: build/lib/core/enums.py:35 core/enums.py:35
msgid "First child"
msgstr "Bugel kentañ"
#: build/lib/core/enums.py:36 core/enums.py:36
msgid "Last child"
msgstr "Bugel diwezhañ"
#: build/lib/core/enums.py:37 core/enums.py:37
msgid "First sibling"
msgstr ""
#: build/lib/core/enums.py:38 core/enums.py:38
msgid "Last sibling"
msgstr ""
#: build/lib/core/enums.py:39 core/enums.py:39
msgid "Left"
msgstr "Kleiz"
#: build/lib/core/enums.py:40 core/enums.py:40
msgid "Right"
msgstr "Dehoù"
#: build/lib/core/models.py:56 build/lib/core/models.py:63 core/models.py:56
#: core/models.py:63
msgid "Reader"
msgstr "Lenner"
#: build/lib/core/models.py:57 build/lib/core/models.py:64 core/models.py:57
#: core/models.py:64
msgid "Editor"
msgstr ""
#: build/lib/core/models.py:65 core/models.py:65
msgid "Administrator"
msgstr "Merour"
#: build/lib/core/models.py:66 core/models.py:66
msgid "Owner"
msgstr "Perc'henn"
#: build/lib/core/models.py:77 core/models.py:77
msgid "Restricted"
msgstr ""
#: build/lib/core/models.py:81 core/models.py:81
msgid "Authenticated"
msgstr ""
#: build/lib/core/models.py:83 core/models.py:83
msgid "Public"
msgstr "Publik"
#: build/lib/core/models.py:154 core/models.py:154
msgid "id"
msgstr "id"
#: build/lib/core/models.py:155 core/models.py:155
msgid "primary key for the record as UUID"
msgstr ""
#: build/lib/core/models.py:161 core/models.py:161
msgid "created on"
msgstr "krouet d'ar/al"
#: build/lib/core/models.py:162 core/models.py:162
msgid "date and time at which a record was created"
msgstr ""
#: build/lib/core/models.py:167 core/models.py:167
msgid "updated on"
msgstr "hizivaet d'ar/al"
#: build/lib/core/models.py:168 core/models.py:168
msgid "date and time at which a record was last updated"
msgstr ""
#: build/lib/core/models.py:204 core/models.py:204
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:217 core/models.py:217
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
msgstr ""
#: build/lib/core/models.py:223 core/models.py:223
msgid "sub"
msgstr ""
#: build/lib/core/models.py:225 core/models.py:225
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
msgstr ""
#: build/lib/core/models.py:234 core/models.py:234
msgid "full name"
msgstr "anv klok"
#: build/lib/core/models.py:235 core/models.py:235
msgid "short name"
msgstr "anv berr"
#: build/lib/core/models.py:237 core/models.py:237
msgid "identity email address"
msgstr ""
#: build/lib/core/models.py:242 core/models.py:242
msgid "admin email address"
msgstr ""
#: build/lib/core/models.py:249 core/models.py:249
msgid "language"
msgstr "yezh"
#: build/lib/core/models.py:250 core/models.py:250
msgid "The language in which the user wants to see the interface."
msgstr ""
#: build/lib/core/models.py:258 core/models.py:258
msgid "The timezone in which the user wants to see times."
msgstr ""
#: build/lib/core/models.py:261 core/models.py:261
msgid "device"
msgstr "trevnad"
#: build/lib/core/models.py:263 core/models.py:263
msgid "Whether the user is a device or a real user."
msgstr ""
#: build/lib/core/models.py:266 core/models.py:266
msgid "staff status"
msgstr ""
#: build/lib/core/models.py:268 core/models.py:268
msgid "Whether the user can log into this admin site."
msgstr ""
#: build/lib/core/models.py:271 core/models.py:271
msgid "active"
msgstr ""
#: build/lib/core/models.py:274 core/models.py:274
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: build/lib/core/models.py:286 core/models.py:286
msgid "user"
msgstr "implijer"
#: build/lib/core/models.py:287 core/models.py:287
msgid "users"
msgstr "implijerien"
#: build/lib/core/models.py:470 build/lib/core/models.py:1154
#: core/models.py:470 core/models.py:1154
msgid "title"
msgstr "titl"
#: build/lib/core/models.py:471 core/models.py:471
msgid "excerpt"
msgstr ""
#: build/lib/core/models.py:519 core/models.py:519
msgid "Document"
msgstr ""
#: build/lib/core/models.py:520 core/models.py:520
msgid "Documents"
msgstr ""
#: build/lib/core/models.py:532 build/lib/core/models.py:872 core/models.py:532
#: core/models.py:872
msgid "Untitled Document"
msgstr ""
#: build/lib/core/models.py:907 core/models.py:907
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
#: build/lib/core/models.py:911 core/models.py:911
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr ""
#: build/lib/core/models.py:917 core/models.py:917
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr ""
#: build/lib/core/models.py:1015 core/models.py:1015
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:1016 core/models.py:1016
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:1022 core/models.py:1022
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:1045 core/models.py:1045
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:1046 core/models.py:1046
msgid "Document favorites"
msgstr ""
#: build/lib/core/models.py:1052 core/models.py:1052
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:1074 core/models.py:1074
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:1075 core/models.py:1075
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:1081 core/models.py:1081
msgid "This user is already in this document."
msgstr ""
#: build/lib/core/models.py:1087 core/models.py:1087
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:1093 build/lib/core/models.py:1241
#: core/models.py:1093 core/models.py:1241
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:1155 core/models.py:1155
msgid "description"
msgstr ""
#: build/lib/core/models.py:1156 core/models.py:1156
msgid "code"
msgstr ""
#: build/lib/core/models.py:1157 core/models.py:1157
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1159 core/models.py:1159
msgid "public"
msgstr "publik"
#: build/lib/core/models.py:1161 core/models.py:1161
msgid "Whether this template is public for anyone to use."
msgstr ""
#: build/lib/core/models.py:1167 core/models.py:1167
msgid "Template"
msgstr "Patrom"
#: build/lib/core/models.py:1168 core/models.py:1168
msgid "Templates"
msgstr "Patromoù"
#: build/lib/core/models.py:1222 core/models.py:1222
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1223 core/models.py:1223
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1229 core/models.py:1229
msgid "This user is already in this template."
msgstr ""
#: build/lib/core/models.py:1235 core/models.py:1235
msgid "This team is already in this template."
msgstr ""
#: build/lib/core/models.py:1258 core/models.py:1258
msgid "email address"
msgstr ""
#: build/lib/core/models.py:1277 core/models.py:1277
msgid "Document invitation"
msgstr ""
#: build/lib/core/models.py:1278 core/models.py:1278
msgid "Document invitations"
msgstr ""
#: build/lib/core/models.py:1298 core/models.py:1298
msgid "This email is already associated to a registered user."
msgstr ""
#: core/templates/mail/html/invitation.html:162
#: core/templates/mail/text/invitation.txt:3
msgid "Logo email"
msgstr ""
#: core/templates/mail/html/invitation.html:209
#: core/templates/mail/text/invitation.txt:10
msgid "Open"
msgstr "Digeriñ"
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr ""
#: core/templates/mail/html/invitation.html:233
#: core/templates/mail/text/invitation.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
msgstr ""

View File

@@ -0,0 +1,399 @@
msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-04-04 13:46+0000\n"
"PO-Revision-Date: 2025-04-16 16:32\n"
"Last-Translator: \n"
"Language-Team: Chinese Simplified\n"
"Language: zh_CN\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=1; plural=0;\n"
"X-Crowdin-Project: lasuite-docs\n"
"X-Crowdin-Project-ID: 754523\n"
"X-Crowdin-Language: zh-CN\n"
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:37 core/admin.py:37
msgid "Personal info"
msgstr "个人信息"
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
msgid "Permissions"
msgstr "权限"
#: build/lib/core/admin.py:62 core/admin.py:62
msgid "Important dates"
msgstr "重要日期"
#: build/lib/core/admin.py:148 core/admin.py:148
msgid "Tree structure"
msgstr "树状结构"
#: build/lib/core/api/filters.py:16 core/api/filters.py:16
msgid "Title"
msgstr "标题"
#: build/lib/core/api/filters.py:30 core/api/filters.py:30
msgid "Creator is me"
msgstr "创建者是我"
#: build/lib/core/api/filters.py:33 core/api/filters.py:33
msgid "Favorite"
msgstr "收藏"
#: build/lib/core/api/serializers.py:446 core/api/serializers.py:446
msgid "A new document was created on your behalf!"
msgstr "已为您创建了一份新文档!"
#: build/lib/core/api/serializers.py:450 core/api/serializers.py:450
msgid "You have been granted ownership of a new document:"
msgstr "您已被授予新文档的所有权:"
#: build/lib/core/api/serializers.py:586 core/api/serializers.py:586
msgid "Body"
msgstr "正文"
#: build/lib/core/api/serializers.py:589 core/api/serializers.py:589
msgid "Body type"
msgstr "正文类型"
#: build/lib/core/api/serializers.py:595 core/api/serializers.py:595
msgid "Format"
msgstr "格式"
#: build/lib/core/api/viewsets.py:944 core/api/viewsets.py:944
#, python-brace-format
msgid "copy of {title}"
msgstr "{title} 的副本"
#: build/lib/core/authentication/backends.py:61
#: core/authentication/backends.py:61
msgid "Invalid response format or token verification failed"
msgstr "响应格式无效或令牌验证失败"
#: build/lib/core/authentication/backends.py:108
#: core/authentication/backends.py:108
msgid "User account is disabled"
msgstr "用户账户已被禁用"
#: build/lib/core/enums.py:35 core/enums.py:35
msgid "First child"
msgstr "第一个子项"
#: build/lib/core/enums.py:36 core/enums.py:36
msgid "Last child"
msgstr "最后一个子项"
#: build/lib/core/enums.py:37 core/enums.py:37
msgid "First sibling"
msgstr "第一个同级项"
#: build/lib/core/enums.py:38 core/enums.py:38
msgid "Last sibling"
msgstr "最后一个同级项"
#: build/lib/core/enums.py:39 core/enums.py:39
msgid "Left"
msgstr "左"
#: build/lib/core/enums.py:40 core/enums.py:40
msgid "Right"
msgstr "右"
#: build/lib/core/models.py:56 build/lib/core/models.py:63 core/models.py:56
#: core/models.py:63
msgid "Reader"
msgstr "阅读者"
#: build/lib/core/models.py:57 build/lib/core/models.py:64 core/models.py:57
#: core/models.py:64
msgid "Editor"
msgstr "编辑者"
#: build/lib/core/models.py:65 core/models.py:65
msgid "Administrator"
msgstr "超级管理员"
#: build/lib/core/models.py:66 core/models.py:66
msgid "Owner"
msgstr "所有者"
#: build/lib/core/models.py:77 core/models.py:77
msgid "Restricted"
msgstr "受限的"
#: build/lib/core/models.py:81 core/models.py:81
msgid "Authenticated"
msgstr "已验证"
#: build/lib/core/models.py:83 core/models.py:83
msgid "Public"
msgstr "公开"
#: build/lib/core/models.py:154 core/models.py:154
msgid "id"
msgstr "id"
#: build/lib/core/models.py:155 core/models.py:155
msgid "primary key for the record as UUID"
msgstr "记录的主密钥为 UUID"
#: build/lib/core/models.py:161 core/models.py:161
msgid "created on"
msgstr "创建时间"
#: build/lib/core/models.py:162 core/models.py:162
msgid "date and time at which a record was created"
msgstr "记录的创建日期和时间"
#: build/lib/core/models.py:167 core/models.py:167
msgid "updated on"
msgstr "更新时间"
#: build/lib/core/models.py:168 core/models.py:168
msgid "date and time at which a record was last updated"
msgstr "记录的最后更新时间"
#: build/lib/core/models.py:204 core/models.py:204
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
msgstr "未找到具有该 sub 的用户,但该邮箱已关联到一个注册用户。"
#: build/lib/core/models.py:217 core/models.py:217
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
msgstr "请输入有效的 sub。该值只能包含字母、数字及 @/./+/-/_/: 字符。"
#: build/lib/core/models.py:223 core/models.py:223
msgid "sub"
msgstr "sub"
#: build/lib/core/models.py:225 core/models.py:225
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
msgstr "必填。最多 255 个字符,仅允许字母、数字及 @/./+/-/_/: 字符。"
#: build/lib/core/models.py:234 core/models.py:234
msgid "full name"
msgstr "全名"
#: build/lib/core/models.py:235 core/models.py:235
msgid "short name"
msgstr "简称"
#: build/lib/core/models.py:237 core/models.py:237
msgid "identity email address"
msgstr "身份电子邮件地址"
#: build/lib/core/models.py:242 core/models.py:242
msgid "admin email address"
msgstr "管理员电子邮件地址"
#: build/lib/core/models.py:249 core/models.py:249
msgid "language"
msgstr "语言"
#: build/lib/core/models.py:250 core/models.py:250
msgid "The language in which the user wants to see the interface."
msgstr "用户希望看到的界面语言。"
#: build/lib/core/models.py:258 core/models.py:258
msgid "The timezone in which the user wants to see times."
msgstr "用户查看时间希望的时区。"
#: build/lib/core/models.py:261 core/models.py:261
msgid "device"
msgstr "设备"
#: build/lib/core/models.py:263 core/models.py:263
msgid "Whether the user is a device or a real user."
msgstr "用户是设备还是真实用户。"
#: build/lib/core/models.py:266 core/models.py:266
msgid "staff status"
msgstr "员工状态"
#: build/lib/core/models.py:268 core/models.py:268
msgid "Whether the user can log into this admin site."
msgstr "用户是否可以登录该管理员站点。"
#: build/lib/core/models.py:271 core/models.py:271
msgid "active"
msgstr "激活"
#: build/lib/core/models.py:274 core/models.py:274
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "是否应将此用户视为活跃用户。取消选择此选项而不是删除账户。"
#: build/lib/core/models.py:286 core/models.py:286
msgid "user"
msgstr "用户"
#: build/lib/core/models.py:287 core/models.py:287
msgid "users"
msgstr "个用户"
#: build/lib/core/models.py:470 build/lib/core/models.py:1154
#: core/models.py:470 core/models.py:1154
msgid "title"
msgstr "标题"
#: build/lib/core/models.py:471 core/models.py:471
msgid "excerpt"
msgstr "摘要"
#: build/lib/core/models.py:519 core/models.py:519
msgid "Document"
msgstr "文档"
#: build/lib/core/models.py:520 core/models.py:520
msgid "Documents"
msgstr "个文档"
#: build/lib/core/models.py:532 build/lib/core/models.py:872 core/models.py:532
#: core/models.py:872
msgid "Untitled Document"
msgstr "未命名文档"
#: build/lib/core/models.py:907 core/models.py:907
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} 与您共享了一个文档!"
#: build/lib/core/models.py:911 core/models.py:911
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} 邀请您以“{role}”角色访问以下文档:"
#: build/lib/core/models.py:917 core/models.py:917
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} 与您共享了一个文档:{title}"
#: build/lib/core/models.py:1015 core/models.py:1015
msgid "Document/user link trace"
msgstr "文档/用户链接跟踪"
#: build/lib/core/models.py:1016 core/models.py:1016
msgid "Document/user link traces"
msgstr "个文档/用户链接跟踪"
#: build/lib/core/models.py:1022 core/models.py:1022
msgid "A link trace already exists for this document/user."
msgstr "此文档/用户的链接跟踪已存在。"
#: build/lib/core/models.py:1045 core/models.py:1045
msgid "Document favorite"
msgstr "文档收藏"
#: build/lib/core/models.py:1046 core/models.py:1046
msgid "Document favorites"
msgstr "文档收藏夹"
#: build/lib/core/models.py:1052 core/models.py:1052
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "该文档已被同一用户的收藏关系实例关联。"
#: build/lib/core/models.py:1074 core/models.py:1074
msgid "Document/user relation"
msgstr "文档/用户关系"
#: build/lib/core/models.py:1075 core/models.py:1075
msgid "Document/user relations"
msgstr "文档/用户关系集"
#: build/lib/core/models.py:1081 core/models.py:1081
msgid "This user is already in this document."
msgstr "该用户已在此文档中。"
#: build/lib/core/models.py:1087 core/models.py:1087
msgid "This team is already in this document."
msgstr "该团队已在此文档中。"
#: build/lib/core/models.py:1093 build/lib/core/models.py:1241
#: core/models.py:1093 core/models.py:1241
msgid "Either user or team must be set, not both."
msgstr "必须设置用户或团队之一,不能同时设置两者。"
#: build/lib/core/models.py:1155 core/models.py:1155
msgid "description"
msgstr "说明"
#: build/lib/core/models.py:1156 core/models.py:1156
msgid "code"
msgstr "代码"
#: build/lib/core/models.py:1157 core/models.py:1157
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1159 core/models.py:1159
msgid "public"
msgstr "公开"
#: build/lib/core/models.py:1161 core/models.py:1161
msgid "Whether this template is public for anyone to use."
msgstr "该模板是否公开供任何人使用。"
#: build/lib/core/models.py:1167 core/models.py:1167
msgid "Template"
msgstr "模板"
#: build/lib/core/models.py:1168 core/models.py:1168
msgid "Templates"
msgstr "模板"
#: build/lib/core/models.py:1222 core/models.py:1222
msgid "Template/user relation"
msgstr "模板/用户关系"
#: build/lib/core/models.py:1223 core/models.py:1223
msgid "Template/user relations"
msgstr "模板/用户关系集"
#: build/lib/core/models.py:1229 core/models.py:1229
msgid "This user is already in this template."
msgstr "该用户已在此模板中。"
#: build/lib/core/models.py:1235 core/models.py:1235
msgid "This team is already in this template."
msgstr "该团队已在此模板中。"
#: build/lib/core/models.py:1258 core/models.py:1258
msgid "email address"
msgstr "电子邮件地址"
#: build/lib/core/models.py:1277 core/models.py:1277
msgid "Document invitation"
msgstr "文档邀请"
#: build/lib/core/models.py:1278 core/models.py:1278
msgid "Document invitations"
msgstr "文档邀请"
#: build/lib/core/models.py:1298 core/models.py:1298
msgid "This email is already associated to a registered user."
msgstr "此电子邮件已经与现有注册用户关联。"
#: core/templates/mail/html/invitation.html:162
#: core/templates/mail/text/invitation.txt:3
msgid "Logo email"
msgstr "徽标邮件"
#: core/templates/mail/html/invitation.html:209
#: core/templates/mail/text/invitation.txt:10
msgid "Open"
msgstr "打开"
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.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/invitation.html:233
#: core/templates/mail/text/invitation.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
msgstr " 由 %(brandname)s 倾力打造。 "

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-02-06 15:30+0000\n"
"PO-Revision-Date: 2025-02-10 14:14\n"
"POT-Creation-Date: 2025-04-29 11:48+0000\n"
"PO-Revision-Date: 2025-05-05 07:07\n"
"Last-Translator: \n"
"Language-Team: German\n"
"Language: de_DE\n"
@@ -32,365 +32,341 @@ msgstr "Wichtige Daten"
#: build/lib/core/admin.py:148 core/admin.py:148
msgid "Tree structure"
msgstr ""
msgstr "Baumstruktur"
#: build/lib/core/api/filters.py:16 core/api/filters.py:16
msgid "Creator is me"
msgstr "Ersteller bin ich"
#: build/lib/core/api/filters.py:19 core/api/filters.py:19
msgid "Favorite"
msgstr "Favorit"
#: build/lib/core/api/filters.py:22 core/api/filters.py:22
#: build/lib/core/api/filters.py:47 core/api/filters.py:47
msgid "Title"
msgstr "Titel"
#: build/lib/core/api/serializers.py:346 core/api/serializers.py:346
#: build/lib/core/api/filters.py:61 core/api/filters.py:61
msgid "Creator is me"
msgstr "Ersteller bin ich"
#: build/lib/core/api/filters.py:64 core/api/filters.py:64
msgid "Favorite"
msgstr "Favorit"
#: build/lib/core/api/serializers.py:446 core/api/serializers.py:446
msgid "A new document was created on your behalf!"
msgstr "Ein neues Dokument wurde in Ihrem Namen erstellt!"
#: build/lib/core/api/serializers.py:350 core/api/serializers.py:350
#: build/lib/core/api/serializers.py:450 core/api/serializers.py:450
msgid "You have been granted ownership of a new document:"
msgstr "Sie sind Besitzer eines neuen Dokuments:"
#: build/lib/core/api/serializers.py:453 core/api/serializers.py:453
#: build/lib/core/api/serializers.py:586 core/api/serializers.py:586
msgid "Body"
msgstr "Inhalt"
#: build/lib/core/api/serializers.py:456 core/api/serializers.py:456
#: build/lib/core/api/serializers.py:589 core/api/serializers.py:589
msgid "Body type"
msgstr "Typ"
#: build/lib/core/api/serializers.py:462 core/api/serializers.py:462
#: build/lib/core/api/serializers.py:595 core/api/serializers.py:595
msgid "Format"
msgstr ""
msgstr "Format"
#: build/lib/core/authentication/backends.py:61
#: core/authentication/backends.py:61
msgid "Invalid response format or token verification failed"
msgstr "Ungültiges Antwortformat oder Token-Verifizierung fehlgeschlagen"
#: build/lib/core/api/viewsets.py:966 core/api/viewsets.py:966
#, python-brace-format
msgid "copy of {title}"
msgstr "Kopie von {title}"
#: build/lib/core/authentication/backends.py:108
#: core/authentication/backends.py:108
msgid "User account is disabled"
msgstr "Benutzerkonto ist deaktiviert"
#: build/lib/core/enums.py:19 core/enums.py:19
#: build/lib/core/enums.py:35 core/enums.py:35
msgid "First child"
msgstr ""
msgstr "Erstes Unterelement"
#: build/lib/core/enums.py:20 core/enums.py:20
#: build/lib/core/enums.py:36 core/enums.py:36
msgid "Last child"
msgstr ""
msgstr "Letztes Unterelement"
#: build/lib/core/enums.py:21 core/enums.py:21
#: build/lib/core/enums.py:37 core/enums.py:37
msgid "First sibling"
msgstr ""
msgstr "Erstes Nebenelement"
#: build/lib/core/enums.py:22 core/enums.py:22
#: build/lib/core/enums.py:38 core/enums.py:38
msgid "Last sibling"
msgstr ""
msgstr "Letztes Nebenelement"
#: build/lib/core/enums.py:23 core/enums.py:23
#: build/lib/core/enums.py:39 core/enums.py:39
msgid "Left"
msgstr ""
msgstr "Links"
#: build/lib/core/enums.py:24 core/enums.py:24
#: build/lib/core/enums.py:40 core/enums.py:40
msgid "Right"
msgstr ""
msgstr "Rechts"
#: build/lib/core/models.py:54 build/lib/core/models.py:61 core/models.py:54
#: core/models.py:61
#: build/lib/core/models.py:56 build/lib/core/models.py:63 core/models.py:56
#: core/models.py:63
msgid "Reader"
msgstr "Lesen"
#: build/lib/core/models.py:55 build/lib/core/models.py:62 core/models.py:55
#: core/models.py:62
#: build/lib/core/models.py:57 build/lib/core/models.py:64 core/models.py:57
#: core/models.py:64
msgid "Editor"
msgstr "Bearbeiten"
#: build/lib/core/models.py:63 core/models.py:63
#: build/lib/core/models.py:65 core/models.py:65
msgid "Administrator"
msgstr ""
msgstr "Administrator"
#: build/lib/core/models.py:64 core/models.py:64
#: build/lib/core/models.py:66 core/models.py:66
msgid "Owner"
msgstr "Besitzer"
#: build/lib/core/models.py:75 core/models.py:75
#: build/lib/core/models.py:77 core/models.py:77
msgid "Restricted"
msgstr "Beschränkt"
#: build/lib/core/models.py:79 core/models.py:79
#: build/lib/core/models.py:81 core/models.py:81
msgid "Authenticated"
msgstr "Authentifiziert"
#: build/lib/core/models.py:81 core/models.py:81
#: build/lib/core/models.py:83 core/models.py:83
msgid "Public"
msgstr "Öffentlich"
#: build/lib/core/models.py:103 core/models.py:103
#: build/lib/core/models.py:154 core/models.py:154
msgid "id"
msgstr ""
msgstr "id"
#: build/lib/core/models.py:104 core/models.py:104
#: build/lib/core/models.py:155 core/models.py:155
msgid "primary key for the record as UUID"
msgstr ""
msgstr "primärer Schlüssel für den Datensatz als UUID"
#: build/lib/core/models.py:110 core/models.py:110
#: build/lib/core/models.py:161 core/models.py:161
msgid "created on"
msgstr "Erstellt"
#: build/lib/core/models.py:111 core/models.py:111
#: build/lib/core/models.py:162 core/models.py:162
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:116 core/models.py:116
#: build/lib/core/models.py:167 core/models.py:167
msgid "updated on"
msgstr "Aktualisiert"
#: build/lib/core/models.py:117 core/models.py:117
#: build/lib/core/models.py:168 core/models.py:168
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:153 core/models.py:153
#: build/lib/core/models.py:204 core/models.py:204
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
msgstr ""
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:166 core/models.py:166
#: build/lib/core/models.py:217 core/models.py:217
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
msgstr "Geben Sie eine gültige Unterseite ein. Dieser Wert darf nur Buchstaben, Zahlen und die @/./+/-/_/: Zeichen enthalten."
#: build/lib/core/models.py:172 core/models.py:172
#: build/lib/core/models.py:223 core/models.py:223
msgid "sub"
msgstr "unter"
#: build/lib/core/models.py:174 core/models.py:174
#: build/lib/core/models.py:225 core/models.py:225
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
msgstr "Erforderlich. 255 Zeichen oder weniger. Buchstaben, Zahlen und die Zeichen @/./+/-/_/:"
#: build/lib/core/models.py:183 core/models.py:183
#: build/lib/core/models.py:234 core/models.py:234
msgid "full name"
msgstr "Name"
#: build/lib/core/models.py:184 core/models.py:184
#: build/lib/core/models.py:235 core/models.py:235
msgid "short name"
msgstr "Kurzbezeichnung"
#: build/lib/core/models.py:186 core/models.py:186
#: build/lib/core/models.py:237 core/models.py:237
msgid "identity email address"
msgstr "Identitäts-E-Mail-Adresse"
#: build/lib/core/models.py:191 core/models.py:191
#: build/lib/core/models.py:242 core/models.py:242
msgid "admin email address"
msgstr "Admin E-Mail-Adresse"
#: build/lib/core/models.py:198 core/models.py:198
#: build/lib/core/models.py:249 core/models.py:249
msgid "language"
msgstr "Sprache"
#: build/lib/core/models.py:199 core/models.py:199
#: build/lib/core/models.py:250 core/models.py:250
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:205 core/models.py:205
#: build/lib/core/models.py:258 core/models.py:258
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:208 core/models.py:208
#: build/lib/core/models.py:261 core/models.py:261
msgid "device"
msgstr "Gerät"
#: build/lib/core/models.py:210 core/models.py:210
#: build/lib/core/models.py:263 core/models.py:263
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:213 core/models.py:213
#: build/lib/core/models.py:266 core/models.py:266
msgid "staff status"
msgstr "Status des Teammitgliedes"
#: build/lib/core/models.py:215 core/models.py:215
#: build/lib/core/models.py:268 core/models.py:268
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:218 core/models.py:218
#: build/lib/core/models.py:271 core/models.py:271
msgid "active"
msgstr "aktiviert"
#: build/lib/core/models.py:221 core/models.py:221
#: build/lib/core/models.py:274 core/models.py:274
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:233 core/models.py:233
#: build/lib/core/models.py:286 core/models.py:286
msgid "user"
msgstr "Benutzer"
#: build/lib/core/models.py:234 core/models.py:234
#: build/lib/core/models.py:287 core/models.py:287
msgid "users"
msgstr "Benutzer"
#: build/lib/core/models.py:373 build/lib/core/models.py:925 core/models.py:373
#: core/models.py:925
#: build/lib/core/models.py:470 build/lib/core/models.py:1154
#: core/models.py:470 core/models.py:1154
msgid "title"
msgstr "Titel"
#: build/lib/core/models.py:374 core/models.py:374
#: build/lib/core/models.py:471 core/models.py:471
msgid "excerpt"
msgstr ""
msgstr "Auszug"
#: build/lib/core/models.py:405 core/models.py:405
#: build/lib/core/models.py:519 core/models.py:519
msgid "Document"
msgstr "Dokument"
#: build/lib/core/models.py:406 core/models.py:406
#: build/lib/core/models.py:520 core/models.py:520
msgid "Documents"
msgstr "Dokumente"
#: build/lib/core/models.py:418 core/models.py:418
#: build/lib/core/models.py:532 build/lib/core/models.py:872 core/models.py:532
#: core/models.py:872
msgid "Untitled Document"
msgstr "Unbenanntes Dokument"
#: build/lib/core/models.py:719 core/models.py:719
#: build/lib/core/models.py:907 core/models.py:907
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} hat ein Dokument mit Ihnen geteilt!"
#: build/lib/core/models.py:723 core/models.py:723
#: build/lib/core/models.py:911 core/models.py:911
#, 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:726 core/models.py:726
#: build/lib/core/models.py:917 core/models.py:917
#, 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:762 core/models.py:762
msgid "This document is not deleted."
msgstr ""
#: build/lib/core/models.py:769 core/models.py:769
msgid "This document was permanently deleted and cannot be restored."
msgstr ""
#: build/lib/core/models.py:820 core/models.py:820
#: build/lib/core/models.py:1015 core/models.py:1015
msgid "Document/user link trace"
msgstr "Dokument/Benutzer Linkverfolgung"
#: build/lib/core/models.py:821 core/models.py:821
#: build/lib/core/models.py:1016 core/models.py:1016
msgid "Document/user link traces"
msgstr "Dokument/Benutzer Linkverfolgung"
#: build/lib/core/models.py:827 core/models.py:827
#: build/lib/core/models.py:1022 core/models.py:1022
msgid "A link trace already exists for this document/user."
msgstr ""
msgstr "Für dieses Dokument/ diesen Benutzer ist bereits eine Linkverfolgung vorhanden."
#: build/lib/core/models.py:850 core/models.py:850
#: build/lib/core/models.py:1045 core/models.py:1045
msgid "Document favorite"
msgstr "Dokumentenfavorit"
#: build/lib/core/models.py:851 core/models.py:851
#: build/lib/core/models.py:1046 core/models.py:1046
msgid "Document favorites"
msgstr "Dokumentfavoriten"
#: build/lib/core/models.py:857 core/models.py:857
#: build/lib/core/models.py:1052 core/models.py:1052
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:879 core/models.py:879
#: build/lib/core/models.py:1074 core/models.py:1074
msgid "Document/user relation"
msgstr "Dokument/Benutzerbeziehung"
#: build/lib/core/models.py:880 core/models.py:880
#: build/lib/core/models.py:1075 core/models.py:1075
msgid "Document/user relations"
msgstr "Dokument/Benutzerbeziehungen"
#: build/lib/core/models.py:886 core/models.py:886
#: build/lib/core/models.py:1081 core/models.py:1081
msgid "This user is already in this document."
msgstr "Dieser Benutzer befindet sich bereits in diesem Dokument."
#: build/lib/core/models.py:892 core/models.py:892
#: build/lib/core/models.py:1087 core/models.py:1087
msgid "This team is already in this document."
msgstr "Dieses Team befindet sich bereits in diesem Dokument."
#: build/lib/core/models.py:898 build/lib/core/models.py:1012
#: core/models.py:898 core/models.py:1012
#: build/lib/core/models.py:1093 build/lib/core/models.py:1241
#: core/models.py:1093 core/models.py:1241
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:926 core/models.py:926
#: build/lib/core/models.py:1155 core/models.py:1155
msgid "description"
msgstr "Beschreibung"
#: build/lib/core/models.py:927 core/models.py:927
#: build/lib/core/models.py:1156 core/models.py:1156
msgid "code"
msgstr "Code"
#: build/lib/core/models.py:928 core/models.py:928
#: build/lib/core/models.py:1157 core/models.py:1157
msgid "css"
msgstr "CSS"
#: build/lib/core/models.py:930 core/models.py:930
#: build/lib/core/models.py:1159 core/models.py:1159
msgid "public"
msgstr "öffentlich"
#: build/lib/core/models.py:932 core/models.py:932
#: build/lib/core/models.py:1161 core/models.py:1161
msgid "Whether this template is public for anyone to use."
msgstr "Ob diese Vorlage für jedermann öffentlich ist."
#: build/lib/core/models.py:938 core/models.py:938
#: build/lib/core/models.py:1167 core/models.py:1167
msgid "Template"
msgstr "Vorlage"
#: build/lib/core/models.py:939 core/models.py:939
#: build/lib/core/models.py:1168 core/models.py:1168
msgid "Templates"
msgstr "Vorlagen"
#: build/lib/core/models.py:993 core/models.py:993
#: build/lib/core/models.py:1222 core/models.py:1222
msgid "Template/user relation"
msgstr "Vorlage/Benutzer-Beziehung"
#: build/lib/core/models.py:994 core/models.py:994
#: build/lib/core/models.py:1223 core/models.py:1223
msgid "Template/user relations"
msgstr "Vorlage/Benutzerbeziehungen"
#: build/lib/core/models.py:1000 core/models.py:1000
#: build/lib/core/models.py:1229 core/models.py:1229
msgid "This user is already in this template."
msgstr "Dieser Benutzer ist bereits in dieser Vorlage."
#: build/lib/core/models.py:1006 core/models.py:1006
#: build/lib/core/models.py:1235 core/models.py:1235
msgid "This team is already in this template."
msgstr "Dieses Team ist bereits in diesem Template."
#: build/lib/core/models.py:1029 core/models.py:1029
#: build/lib/core/models.py:1258 core/models.py:1258
msgid "email address"
msgstr "E-Mail-Adresse"
#: build/lib/core/models.py:1048 core/models.py:1048
#: build/lib/core/models.py:1277 core/models.py:1277
msgid "Document invitation"
msgstr "Einladung zum Dokument"
#: build/lib/core/models.py:1049 core/models.py:1049
#: build/lib/core/models.py:1278 core/models.py:1278
msgid "Document invitations"
msgstr "Dokumenteinladungen"
#: build/lib/core/models.py:1069 core/models.py:1069
#: build/lib/core/models.py:1298 core/models.py:1298
msgid "This email is already associated to a registered user."
msgstr "Diese E-Mail ist bereits einem registrierten Benutzer zugeordnet."
#: build/lib/impress/settings.py:236 impress/settings.py:236
msgid "English"
msgstr "Englisch"
#: build/lib/impress/settings.py:237 impress/settings.py:237
msgid "French"
msgstr "Französisch"
#: build/lib/impress/settings.py:238 impress/settings.py:238
msgid "German"
msgstr "Deutsch"
#: core/templates/mail/html/invitation.html:162
#: core/templates/mail/text/invitation.txt:3
msgid "Logo email"

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-02-06 15:30+0000\n"
"PO-Revision-Date: 2025-02-10 14:14\n"
"POT-Creation-Date: 2025-04-29 11:48+0000\n"
"PO-Revision-Date: 2025-05-05 07:07\n"
"Last-Translator: \n"
"Language-Team: English\n"
"Language: en_US\n"
@@ -34,363 +34,339 @@ msgstr ""
msgid "Tree structure"
msgstr ""
#: build/lib/core/api/filters.py:16 core/api/filters.py:16
msgid "Creator is me"
msgstr ""
#: build/lib/core/api/filters.py:19 core/api/filters.py:19
msgid "Favorite"
msgstr ""
#: build/lib/core/api/filters.py:22 core/api/filters.py:22
#: build/lib/core/api/filters.py:47 core/api/filters.py:47
msgid "Title"
msgstr ""
#: build/lib/core/api/serializers.py:346 core/api/serializers.py:346
#: build/lib/core/api/filters.py:61 core/api/filters.py:61
msgid "Creator is me"
msgstr ""
#: build/lib/core/api/filters.py:64 core/api/filters.py:64
msgid "Favorite"
msgstr ""
#: build/lib/core/api/serializers.py:446 core/api/serializers.py:446
msgid "A new document was created on your behalf!"
msgstr ""
#: build/lib/core/api/serializers.py:350 core/api/serializers.py:350
#: build/lib/core/api/serializers.py:450 core/api/serializers.py:450
msgid "You have been granted ownership of a new document:"
msgstr ""
#: build/lib/core/api/serializers.py:453 core/api/serializers.py:453
#: build/lib/core/api/serializers.py:586 core/api/serializers.py:586
msgid "Body"
msgstr ""
#: build/lib/core/api/serializers.py:456 core/api/serializers.py:456
#: build/lib/core/api/serializers.py:589 core/api/serializers.py:589
msgid "Body type"
msgstr ""
#: build/lib/core/api/serializers.py:462 core/api/serializers.py:462
#: build/lib/core/api/serializers.py:595 core/api/serializers.py:595
msgid "Format"
msgstr ""
#: build/lib/core/authentication/backends.py:61
#: core/authentication/backends.py:61
msgid "Invalid response format or token verification failed"
#: build/lib/core/api/viewsets.py:966 core/api/viewsets.py:966
#, python-brace-format
msgid "copy of {title}"
msgstr ""
#: build/lib/core/authentication/backends.py:108
#: core/authentication/backends.py:108
msgid "User account is disabled"
msgstr ""
#: build/lib/core/enums.py:19 core/enums.py:19
#: build/lib/core/enums.py:35 core/enums.py:35
msgid "First child"
msgstr ""
#: build/lib/core/enums.py:20 core/enums.py:20
#: build/lib/core/enums.py:36 core/enums.py:36
msgid "Last child"
msgstr ""
#: build/lib/core/enums.py:21 core/enums.py:21
#: build/lib/core/enums.py:37 core/enums.py:37
msgid "First sibling"
msgstr ""
#: build/lib/core/enums.py:22 core/enums.py:22
#: build/lib/core/enums.py:38 core/enums.py:38
msgid "Last sibling"
msgstr ""
#: build/lib/core/enums.py:23 core/enums.py:23
#: build/lib/core/enums.py:39 core/enums.py:39
msgid "Left"
msgstr ""
#: build/lib/core/enums.py:24 core/enums.py:24
#: build/lib/core/enums.py:40 core/enums.py:40
msgid "Right"
msgstr ""
#: build/lib/core/models.py:54 build/lib/core/models.py:61 core/models.py:54
#: core/models.py:61
#: build/lib/core/models.py:56 build/lib/core/models.py:63 core/models.py:56
#: core/models.py:63
msgid "Reader"
msgstr ""
#: build/lib/core/models.py:55 build/lib/core/models.py:62 core/models.py:55
#: core/models.py:62
#: build/lib/core/models.py:57 build/lib/core/models.py:64 core/models.py:57
#: core/models.py:64
msgid "Editor"
msgstr ""
#: build/lib/core/models.py:63 core/models.py:63
#: build/lib/core/models.py:65 core/models.py:65
msgid "Administrator"
msgstr ""
#: build/lib/core/models.py:64 core/models.py:64
#: build/lib/core/models.py:66 core/models.py:66
msgid "Owner"
msgstr ""
#: build/lib/core/models.py:75 core/models.py:75
#: build/lib/core/models.py:77 core/models.py:77
msgid "Restricted"
msgstr ""
#: build/lib/core/models.py:79 core/models.py:79
#: build/lib/core/models.py:81 core/models.py:81
msgid "Authenticated"
msgstr ""
#: build/lib/core/models.py:81 core/models.py:81
#: build/lib/core/models.py:83 core/models.py:83
msgid "Public"
msgstr ""
#: build/lib/core/models.py:103 core/models.py:103
#: build/lib/core/models.py:154 core/models.py:154
msgid "id"
msgstr ""
#: build/lib/core/models.py:104 core/models.py:104
#: build/lib/core/models.py:155 core/models.py:155
msgid "primary key for the record as UUID"
msgstr ""
#: build/lib/core/models.py:110 core/models.py:110
#: build/lib/core/models.py:161 core/models.py:161
msgid "created on"
msgstr ""
#: build/lib/core/models.py:111 core/models.py:111
#: build/lib/core/models.py:162 core/models.py:162
msgid "date and time at which a record was created"
msgstr ""
#: build/lib/core/models.py:116 core/models.py:116
#: build/lib/core/models.py:167 core/models.py:167
msgid "updated on"
msgstr ""
#: build/lib/core/models.py:117 core/models.py:117
#: build/lib/core/models.py:168 core/models.py:168
msgid "date and time at which a record was last updated"
msgstr ""
#: build/lib/core/models.py:153 core/models.py:153
#: build/lib/core/models.py:204 core/models.py:204
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:166 core/models.py:166
#: build/lib/core/models.py:217 core/models.py:217
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
msgstr ""
#: build/lib/core/models.py:172 core/models.py:172
#: build/lib/core/models.py:223 core/models.py:223
msgid "sub"
msgstr ""
#: build/lib/core/models.py:174 core/models.py:174
#: build/lib/core/models.py:225 core/models.py:225
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
msgstr ""
#: build/lib/core/models.py:183 core/models.py:183
#: build/lib/core/models.py:234 core/models.py:234
msgid "full name"
msgstr ""
#: build/lib/core/models.py:184 core/models.py:184
#: build/lib/core/models.py:235 core/models.py:235
msgid "short name"
msgstr ""
#: build/lib/core/models.py:186 core/models.py:186
#: build/lib/core/models.py:237 core/models.py:237
msgid "identity email address"
msgstr ""
#: build/lib/core/models.py:191 core/models.py:191
#: build/lib/core/models.py:242 core/models.py:242
msgid "admin email address"
msgstr ""
#: build/lib/core/models.py:198 core/models.py:198
#: build/lib/core/models.py:249 core/models.py:249
msgid "language"
msgstr ""
#: build/lib/core/models.py:199 core/models.py:199
#: build/lib/core/models.py:250 core/models.py:250
msgid "The language in which the user wants to see the interface."
msgstr ""
#: build/lib/core/models.py:205 core/models.py:205
#: build/lib/core/models.py:258 core/models.py:258
msgid "The timezone in which the user wants to see times."
msgstr ""
#: build/lib/core/models.py:208 core/models.py:208
#: build/lib/core/models.py:261 core/models.py:261
msgid "device"
msgstr ""
#: build/lib/core/models.py:210 core/models.py:210
#: build/lib/core/models.py:263 core/models.py:263
msgid "Whether the user is a device or a real user."
msgstr ""
#: build/lib/core/models.py:213 core/models.py:213
#: build/lib/core/models.py:266 core/models.py:266
msgid "staff status"
msgstr ""
#: build/lib/core/models.py:215 core/models.py:215
#: build/lib/core/models.py:268 core/models.py:268
msgid "Whether the user can log into this admin site."
msgstr ""
#: build/lib/core/models.py:218 core/models.py:218
#: build/lib/core/models.py:271 core/models.py:271
msgid "active"
msgstr ""
#: build/lib/core/models.py:221 core/models.py:221
#: build/lib/core/models.py:274 core/models.py:274
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: build/lib/core/models.py:233 core/models.py:233
#: build/lib/core/models.py:286 core/models.py:286
msgid "user"
msgstr ""
#: build/lib/core/models.py:234 core/models.py:234
#: build/lib/core/models.py:287 core/models.py:287
msgid "users"
msgstr ""
#: build/lib/core/models.py:373 build/lib/core/models.py:925 core/models.py:373
#: core/models.py:925
#: build/lib/core/models.py:470 build/lib/core/models.py:1154
#: core/models.py:470 core/models.py:1154
msgid "title"
msgstr ""
#: build/lib/core/models.py:374 core/models.py:374
#: build/lib/core/models.py:471 core/models.py:471
msgid "excerpt"
msgstr ""
#: build/lib/core/models.py:405 core/models.py:405
#: build/lib/core/models.py:519 core/models.py:519
msgid "Document"
msgstr ""
#: build/lib/core/models.py:406 core/models.py:406
#: build/lib/core/models.py:520 core/models.py:520
msgid "Documents"
msgstr ""
#: build/lib/core/models.py:418 core/models.py:418
#: build/lib/core/models.py:532 build/lib/core/models.py:872 core/models.py:532
#: core/models.py:872
msgid "Untitled Document"
msgstr ""
#: build/lib/core/models.py:719 core/models.py:719
#: build/lib/core/models.py:907 core/models.py:907
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
#: build/lib/core/models.py:723 core/models.py:723
#: build/lib/core/models.py:911 core/models.py:911
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr ""
#: build/lib/core/models.py:726 core/models.py:726
#: build/lib/core/models.py:917 core/models.py:917
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr ""
#: build/lib/core/models.py:762 core/models.py:762
msgid "This document is not deleted."
msgstr ""
#: build/lib/core/models.py:769 core/models.py:769
msgid "This document was permanently deleted and cannot be restored."
msgstr ""
#: build/lib/core/models.py:820 core/models.py:820
#: build/lib/core/models.py:1015 core/models.py:1015
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:821 core/models.py:821
#: build/lib/core/models.py:1016 core/models.py:1016
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:827 core/models.py:827
#: build/lib/core/models.py:1022 core/models.py:1022
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:850 core/models.py:850
#: build/lib/core/models.py:1045 core/models.py:1045
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:851 core/models.py:851
#: build/lib/core/models.py:1046 core/models.py:1046
msgid "Document favorites"
msgstr ""
#: build/lib/core/models.py:857 core/models.py:857
#: build/lib/core/models.py:1052 core/models.py:1052
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:879 core/models.py:879
#: build/lib/core/models.py:1074 core/models.py:1074
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:880 core/models.py:880
#: build/lib/core/models.py:1075 core/models.py:1075
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:886 core/models.py:886
#: build/lib/core/models.py:1081 core/models.py:1081
msgid "This user is already in this document."
msgstr ""
#: build/lib/core/models.py:892 core/models.py:892
#: build/lib/core/models.py:1087 core/models.py:1087
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:898 build/lib/core/models.py:1012
#: core/models.py:898 core/models.py:1012
#: build/lib/core/models.py:1093 build/lib/core/models.py:1241
#: core/models.py:1093 core/models.py:1241
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:926 core/models.py:926
#: build/lib/core/models.py:1155 core/models.py:1155
msgid "description"
msgstr ""
#: build/lib/core/models.py:927 core/models.py:927
#: build/lib/core/models.py:1156 core/models.py:1156
msgid "code"
msgstr ""
#: build/lib/core/models.py:928 core/models.py:928
#: build/lib/core/models.py:1157 core/models.py:1157
msgid "css"
msgstr ""
#: build/lib/core/models.py:930 core/models.py:930
#: build/lib/core/models.py:1159 core/models.py:1159
msgid "public"
msgstr ""
#: build/lib/core/models.py:932 core/models.py:932
#: build/lib/core/models.py:1161 core/models.py:1161
msgid "Whether this template is public for anyone to use."
msgstr ""
#: build/lib/core/models.py:938 core/models.py:938
#: build/lib/core/models.py:1167 core/models.py:1167
msgid "Template"
msgstr ""
#: build/lib/core/models.py:939 core/models.py:939
#: build/lib/core/models.py:1168 core/models.py:1168
msgid "Templates"
msgstr ""
#: build/lib/core/models.py:993 core/models.py:993
#: build/lib/core/models.py:1222 core/models.py:1222
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:994 core/models.py:994
#: build/lib/core/models.py:1223 core/models.py:1223
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1000 core/models.py:1000
#: build/lib/core/models.py:1229 core/models.py:1229
msgid "This user is already in this template."
msgstr ""
#: build/lib/core/models.py:1006 core/models.py:1006
#: build/lib/core/models.py:1235 core/models.py:1235
msgid "This team is already in this template."
msgstr ""
#: build/lib/core/models.py:1029 core/models.py:1029
#: build/lib/core/models.py:1258 core/models.py:1258
msgid "email address"
msgstr ""
#: build/lib/core/models.py:1048 core/models.py:1048
#: build/lib/core/models.py:1277 core/models.py:1277
msgid "Document invitation"
msgstr ""
#: build/lib/core/models.py:1049 core/models.py:1049
#: build/lib/core/models.py:1278 core/models.py:1278
msgid "Document invitations"
msgstr ""
#: build/lib/core/models.py:1069 core/models.py:1069
#: build/lib/core/models.py:1298 core/models.py:1298
msgid "This email is already associated to a registered user."
msgstr ""
#: build/lib/impress/settings.py:236 impress/settings.py:236
msgid "English"
msgstr ""
#: build/lib/impress/settings.py:237 impress/settings.py:237
msgid "French"
msgstr ""
#: build/lib/impress/settings.py:238 impress/settings.py:238
msgid "German"
msgstr ""
#: core/templates/mail/html/invitation.html:162
#: core/templates/mail/text/invitation.txt:3
msgid "Logo email"

View File

@@ -0,0 +1,390 @@
msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-04-29 11:48+0000\n"
"PO-Revision-Date: 2025-05-05 07:07\n"
"Last-Translator: \n"
"Language-Team: Spanish\n"
"Language: es_ES\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Crowdin-Project: lasuite-docs\n"
"X-Crowdin-Project-ID: 754523\n"
"X-Crowdin-Language: es-ES\n"
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:37 core/admin.py:37
msgid "Personal info"
msgstr "Información Personal"
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
msgid "Permissions"
msgstr "Permisos"
#: build/lib/core/admin.py:62 core/admin.py:62
msgid "Important dates"
msgstr "Fechas importantes"
#: build/lib/core/admin.py:148 core/admin.py:148
msgid "Tree structure"
msgstr "Estructura en árbol"
#: build/lib/core/api/filters.py:47 core/api/filters.py:47
msgid "Title"
msgstr "Título"
#: build/lib/core/api/filters.py:61 core/api/filters.py:61
msgid "Creator is me"
msgstr "Yo soy el creador"
#: build/lib/core/api/filters.py:64 core/api/filters.py:64
msgid "Favorite"
msgstr "Favorito"
#: build/lib/core/api/serializers.py:446 core/api/serializers.py:446
msgid "A new document was created on your behalf!"
msgstr "¡Un nuevo documento se ha creado por ti!"
#: build/lib/core/api/serializers.py:450 core/api/serializers.py:450
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:586 core/api/serializers.py:586
msgid "Body"
msgstr "Cuerpo"
#: build/lib/core/api/serializers.py:589 core/api/serializers.py:589
msgid "Body type"
msgstr "Tipo de Cuerpo"
#: build/lib/core/api/serializers.py:595 core/api/serializers.py:595
msgid "Format"
msgstr "Formato"
#: build/lib/core/api/viewsets.py:966 core/api/viewsets.py:966
#, python-brace-format
msgid "copy of {title}"
msgstr "copia de {title}"
#: build/lib/core/enums.py:35 core/enums.py:35
msgid "First child"
msgstr "Primer nodo"
#: build/lib/core/enums.py:36 core/enums.py:36
msgid "Last child"
msgstr "Último nodo"
#: build/lib/core/enums.py:37 core/enums.py:37
msgid "First sibling"
msgstr "Primera relación"
#: build/lib/core/enums.py:38 core/enums.py:38
msgid "Last sibling"
msgstr "Última relación"
#: build/lib/core/enums.py:39 core/enums.py:39
msgid "Left"
msgstr "Izquierda"
#: build/lib/core/enums.py:40 core/enums.py:40
msgid "Right"
msgstr "Derecha"
#: build/lib/core/models.py:56 build/lib/core/models.py:63 core/models.py:56
#: core/models.py:63
msgid "Reader"
msgstr "Lector"
#: build/lib/core/models.py:57 build/lib/core/models.py:64 core/models.py:57
#: core/models.py:64
msgid "Editor"
msgstr "Editor"
#: build/lib/core/models.py:65 core/models.py:65
msgid "Administrator"
msgstr "Administrador"
#: build/lib/core/models.py:66 core/models.py:66
msgid "Owner"
msgstr "Propietario"
#: build/lib/core/models.py:77 core/models.py:77
msgid "Restricted"
msgstr "Restringido"
#: build/lib/core/models.py:81 core/models.py:81
msgid "Authenticated"
msgstr "Autentificado"
#: build/lib/core/models.py:83 core/models.py:83
msgid "Public"
msgstr "Público"
#: build/lib/core/models.py:154 core/models.py:154
msgid "id"
msgstr "id"
#: build/lib/core/models.py:155 core/models.py:155
msgid "primary key for the record as UUID"
msgstr "clave primaria para el registro como UUID"
#: build/lib/core/models.py:161 core/models.py:161
msgid "created on"
msgstr "creado el"
#: build/lib/core/models.py:162 core/models.py:162
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:167 core/models.py:167
msgid "updated on"
msgstr "actualizado el"
#: build/lib/core/models.py:168 core/models.py:168
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:204 core/models.py:204
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:217 core/models.py:217
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
msgstr "Introduzca un sub (UUID) válido. Este valor solo puede contener letras, números y los siguientes caracteres @/./+/-/_/:"
#: build/lib/core/models.py:223 core/models.py:223
msgid "sub"
msgstr "sub (UUID)"
#: build/lib/core/models.py:225 core/models.py:225
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
msgstr "Requerido. 255 caracteres o menos. Letras, números y los siguientes caracteres @/./+/-/_/: solamente."
#: build/lib/core/models.py:234 core/models.py:234
msgid "full name"
msgstr "nombre completo"
#: build/lib/core/models.py:235 core/models.py:235
msgid "short name"
msgstr "nombre abreviado"
#: build/lib/core/models.py:237 core/models.py:237
msgid "identity email address"
msgstr "correo electrónico de identidad"
#: build/lib/core/models.py:242 core/models.py:242
msgid "admin email address"
msgstr "correo electrónico del administrador"
#: build/lib/core/models.py:249 core/models.py:249
msgid "language"
msgstr "idioma"
#: build/lib/core/models.py:250 core/models.py:250
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:258 core/models.py:258
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:261 core/models.py:261
msgid "device"
msgstr "dispositivo"
#: build/lib/core/models.py:263 core/models.py:263
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:266 core/models.py:266
msgid "staff status"
msgstr "rol en el equipo"
#: build/lib/core/models.py:268 core/models.py:268
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:271 core/models.py:271
msgid "active"
msgstr "activo"
#: build/lib/core/models.py:274 core/models.py:274
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:286 core/models.py:286
msgid "user"
msgstr "usuario"
#: build/lib/core/models.py:287 core/models.py:287
msgid "users"
msgstr "usuarios"
#: build/lib/core/models.py:470 build/lib/core/models.py:1154
#: core/models.py:470 core/models.py:1154
msgid "title"
msgstr "título"
#: build/lib/core/models.py:471 core/models.py:471
msgid "excerpt"
msgstr "resumen"
#: build/lib/core/models.py:519 core/models.py:519
msgid "Document"
msgstr "Documento"
#: build/lib/core/models.py:520 core/models.py:520
msgid "Documents"
msgstr "Documentos"
#: build/lib/core/models.py:532 build/lib/core/models.py:872 core/models.py:532
#: core/models.py:872
msgid "Untitled Document"
msgstr "Documento sin título"
#: build/lib/core/models.py:907 core/models.py:907
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "¡{name} ha compartido un documento contigo!"
#: build/lib/core/models.py:911 core/models.py:911
#, 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:917 core/models.py:917
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} ha compartido un documento contigo: {title}"
#: build/lib/core/models.py:1015 core/models.py:1015
msgid "Document/user link trace"
msgstr "Traza del enlace de documento/usuario"
#: build/lib/core/models.py:1016 core/models.py:1016
msgid "Document/user link traces"
msgstr "Trazas del enlace de documento/usuario"
#: build/lib/core/models.py:1022 core/models.py:1022
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:1045 core/models.py:1045
msgid "Document favorite"
msgstr "Documento favorito"
#: build/lib/core/models.py:1046 core/models.py:1046
msgid "Document favorites"
msgstr "Documentos favoritos"
#: build/lib/core/models.py:1052 core/models.py:1052
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:1074 core/models.py:1074
msgid "Document/user relation"
msgstr "Relación documento/usuario"
#: build/lib/core/models.py:1075 core/models.py:1075
msgid "Document/user relations"
msgstr "Relaciones documento/usuario"
#: build/lib/core/models.py:1081 core/models.py:1081
msgid "This user is already in this document."
msgstr "Este usuario ya forma parte del documento."
#: build/lib/core/models.py:1087 core/models.py:1087
msgid "This team is already in this document."
msgstr "Este equipo ya forma parte del documento."
#: build/lib/core/models.py:1093 build/lib/core/models.py:1241
#: core/models.py:1093 core/models.py:1241
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:1155 core/models.py:1155
msgid "description"
msgstr "descripción"
#: build/lib/core/models.py:1156 core/models.py:1156
msgid "code"
msgstr "código"
#: build/lib/core/models.py:1157 core/models.py:1157
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1159 core/models.py:1159
msgid "public"
msgstr "público"
#: build/lib/core/models.py:1161 core/models.py:1161
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:1167 core/models.py:1167
msgid "Template"
msgstr "Plantilla"
#: build/lib/core/models.py:1168 core/models.py:1168
msgid "Templates"
msgstr "Plantillas"
#: build/lib/core/models.py:1222 core/models.py:1222
msgid "Template/user relation"
msgstr "Relación plantilla/usuario"
#: build/lib/core/models.py:1223 core/models.py:1223
msgid "Template/user relations"
msgstr "Relaciones plantilla/usuario"
#: build/lib/core/models.py:1229 core/models.py:1229
msgid "This user is already in this template."
msgstr "Este usuario ya forma parte de la plantilla."
#: build/lib/core/models.py:1235 core/models.py:1235
msgid "This team is already in this template."
msgstr "Este equipo ya se encuentra en esta plantilla."
#: build/lib/core/models.py:1258 core/models.py:1258
msgid "email address"
msgstr "dirección de correo electrónico"
#: build/lib/core/models.py:1277 core/models.py:1277
msgid "Document invitation"
msgstr "Invitación al documento"
#: build/lib/core/models.py:1278 core/models.py:1278
msgid "Document invitations"
msgstr "Invitaciones a documentos"
#: build/lib/core/models.py:1298 core/models.py:1298
msgid "This email is already associated to a registered user."
msgstr "Este correo electrónico está asociado a un usuario registrado."
#: core/templates/mail/html/invitation.html:162
#: core/templates/mail/text/invitation.txt:3
msgid "Logo email"
msgstr "Logo de correo electrónico"
#: core/templates/mail/html/invitation.html:209
#: core/templates/mail/text/invitation.txt:10
msgid "Open"
msgstr "Abrir"
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.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/invitation.html:233
#: core/templates/mail/text/invitation.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
msgstr " Presentado por %(brandname)s "

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-02-06 15:30+0000\n"
"PO-Revision-Date: 2025-02-10 14:14\n"
"POT-Creation-Date: 2025-04-29 11:48+0000\n"
"PO-Revision-Date: 2025-05-05 09:05\n"
"Last-Translator: \n"
"Language-Team: French\n"
"Language: fr_FR\n"
@@ -24,7 +24,7 @@ msgstr "Infos Personnelles"
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
msgid "Permissions"
msgstr ""
msgstr "Permissions"
#: build/lib/core/admin.py:62 core/admin.py:62
msgid "Important dates"
@@ -32,364 +32,340 @@ msgstr "Dates importantes"
#: build/lib/core/admin.py:148 core/admin.py:148
msgid "Tree structure"
msgstr ""
msgstr "Arborescence"
#: build/lib/core/api/filters.py:16 core/api/filters.py:16
msgid "Creator is me"
msgstr ""
#: build/lib/core/api/filters.py:19 core/api/filters.py:19
msgid "Favorite"
msgstr ""
#: build/lib/core/api/filters.py:22 core/api/filters.py:22
#: build/lib/core/api/filters.py:47 core/api/filters.py:47
msgid "Title"
msgstr ""
msgstr "Titre"
#: build/lib/core/api/serializers.py:346 core/api/serializers.py:346
#: build/lib/core/api/filters.py:61 core/api/filters.py:61
msgid "Creator is me"
msgstr "Je suis l'auteur"
#: build/lib/core/api/filters.py:64 core/api/filters.py:64
msgid "Favorite"
msgstr "Favoris"
#: build/lib/core/api/serializers.py:446 core/api/serializers.py:446
msgid "A new document was created on your behalf!"
msgstr "Un nouveau document a été créé pour vous !"
#: build/lib/core/api/serializers.py:350 core/api/serializers.py:350
#: build/lib/core/api/serializers.py:450 core/api/serializers.py:450
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:453 core/api/serializers.py:453
#: build/lib/core/api/serializers.py:586 core/api/serializers.py:586
msgid "Body"
msgstr ""
msgstr "Corps"
#: build/lib/core/api/serializers.py:456 core/api/serializers.py:456
#: build/lib/core/api/serializers.py:589 core/api/serializers.py:589
msgid "Body type"
msgstr ""
msgstr "Type de corps"
#: build/lib/core/api/serializers.py:462 core/api/serializers.py:462
#: build/lib/core/api/serializers.py:595 core/api/serializers.py:595
msgid "Format"
msgstr ""
msgstr "Format"
#: build/lib/core/authentication/backends.py:61
#: core/authentication/backends.py:61
msgid "Invalid response format or token verification failed"
msgstr ""
#: build/lib/core/api/viewsets.py:966 core/api/viewsets.py:966
#, python-brace-format
msgid "copy of {title}"
msgstr "copie de {title}"
#: build/lib/core/authentication/backends.py:108
#: core/authentication/backends.py:108
msgid "User account is disabled"
msgstr ""
#: build/lib/core/enums.py:19 core/enums.py:19
#: build/lib/core/enums.py:35 core/enums.py:35
msgid "First child"
msgstr ""
msgstr "Premier enfant"
#: build/lib/core/enums.py:20 core/enums.py:20
#: build/lib/core/enums.py:36 core/enums.py:36
msgid "Last child"
msgstr ""
msgstr "Dernier enfant"
#: build/lib/core/enums.py:21 core/enums.py:21
#: build/lib/core/enums.py:37 core/enums.py:37
msgid "First sibling"
msgstr ""
msgstr "Premier frère ou sœur"
#: build/lib/core/enums.py:22 core/enums.py:22
#: build/lib/core/enums.py:38 core/enums.py:38
msgid "Last sibling"
msgstr ""
msgstr "Dernière relation"
#: build/lib/core/enums.py:23 core/enums.py:23
#: build/lib/core/enums.py:39 core/enums.py:39
msgid "Left"
msgstr ""
msgstr "Gauche"
#: build/lib/core/enums.py:24 core/enums.py:24
#: build/lib/core/enums.py:40 core/enums.py:40
msgid "Right"
msgstr ""
msgstr "Droite"
#: build/lib/core/models.py:54 build/lib/core/models.py:61 core/models.py:54
#: core/models.py:61
#: build/lib/core/models.py:56 build/lib/core/models.py:63 core/models.py:56
#: core/models.py:63
msgid "Reader"
msgstr "Lecteur"
#: build/lib/core/models.py:55 build/lib/core/models.py:62 core/models.py:55
#: core/models.py:62
#: build/lib/core/models.py:57 build/lib/core/models.py:64 core/models.py:57
#: core/models.py:64
msgid "Editor"
msgstr "Éditeur"
#: build/lib/core/models.py:63 core/models.py:63
#: build/lib/core/models.py:65 core/models.py:65
msgid "Administrator"
msgstr "Administrateur"
#: build/lib/core/models.py:64 core/models.py:64
#: build/lib/core/models.py:66 core/models.py:66
msgid "Owner"
msgstr "Propriétaire"
#: build/lib/core/models.py:75 core/models.py:75
#: build/lib/core/models.py:77 core/models.py:77
msgid "Restricted"
msgstr "Restreint"
#: build/lib/core/models.py:79 core/models.py:79
#: build/lib/core/models.py:81 core/models.py:81
msgid "Authenticated"
msgstr "Authentifié"
#: build/lib/core/models.py:81 core/models.py:81
#: build/lib/core/models.py:83 core/models.py:83
msgid "Public"
msgstr ""
msgstr "Public"
#: build/lib/core/models.py:103 core/models.py:103
#: build/lib/core/models.py:154 core/models.py:154
msgid "id"
msgstr ""
msgstr "identifiant/id"
#: build/lib/core/models.py:104 core/models.py:104
#: build/lib/core/models.py:155 core/models.py:155
msgid "primary key for the record as UUID"
msgstr ""
msgstr "clé primaire pour l'enregistrement en tant que UUID"
#: build/lib/core/models.py:110 core/models.py:110
#: build/lib/core/models.py:161 core/models.py:161
msgid "created on"
msgstr ""
msgstr "créé le"
#: build/lib/core/models.py:111 core/models.py:111
#: build/lib/core/models.py:162 core/models.py:162
msgid "date and time at which a record was created"
msgstr ""
msgstr "date et heure de création de l'enregistrement"
#: build/lib/core/models.py:116 core/models.py:116
#: build/lib/core/models.py:167 core/models.py:167
msgid "updated on"
msgstr ""
msgstr "mis à jour le"
#: build/lib/core/models.py:117 core/models.py:117
#: build/lib/core/models.py:168 core/models.py:168
msgid "date and time at which a record was last updated"
msgstr ""
msgstr "date et heure de la dernière mise à jour de l'enregistrement"
#: build/lib/core/models.py:153 core/models.py:153
#: build/lib/core/models.py:204 core/models.py:204
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
msgstr ""
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:166 core/models.py:166
#: build/lib/core/models.py:217 core/models.py:217
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
msgstr ""
msgstr "Saisissez un sous-groupe valide. Cette valeur ne peut contenir que des lettres, des chiffres et les caractères @/./+/-/_/: uniquement."
#: build/lib/core/models.py:172 core/models.py:172
#: build/lib/core/models.py:223 core/models.py:223
msgid "sub"
msgstr ""
msgstr "sous-groupe"
#: build/lib/core/models.py:174 core/models.py:174
#: build/lib/core/models.py:225 core/models.py:225
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
msgstr ""
#: build/lib/core/models.py:183 core/models.py:183
msgid "full name"
msgstr ""
#: build/lib/core/models.py:184 core/models.py:184
msgid "short name"
msgstr ""
#: build/lib/core/models.py:186 core/models.py:186
msgid "identity email address"
msgstr ""
#: build/lib/core/models.py:191 core/models.py:191
msgid "admin email address"
msgstr ""
#: build/lib/core/models.py:198 core/models.py:198
msgid "language"
msgstr ""
#: build/lib/core/models.py:199 core/models.py:199
msgid "The language in which the user wants to see the interface."
msgstr ""
#: build/lib/core/models.py:205 core/models.py:205
msgid "The timezone in which the user wants to see times."
msgstr ""
#: build/lib/core/models.py:208 core/models.py:208
msgid "device"
msgstr ""
#: build/lib/core/models.py:210 core/models.py:210
msgid "Whether the user is a device or a real user."
msgstr ""
#: build/lib/core/models.py:213 core/models.py:213
msgid "staff status"
msgstr ""
#: build/lib/core/models.py:215 core/models.py:215
msgid "Whether the user can log into this admin site."
msgstr ""
#: build/lib/core/models.py:218 core/models.py:218
msgid "active"
msgstr ""
#: build/lib/core/models.py:221 core/models.py:221
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: build/lib/core/models.py:233 core/models.py:233
msgid "user"
msgstr ""
msgstr "Obligatoire. 255 caractères ou moins. Lettres, chiffres et caractères @/./+/-/_/: uniquement."
#: build/lib/core/models.py:234 core/models.py:234
msgid "full name"
msgstr "nom complet"
#: build/lib/core/models.py:235 core/models.py:235
msgid "short name"
msgstr "nom court"
#: build/lib/core/models.py:237 core/models.py:237
msgid "identity email address"
msgstr "adresse e-mail d'identité"
#: build/lib/core/models.py:242 core/models.py:242
msgid "admin email address"
msgstr "adresse e-mail de l'administrateur"
#: build/lib/core/models.py:249 core/models.py:249
msgid "language"
msgstr "langue"
#: build/lib/core/models.py:250 core/models.py:250
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:258 core/models.py:258
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:261 core/models.py:261
msgid "device"
msgstr "appareil"
#: build/lib/core/models.py:263 core/models.py:263
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:266 core/models.py:266
msgid "staff status"
msgstr "statut d'équipe"
#: build/lib/core/models.py:268 core/models.py:268
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:271 core/models.py:271
msgid "active"
msgstr "actif"
#: build/lib/core/models.py:274 core/models.py:274
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:286 core/models.py:286
msgid "user"
msgstr "utilisateur"
#: build/lib/core/models.py:287 core/models.py:287
msgid "users"
msgstr ""
msgstr "utilisateurs"
#: build/lib/core/models.py:373 build/lib/core/models.py:925 core/models.py:373
#: core/models.py:925
#: build/lib/core/models.py:470 build/lib/core/models.py:1154
#: core/models.py:470 core/models.py:1154
msgid "title"
msgstr ""
msgstr "titre"
#: build/lib/core/models.py:374 core/models.py:374
#: build/lib/core/models.py:471 core/models.py:471
msgid "excerpt"
msgstr ""
msgstr "extrait"
#: build/lib/core/models.py:405 core/models.py:405
#: build/lib/core/models.py:519 core/models.py:519
msgid "Document"
msgstr ""
msgstr "Document"
#: build/lib/core/models.py:406 core/models.py:406
#: build/lib/core/models.py:520 core/models.py:520
msgid "Documents"
msgstr ""
msgstr "Documents"
#: build/lib/core/models.py:418 core/models.py:418
#: build/lib/core/models.py:532 build/lib/core/models.py:872 core/models.py:532
#: core/models.py:872
msgid "Untitled Document"
msgstr ""
msgstr "Document sans titre"
#: build/lib/core/models.py:719 core/models.py:719
#: build/lib/core/models.py:907 core/models.py:907
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} a partagé un document avec vous!"
#: build/lib/core/models.py:723 core/models.py:723
#: build/lib/core/models.py:911 core/models.py:911
#, 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:"
msgstr "{name} vous a invité avec le rôle \"{role}\" sur le document suivant :"
#: build/lib/core/models.py:726 core/models.py:726
#: build/lib/core/models.py:917 core/models.py:917
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} a partagé un document avec vous: {title}"
msgstr "{name} a partagé un document avec vous : {title}"
#: build/lib/core/models.py:762 core/models.py:762
msgid "This document is not deleted."
msgstr ""
#: build/lib/core/models.py:769 core/models.py:769
msgid "This document was permanently deleted and cannot be restored."
msgstr ""
#: build/lib/core/models.py:820 core/models.py:820
#: build/lib/core/models.py:1015 core/models.py:1015
msgid "Document/user link trace"
msgstr ""
msgstr "Trace du lien document/utilisateur"
#: build/lib/core/models.py:821 core/models.py:821
#: build/lib/core/models.py:1016 core/models.py:1016
msgid "Document/user link traces"
msgstr ""
msgstr "Traces du lien document/utilisateur"
#: build/lib/core/models.py:827 core/models.py:827
#: build/lib/core/models.py:1022 core/models.py:1022
msgid "A link trace already exists for this document/user."
msgstr ""
msgstr "Une trace de lien existe déjà pour ce document/utilisateur."
#: build/lib/core/models.py:850 core/models.py:850
#: build/lib/core/models.py:1045 core/models.py:1045
msgid "Document favorite"
msgstr ""
msgstr "Document favori"
#: build/lib/core/models.py:851 core/models.py:851
#: build/lib/core/models.py:1046 core/models.py:1046
msgid "Document favorites"
msgstr ""
msgstr "Documents favoris"
#: build/lib/core/models.py:857 core/models.py:857
#: build/lib/core/models.py:1052 core/models.py:1052
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
msgstr "Ce document est déjà un favori de cet utilisateur."
#: build/lib/core/models.py:879 core/models.py:879
#: build/lib/core/models.py:1074 core/models.py:1074
msgid "Document/user relation"
msgstr ""
msgstr "Relation document/utilisateur"
#: build/lib/core/models.py:880 core/models.py:880
#: build/lib/core/models.py:1075 core/models.py:1075
msgid "Document/user relations"
msgstr ""
msgstr "Relations document/utilisateur"
#: build/lib/core/models.py:886 core/models.py:886
#: build/lib/core/models.py:1081 core/models.py:1081
msgid "This user is already in this document."
msgstr ""
msgstr "Cet utilisateur est déjà dans ce document."
#: build/lib/core/models.py:892 core/models.py:892
#: build/lib/core/models.py:1087 core/models.py:1087
msgid "This team is already in this document."
msgstr ""
msgstr "Cette équipe est déjà dans ce document."
#: build/lib/core/models.py:898 build/lib/core/models.py:1012
#: core/models.py:898 core/models.py:1012
#: build/lib/core/models.py:1093 build/lib/core/models.py:1241
#: core/models.py:1093 core/models.py:1241
msgid "Either user or team must be set, not both."
msgstr ""
msgstr "L'utilisateur ou l'équipe doivent être définis, pas les deux."
#: build/lib/core/models.py:926 core/models.py:926
#: build/lib/core/models.py:1155 core/models.py:1155
msgid "description"
msgstr ""
msgstr "description"
#: build/lib/core/models.py:927 core/models.py:927
#: build/lib/core/models.py:1156 core/models.py:1156
msgid "code"
msgstr ""
msgstr "code"
#: build/lib/core/models.py:928 core/models.py:928
#: build/lib/core/models.py:1157 core/models.py:1157
msgid "css"
msgstr ""
msgstr "CSS"
#: build/lib/core/models.py:930 core/models.py:930
#: build/lib/core/models.py:1159 core/models.py:1159
msgid "public"
msgstr ""
msgstr "public"
#: build/lib/core/models.py:932 core/models.py:932
#: build/lib/core/models.py:1161 core/models.py:1161
msgid "Whether this template is public for anyone to use."
msgstr ""
msgstr "Si ce modèle est public, utilisable par n'importe qui."
#: build/lib/core/models.py:938 core/models.py:938
#: build/lib/core/models.py:1167 core/models.py:1167
msgid "Template"
msgstr ""
msgstr "Modèle"
#: build/lib/core/models.py:939 core/models.py:939
#: build/lib/core/models.py:1168 core/models.py:1168
msgid "Templates"
msgstr ""
msgstr "Modèles"
#: build/lib/core/models.py:993 core/models.py:993
#: build/lib/core/models.py:1222 core/models.py:1222
msgid "Template/user relation"
msgstr ""
msgstr "Relation modèle/utilisateur"
#: build/lib/core/models.py:994 core/models.py:994
#: build/lib/core/models.py:1223 core/models.py:1223
msgid "Template/user relations"
msgstr ""
msgstr "Relations modèle/utilisateur"
#: build/lib/core/models.py:1000 core/models.py:1000
#: build/lib/core/models.py:1229 core/models.py:1229
msgid "This user is already in this template."
msgstr ""
msgstr "Cet utilisateur est déjà dans ce modèle."
#: build/lib/core/models.py:1006 core/models.py:1006
#: build/lib/core/models.py:1235 core/models.py:1235
msgid "This team is already in this template."
msgstr ""
msgstr "Cette équipe est déjà modèle."
#: build/lib/core/models.py:1029 core/models.py:1029
#: build/lib/core/models.py:1258 core/models.py:1258
msgid "email address"
msgstr ""
msgstr "adresse e-mail"
#: build/lib/core/models.py:1048 core/models.py:1048
#: build/lib/core/models.py:1277 core/models.py:1277
msgid "Document invitation"
msgstr ""
msgstr "Invitation à un document"
#: build/lib/core/models.py:1049 core/models.py:1049
#: build/lib/core/models.py:1278 core/models.py:1278
msgid "Document invitations"
msgstr ""
msgstr "Invitations à un document"
#: build/lib/core/models.py:1069 core/models.py:1069
#: build/lib/core/models.py:1298 core/models.py:1298
msgid "This email is already associated to a registered user."
msgstr ""
#: build/lib/impress/settings.py:236 impress/settings.py:236
msgid "English"
msgstr ""
#: build/lib/impress/settings.py:237 impress/settings.py:237
msgid "French"
msgstr ""
#: build/lib/impress/settings.py:238 impress/settings.py:238
msgid "German"
msgstr ""
msgstr "Cette adresse email est déjà associée à un utilisateur inscrit."
#: core/templates/mail/html/invitation.html:162
#: core/templates/mail/text/invitation.txt:3

View File

@@ -0,0 +1,390 @@
msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-04-29 11:48+0000\n"
"PO-Revision-Date: 2025-05-05 07:07\n"
"Last-Translator: \n"
"Language-Team: Italian\n"
"Language: it_IT\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Crowdin-Project: lasuite-docs\n"
"X-Crowdin-Project-ID: 754523\n"
"X-Crowdin-Language: it\n"
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:37 core/admin.py:37
msgid "Personal info"
msgstr "Informazioni personali"
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
msgid "Permissions"
msgstr "Permessi"
#: build/lib/core/admin.py:62 core/admin.py:62
msgid "Important dates"
msgstr "Date importanti"
#: build/lib/core/admin.py:148 core/admin.py:148
msgid "Tree structure"
msgstr "Struttura ad albero"
#: build/lib/core/api/filters.py:47 core/api/filters.py:47
msgid "Title"
msgstr "Titolo"
#: build/lib/core/api/filters.py:61 core/api/filters.py:61
msgid "Creator is me"
msgstr "Il creatore sono io"
#: build/lib/core/api/filters.py:64 core/api/filters.py:64
msgid "Favorite"
msgstr "Preferiti"
#: build/lib/core/api/serializers.py:446 core/api/serializers.py:446
msgid "A new document was created on your behalf!"
msgstr "Un nuovo documento è stato creato a tuo nome!"
#: build/lib/core/api/serializers.py:450 core/api/serializers.py:450
msgid "You have been granted ownership of a new document:"
msgstr "Sei ora proprietario di un nuovo documento:"
#: build/lib/core/api/serializers.py:586 core/api/serializers.py:586
msgid "Body"
msgstr "Corpo"
#: build/lib/core/api/serializers.py:589 core/api/serializers.py:589
msgid "Body type"
msgstr ""
#: build/lib/core/api/serializers.py:595 core/api/serializers.py:595
msgid "Format"
msgstr "Formato"
#: build/lib/core/api/viewsets.py:966 core/api/viewsets.py:966
#, python-brace-format
msgid "copy of {title}"
msgstr "copia di {title}"
#: build/lib/core/enums.py:35 core/enums.py:35
msgid "First child"
msgstr ""
#: build/lib/core/enums.py:36 core/enums.py:36
msgid "Last child"
msgstr ""
#: build/lib/core/enums.py:37 core/enums.py:37
msgid "First sibling"
msgstr ""
#: build/lib/core/enums.py:38 core/enums.py:38
msgid "Last sibling"
msgstr ""
#: build/lib/core/enums.py:39 core/enums.py:39
msgid "Left"
msgstr "Sinistra"
#: build/lib/core/enums.py:40 core/enums.py:40
msgid "Right"
msgstr "Destra"
#: build/lib/core/models.py:56 build/lib/core/models.py:63 core/models.py:56
#: core/models.py:63
msgid "Reader"
msgstr "Lettore"
#: build/lib/core/models.py:57 build/lib/core/models.py:64 core/models.py:57
#: core/models.py:64
msgid "Editor"
msgstr "Editor"
#: build/lib/core/models.py:65 core/models.py:65
msgid "Administrator"
msgstr "Amministratore"
#: build/lib/core/models.py:66 core/models.py:66
msgid "Owner"
msgstr "Proprietario"
#: build/lib/core/models.py:77 core/models.py:77
msgid "Restricted"
msgstr "Limitato"
#: build/lib/core/models.py:81 core/models.py:81
msgid "Authenticated"
msgstr "Autenticato"
#: build/lib/core/models.py:83 core/models.py:83
msgid "Public"
msgstr "Pubblico"
#: build/lib/core/models.py:154 core/models.py:154
msgid "id"
msgstr "Id"
#: build/lib/core/models.py:155 core/models.py:155
msgid "primary key for the record as UUID"
msgstr "chiave primaria per il record come UUID"
#: build/lib/core/models.py:161 core/models.py:161
msgid "created on"
msgstr "creato il"
#: build/lib/core/models.py:162 core/models.py:162
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:167 core/models.py:167
msgid "updated on"
msgstr "aggiornato il"
#: build/lib/core/models.py:168 core/models.py:168
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:204 core/models.py:204
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:217 core/models.py:217
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
msgstr ""
#: build/lib/core/models.py:223 core/models.py:223
msgid "sub"
msgstr ""
#: build/lib/core/models.py:225 core/models.py:225
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
msgstr "Richiesto. 255 caratteri o meno. Solo lettere, numeri e @/./+/-/_/: caratteri."
#: build/lib/core/models.py:234 core/models.py:234
msgid "full name"
msgstr "nome completo"
#: build/lib/core/models.py:235 core/models.py:235
msgid "short name"
msgstr "nome"
#: build/lib/core/models.py:237 core/models.py:237
msgid "identity email address"
msgstr "indirizzo email di identità"
#: build/lib/core/models.py:242 core/models.py:242
msgid "admin email address"
msgstr "Indirizzo email dell'amministratore"
#: build/lib/core/models.py:249 core/models.py:249
msgid "language"
msgstr "lingua"
#: build/lib/core/models.py:250 core/models.py:250
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:258 core/models.py:258
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:261 core/models.py:261
msgid "device"
msgstr "dispositivo"
#: build/lib/core/models.py:263 core/models.py:263
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:266 core/models.py:266
msgid "staff status"
msgstr "stato del personale"
#: build/lib/core/models.py:268 core/models.py:268
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:271 core/models.py:271
msgid "active"
msgstr "attivo"
#: build/lib/core/models.py:274 core/models.py:274
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:286 core/models.py:286
msgid "user"
msgstr "utente"
#: build/lib/core/models.py:287 core/models.py:287
msgid "users"
msgstr "utenti"
#: build/lib/core/models.py:470 build/lib/core/models.py:1154
#: core/models.py:470 core/models.py:1154
msgid "title"
msgstr "titolo"
#: build/lib/core/models.py:471 core/models.py:471
msgid "excerpt"
msgstr ""
#: build/lib/core/models.py:519 core/models.py:519
msgid "Document"
msgstr "Documento"
#: build/lib/core/models.py:520 core/models.py:520
msgid "Documents"
msgstr "Documenti"
#: build/lib/core/models.py:532 build/lib/core/models.py:872 core/models.py:532
#: core/models.py:872
msgid "Untitled Document"
msgstr "Documento senza titolo"
#: build/lib/core/models.py:907 core/models.py:907
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} ha condiviso un documento con te!"
#: build/lib/core/models.py:911 core/models.py:911
#, 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:917 core/models.py:917
#, 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:1015 core/models.py:1015
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:1016 core/models.py:1016
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:1022 core/models.py:1022
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:1045 core/models.py:1045
msgid "Document favorite"
msgstr "Documento preferito"
#: build/lib/core/models.py:1046 core/models.py:1046
msgid "Document favorites"
msgstr "Documenti preferiti"
#: build/lib/core/models.py:1052 core/models.py:1052
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:1074 core/models.py:1074
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:1075 core/models.py:1075
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:1081 core/models.py:1081
msgid "This user is already in this document."
msgstr "Questo utente è già presente in questo documento."
#: build/lib/core/models.py:1087 core/models.py:1087
msgid "This team is already in this document."
msgstr "Questo team è già presente in questo documento."
#: build/lib/core/models.py:1093 build/lib/core/models.py:1241
#: core/models.py:1093 core/models.py:1241
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:1155 core/models.py:1155
msgid "description"
msgstr "descrizione"
#: build/lib/core/models.py:1156 core/models.py:1156
msgid "code"
msgstr "code"
#: build/lib/core/models.py:1157 core/models.py:1157
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1159 core/models.py:1159
msgid "public"
msgstr "pubblico"
#: build/lib/core/models.py:1161 core/models.py:1161
msgid "Whether this template is public for anyone to use."
msgstr "Indica se questo modello è pubblico per chiunque."
#: build/lib/core/models.py:1167 core/models.py:1167
msgid "Template"
msgstr "Modello"
#: build/lib/core/models.py:1168 core/models.py:1168
msgid "Templates"
msgstr "Modelli"
#: build/lib/core/models.py:1222 core/models.py:1222
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1223 core/models.py:1223
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1229 core/models.py:1229
msgid "This user is already in this template."
msgstr "Questo utente è già in questo modello."
#: build/lib/core/models.py:1235 core/models.py:1235
msgid "This team is already in this template."
msgstr "Questo team è già in questo modello."
#: build/lib/core/models.py:1258 core/models.py:1258
msgid "email address"
msgstr "indirizzo e-mail"
#: build/lib/core/models.py:1277 core/models.py:1277
msgid "Document invitation"
msgstr "Invito al documento"
#: build/lib/core/models.py:1278 core/models.py:1278
msgid "Document invitations"
msgstr "Inviti al documento"
#: build/lib/core/models.py:1298 core/models.py:1298
msgid "This email is already associated to a registered user."
msgstr "Questa email è già associata a un utente registrato."
#: core/templates/mail/html/invitation.html:162
#: core/templates/mail/text/invitation.txt:3
msgid "Logo email"
msgstr "Logo e-mail"
#: core/templates/mail/html/invitation.html:209
#: core/templates/mail/text/invitation.txt:10
msgid "Open"
msgstr "Apri"
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr ""
#: core/templates/mail/html/invitation.html:233
#: core/templates/mail/text/invitation.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
msgstr ""

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-02-06 15:30+0000\n"
"PO-Revision-Date: 2025-02-10 14:14\n"
"POT-Creation-Date: 2025-04-29 11:48+0000\n"
"PO-Revision-Date: 2025-05-05 07:07\n"
"Last-Translator: \n"
"Language-Team: Dutch\n"
"Language: nl_NL\n"
@@ -19,396 +19,372 @@ msgstr ""
#: build/lib/core/admin.py:37 core/admin.py:37
msgid "Personal info"
msgstr ""
msgstr "Persoonlijke informatie"
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
msgid "Permissions"
msgstr ""
msgstr "Toestemmingen"
#: build/lib/core/admin.py:62 core/admin.py:62
msgid "Important dates"
msgstr ""
msgstr "Belangrijke datums"
#: build/lib/core/admin.py:148 core/admin.py:148
msgid "Tree structure"
msgstr ""
msgstr "Document structuur"
#: build/lib/core/api/filters.py:16 core/api/filters.py:16
msgid "Creator is me"
msgstr ""
#: build/lib/core/api/filters.py:19 core/api/filters.py:19
msgid "Favorite"
msgstr ""
#: build/lib/core/api/filters.py:22 core/api/filters.py:22
#: build/lib/core/api/filters.py:47 core/api/filters.py:47
msgid "Title"
msgstr ""
msgstr "Titel"
#: build/lib/core/api/serializers.py:346 core/api/serializers.py:346
#: build/lib/core/api/filters.py:61 core/api/filters.py:61
msgid "Creator is me"
msgstr "Ik ben Eigenaar"
#: build/lib/core/api/filters.py:64 core/api/filters.py:64
msgid "Favorite"
msgstr "Favoriete"
#: build/lib/core/api/serializers.py:446 core/api/serializers.py:446
msgid "A new document was created on your behalf!"
msgstr ""
msgstr "Een nieuw document was gecreëerd voor u!"
#: build/lib/core/api/serializers.py:350 core/api/serializers.py:350
#: build/lib/core/api/serializers.py:450 core/api/serializers.py:450
msgid "You have been granted ownership of a new document:"
msgstr ""
msgstr "U heeft eigenaarschap van een nieuw document:"
#: build/lib/core/api/serializers.py:453 core/api/serializers.py:453
#: build/lib/core/api/serializers.py:586 core/api/serializers.py:586
msgid "Body"
msgstr ""
msgstr "Text"
#: build/lib/core/api/serializers.py:456 core/api/serializers.py:456
#: build/lib/core/api/serializers.py:589 core/api/serializers.py:589
msgid "Body type"
msgstr ""
msgstr "Text type"
#: build/lib/core/api/serializers.py:462 core/api/serializers.py:462
#: build/lib/core/api/serializers.py:595 core/api/serializers.py:595
msgid "Format"
msgstr ""
msgstr "Formaat"
#: build/lib/core/authentication/backends.py:61
#: core/authentication/backends.py:61
msgid "Invalid response format or token verification failed"
msgstr ""
#: build/lib/core/api/viewsets.py:966 core/api/viewsets.py:966
#, python-brace-format
msgid "copy of {title}"
msgstr "kopie van {title}"
#: build/lib/core/authentication/backends.py:108
#: core/authentication/backends.py:108
msgid "User account is disabled"
msgstr ""
#: build/lib/core/enums.py:19 core/enums.py:19
#: build/lib/core/enums.py:35 core/enums.py:35
msgid "First child"
msgstr ""
msgstr "Eerste node"
#: build/lib/core/enums.py:20 core/enums.py:20
#: build/lib/core/enums.py:36 core/enums.py:36
msgid "Last child"
msgstr ""
msgstr "Laatste node"
#: build/lib/core/enums.py:21 core/enums.py:21
#: build/lib/core/enums.py:37 core/enums.py:37
msgid "First sibling"
msgstr ""
msgstr "Eerste naaste"
#: build/lib/core/enums.py:22 core/enums.py:22
#: build/lib/core/enums.py:38 core/enums.py:38
msgid "Last sibling"
msgstr ""
msgstr "Laatste naaste"
#: build/lib/core/enums.py:23 core/enums.py:23
#: build/lib/core/enums.py:39 core/enums.py:39
msgid "Left"
msgstr ""
msgstr "Links"
#: build/lib/core/enums.py:24 core/enums.py:24
#: build/lib/core/enums.py:40 core/enums.py:40
msgid "Right"
msgstr ""
msgstr "Rechts"
#: build/lib/core/models.py:54 build/lib/core/models.py:61 core/models.py:54
#: core/models.py:61
#: build/lib/core/models.py:56 build/lib/core/models.py:63 core/models.py:56
#: core/models.py:63
msgid "Reader"
msgstr ""
msgstr "Lezer"
#: build/lib/core/models.py:55 build/lib/core/models.py:62 core/models.py:55
#: core/models.py:62
#: build/lib/core/models.py:57 build/lib/core/models.py:64 core/models.py:57
#: core/models.py:64
msgid "Editor"
msgstr ""
msgstr "Bewerker"
#: build/lib/core/models.py:63 core/models.py:63
#: build/lib/core/models.py:65 core/models.py:65
msgid "Administrator"
msgstr ""
msgstr "Administrator"
#: build/lib/core/models.py:64 core/models.py:64
#: build/lib/core/models.py:66 core/models.py:66
msgid "Owner"
msgstr ""
msgstr "Eigenaar"
#: build/lib/core/models.py:75 core/models.py:75
#: build/lib/core/models.py:77 core/models.py:77
msgid "Restricted"
msgstr ""
#: build/lib/core/models.py:79 core/models.py:79
msgid "Authenticated"
msgstr ""
msgstr "Niet toegestaan"
#: build/lib/core/models.py:81 core/models.py:81
msgid "Authenticated"
msgstr "Geauthenticeerd"
#: build/lib/core/models.py:83 core/models.py:83
msgid "Public"
msgstr ""
msgstr "Publiek"
#: build/lib/core/models.py:103 core/models.py:103
#: build/lib/core/models.py:154 core/models.py:154
msgid "id"
msgstr ""
msgstr "id"
#: build/lib/core/models.py:104 core/models.py:104
#: build/lib/core/models.py:155 core/models.py:155
msgid "primary key for the record as UUID"
msgstr ""
msgstr "primaire sleutel voor dossier als UUID"
#: build/lib/core/models.py:110 core/models.py:110
#: build/lib/core/models.py:161 core/models.py:161
msgid "created on"
msgstr ""
msgstr "gemaakt op"
#: build/lib/core/models.py:111 core/models.py:111
#: build/lib/core/models.py:162 core/models.py:162
msgid "date and time at which a record was created"
msgstr ""
msgstr "datum en tijd wanneer dossier was gecreëerd"
#: build/lib/core/models.py:116 core/models.py:116
#: build/lib/core/models.py:167 core/models.py:167
msgid "updated on"
msgstr ""
msgstr "Laatst gewijzigd op"
#: build/lib/core/models.py:117 core/models.py:117
#: build/lib/core/models.py:168 core/models.py:168
msgid "date and time at which a record was last updated"
msgstr ""
msgstr "datum en tijd waarop dossier laatst was gewijzigd"
#: build/lib/core/models.py:153 core/models.py:153
#: build/lib/core/models.py:204 core/models.py:204
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
msgstr ""
msgstr "Wij konden geen gebruiker vinden met deze id, maar de email is al geassocieerd met een geregistreerde gebruiker."
#: build/lib/core/models.py:166 core/models.py:166
#: build/lib/core/models.py:217 core/models.py:217
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
msgstr ""
msgstr ".Geef een valide id. De waarde mag alleen letters, nummers en @/./.+/-/_: karakters bevatten."
#: build/lib/core/models.py:172 core/models.py:172
#: build/lib/core/models.py:223 core/models.py:223
msgid "sub"
msgstr ""
msgstr "id"
#: build/lib/core/models.py:174 core/models.py:174
#: build/lib/core/models.py:225 core/models.py:225
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
msgstr ""
#: build/lib/core/models.py:183 core/models.py:183
msgid "full name"
msgstr ""
#: build/lib/core/models.py:184 core/models.py:184
msgid "short name"
msgstr ""
#: build/lib/core/models.py:186 core/models.py:186
msgid "identity email address"
msgstr ""
#: build/lib/core/models.py:191 core/models.py:191
msgid "admin email address"
msgstr ""
#: build/lib/core/models.py:198 core/models.py:198
msgid "language"
msgstr ""
#: build/lib/core/models.py:199 core/models.py:199
msgid "The language in which the user wants to see the interface."
msgstr ""
#: build/lib/core/models.py:205 core/models.py:205
msgid "The timezone in which the user wants to see times."
msgstr ""
#: build/lib/core/models.py:208 core/models.py:208
msgid "device"
msgstr ""
#: build/lib/core/models.py:210 core/models.py:210
msgid "Whether the user is a device or a real user."
msgstr ""
#: build/lib/core/models.py:213 core/models.py:213
msgid "staff status"
msgstr ""
#: build/lib/core/models.py:215 core/models.py:215
msgid "Whether the user can log into this admin site."
msgstr ""
#: build/lib/core/models.py:218 core/models.py:218
msgid "active"
msgstr ""
#: build/lib/core/models.py:221 core/models.py:221
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: build/lib/core/models.py:233 core/models.py:233
msgid "user"
msgstr ""
msgstr "Verplicht. 255 karakters of minder. Alleen letters, nummers en @/./+/-/_/: karakters zijn toegestaan."
#: build/lib/core/models.py:234 core/models.py:234
msgid "full name"
msgstr "volledige naam"
#: build/lib/core/models.py:235 core/models.py:235
msgid "short name"
msgstr "gebruikersnaam"
#: build/lib/core/models.py:237 core/models.py:237
msgid "identity email address"
msgstr "identiteit email adres"
#: build/lib/core/models.py:242 core/models.py:242
msgid "admin email address"
msgstr "admin email adres"
#: build/lib/core/models.py:249 core/models.py:249
msgid "language"
msgstr "taal"
#: build/lib/core/models.py:250 core/models.py:250
msgid "The language in which the user wants to see the interface."
msgstr "De taal waarin de gebruiker de interface wilt zien."
#: build/lib/core/models.py:258 core/models.py:258
msgid "The timezone in which the user wants to see times."
msgstr "De tijdzone waarin de gebruiker de tijden wilt zien."
#: build/lib/core/models.py:261 core/models.py:261
msgid "device"
msgstr "apparaat"
#: build/lib/core/models.py:263 core/models.py:263
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:266 core/models.py:266
msgid "staff status"
msgstr "beheerder status"
#: build/lib/core/models.py:268 core/models.py:268
msgid "Whether the user can log into this admin site."
msgstr "Of de gebruiker kan inloggen in het admin gedeelte."
#: build/lib/core/models.py:271 core/models.py:271
msgid "active"
msgstr "actief"
#: build/lib/core/models.py:274 core/models.py:274
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:286 core/models.py:286
msgid "user"
msgstr "gebruiker"
#: build/lib/core/models.py:287 core/models.py:287
msgid "users"
msgstr ""
msgstr "gebruikers"
#: build/lib/core/models.py:373 build/lib/core/models.py:925 core/models.py:373
#: core/models.py:925
#: build/lib/core/models.py:470 build/lib/core/models.py:1154
#: core/models.py:470 core/models.py:1154
msgid "title"
msgstr ""
msgstr "titel"
#: build/lib/core/models.py:374 core/models.py:374
#: build/lib/core/models.py:471 core/models.py:471
msgid "excerpt"
msgstr ""
msgstr "uittreksel"
#: build/lib/core/models.py:405 core/models.py:405
#: build/lib/core/models.py:519 core/models.py:519
msgid "Document"
msgstr ""
msgstr "Document"
#: build/lib/core/models.py:406 core/models.py:406
#: build/lib/core/models.py:520 core/models.py:520
msgid "Documents"
msgstr ""
msgstr "Documenten"
#: build/lib/core/models.py:418 core/models.py:418
#: build/lib/core/models.py:532 build/lib/core/models.py:872 core/models.py:532
#: core/models.py:872
msgid "Untitled Document"
msgstr ""
msgstr "Naamloos Document"
#: build/lib/core/models.py:719 core/models.py:719
#: build/lib/core/models.py:907 core/models.py:907
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
msgstr "{name} heeft een document met gedeeld!"
#: build/lib/core/models.py:723 core/models.py:723
#: build/lib/core/models.py:911 core/models.py:911
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr ""
msgstr "{name} heeft u uitgenodigd met de rol \"{role}\" op het volgende document:"
#: build/lib/core/models.py:726 core/models.py:726
#: build/lib/core/models.py:917 core/models.py:917
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr ""
msgstr "{name} heeft een document met u gedeeld: {title}"
#: build/lib/core/models.py:762 core/models.py:762
msgid "This document is not deleted."
msgstr ""
#: build/lib/core/models.py:769 core/models.py:769
msgid "This document was permanently deleted and cannot be restored."
msgstr ""
#: build/lib/core/models.py:820 core/models.py:820
#: build/lib/core/models.py:1015 core/models.py:1015
msgid "Document/user link trace"
msgstr ""
msgstr "Document/gebruiker url"
#: build/lib/core/models.py:821 core/models.py:821
#: build/lib/core/models.py:1016 core/models.py:1016
msgid "Document/user link traces"
msgstr ""
msgstr "Document/gebruiker url"
#: build/lib/core/models.py:827 core/models.py:827
#: build/lib/core/models.py:1022 core/models.py:1022
msgid "A link trace already exists for this document/user."
msgstr ""
msgstr "Een url bestaat al voor dit document/deze gebruiker."
#: build/lib/core/models.py:850 core/models.py:850
#: build/lib/core/models.py:1045 core/models.py:1045
msgid "Document favorite"
msgstr ""
msgstr "Document favoriet"
#: build/lib/core/models.py:851 core/models.py:851
#: build/lib/core/models.py:1046 core/models.py:1046
msgid "Document favorites"
msgstr ""
msgstr "Document favorieten"
#: build/lib/core/models.py:857 core/models.py:857
#: build/lib/core/models.py:1052 core/models.py:1052
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
msgstr "Dit document is al in gebruik als favoriete door dezelfde gebruiker."
#: build/lib/core/models.py:879 core/models.py:879
#: build/lib/core/models.py:1074 core/models.py:1074
msgid "Document/user relation"
msgstr ""
msgstr "Document/gebruiker relatie"
#: build/lib/core/models.py:880 core/models.py:880
#: build/lib/core/models.py:1075 core/models.py:1075
msgid "Document/user relations"
msgstr ""
msgstr "Document/gebruiker relaties"
#: build/lib/core/models.py:886 core/models.py:886
#: build/lib/core/models.py:1081 core/models.py:1081
msgid "This user is already in this document."
msgstr ""
msgstr "De gebruiker is al in dit document."
#: build/lib/core/models.py:892 core/models.py:892
#: build/lib/core/models.py:1087 core/models.py:1087
msgid "This team is already in this document."
msgstr ""
msgstr "Het team is al in dit document."
#: build/lib/core/models.py:898 build/lib/core/models.py:1012
#: core/models.py:898 core/models.py:1012
#: build/lib/core/models.py:1093 build/lib/core/models.py:1241
#: core/models.py:1093 core/models.py:1241
msgid "Either user or team must be set, not both."
msgstr ""
msgstr "Een gebruiker of team moet gekozen worden, maar niet beide."
#: build/lib/core/models.py:926 core/models.py:926
#: build/lib/core/models.py:1155 core/models.py:1155
msgid "description"
msgstr ""
msgstr "omschrijving"
#: build/lib/core/models.py:927 core/models.py:927
#: build/lib/core/models.py:1156 core/models.py:1156
msgid "code"
msgstr ""
msgstr "code"
#: build/lib/core/models.py:928 core/models.py:928
#: build/lib/core/models.py:1157 core/models.py:1157
msgid "css"
msgstr ""
msgstr "css"
#: build/lib/core/models.py:930 core/models.py:930
#: build/lib/core/models.py:1159 core/models.py:1159
msgid "public"
msgstr ""
msgstr "publiek"
#: build/lib/core/models.py:932 core/models.py:932
#: build/lib/core/models.py:1161 core/models.py:1161
msgid "Whether this template is public for anyone to use."
msgstr ""
msgstr "Of dit template als publiek is en door iedereen te gebruiken is."
#: build/lib/core/models.py:938 core/models.py:938
#: build/lib/core/models.py:1167 core/models.py:1167
msgid "Template"
msgstr ""
msgstr "Template"
#: build/lib/core/models.py:939 core/models.py:939
#: build/lib/core/models.py:1168 core/models.py:1168
msgid "Templates"
msgstr ""
msgstr "Templates"
#: build/lib/core/models.py:993 core/models.py:993
#: build/lib/core/models.py:1222 core/models.py:1222
msgid "Template/user relation"
msgstr ""
msgstr "Template/gebruiker relatie"
#: build/lib/core/models.py:994 core/models.py:994
#: build/lib/core/models.py:1223 core/models.py:1223
msgid "Template/user relations"
msgstr ""
msgstr "Template/gebruiker relaties"
#: build/lib/core/models.py:1000 core/models.py:1000
#: build/lib/core/models.py:1229 core/models.py:1229
msgid "This user is already in this template."
msgstr ""
msgstr "De gebruiker bestaat al in dit template."
#: build/lib/core/models.py:1006 core/models.py:1006
#: build/lib/core/models.py:1235 core/models.py:1235
msgid "This team is already in this template."
msgstr ""
msgstr "Het team bestaat al in dit template."
#: build/lib/core/models.py:1029 core/models.py:1029
#: build/lib/core/models.py:1258 core/models.py:1258
msgid "email address"
msgstr ""
msgstr "email adres"
#: build/lib/core/models.py:1048 core/models.py:1048
#: build/lib/core/models.py:1277 core/models.py:1277
msgid "Document invitation"
msgstr ""
msgstr "Document uitnodiging"
#: build/lib/core/models.py:1049 core/models.py:1049
#: build/lib/core/models.py:1278 core/models.py:1278
msgid "Document invitations"
msgstr ""
msgstr "Document uitnodigingen"
#: build/lib/core/models.py:1069 core/models.py:1069
#: build/lib/core/models.py:1298 core/models.py:1298
msgid "This email is already associated to a registered user."
msgstr ""
#: build/lib/impress/settings.py:236 impress/settings.py:236
msgid "English"
msgstr ""
#: build/lib/impress/settings.py:237 impress/settings.py:237
msgid "French"
msgstr ""
#: build/lib/impress/settings.py:238 impress/settings.py:238
msgid "German"
msgstr ""
msgstr "Deze email is al geassocieerd met een geregistreerde gebruiker."
#: core/templates/mail/html/invitation.html:162
#: core/templates/mail/text/invitation.txt:3
msgid "Logo email"
msgstr ""
msgstr "Logo email"
#: core/templates/mail/html/invitation.html:209
#: core/templates/mail/text/invitation.txt:10
msgid "Open"
msgstr ""
msgstr "Open"
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr ""
msgstr " Docs, jouw nieuwe essentiële tool voor het organiseren, delen en collaboreren van documenten als team. "
#: core/templates/mail/html/invitation.html:233
#: core/templates/mail/text/invitation.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
msgstr ""
msgstr " Geleverd door %(brandname)s "

View File

@@ -0,0 +1,390 @@
msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-04-29 11:48+0000\n"
"PO-Revision-Date: 2025-05-05 07:07\n"
"Last-Translator: \n"
"Language-Team: Portuguese\n"
"Language: pt_PT\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Crowdin-Project: lasuite-docs\n"
"X-Crowdin-Project-ID: 754523\n"
"X-Crowdin-Language: pt-PT\n"
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:37 core/admin.py:37
msgid "Personal info"
msgstr ""
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
msgid "Permissions"
msgstr ""
#: build/lib/core/admin.py:62 core/admin.py:62
msgid "Important dates"
msgstr ""
#: build/lib/core/admin.py:148 core/admin.py:148
msgid "Tree structure"
msgstr ""
#: build/lib/core/api/filters.py:47 core/api/filters.py:47
msgid "Title"
msgstr ""
#: build/lib/core/api/filters.py:61 core/api/filters.py:61
msgid "Creator is me"
msgstr ""
#: build/lib/core/api/filters.py:64 core/api/filters.py:64
msgid "Favorite"
msgstr ""
#: build/lib/core/api/serializers.py:446 core/api/serializers.py:446
msgid "A new document was created on your behalf!"
msgstr ""
#: build/lib/core/api/serializers.py:450 core/api/serializers.py:450
msgid "You have been granted ownership of a new document:"
msgstr ""
#: build/lib/core/api/serializers.py:586 core/api/serializers.py:586
msgid "Body"
msgstr ""
#: build/lib/core/api/serializers.py:589 core/api/serializers.py:589
msgid "Body type"
msgstr ""
#: build/lib/core/api/serializers.py:595 core/api/serializers.py:595
msgid "Format"
msgstr ""
#: build/lib/core/api/viewsets.py:966 core/api/viewsets.py:966
#, python-brace-format
msgid "copy of {title}"
msgstr ""
#: build/lib/core/enums.py:35 core/enums.py:35
msgid "First child"
msgstr ""
#: build/lib/core/enums.py:36 core/enums.py:36
msgid "Last child"
msgstr ""
#: build/lib/core/enums.py:37 core/enums.py:37
msgid "First sibling"
msgstr ""
#: build/lib/core/enums.py:38 core/enums.py:38
msgid "Last sibling"
msgstr ""
#: build/lib/core/enums.py:39 core/enums.py:39
msgid "Left"
msgstr ""
#: build/lib/core/enums.py:40 core/enums.py:40
msgid "Right"
msgstr ""
#: build/lib/core/models.py:56 build/lib/core/models.py:63 core/models.py:56
#: core/models.py:63
msgid "Reader"
msgstr ""
#: build/lib/core/models.py:57 build/lib/core/models.py:64 core/models.py:57
#: core/models.py:64
msgid "Editor"
msgstr ""
#: build/lib/core/models.py:65 core/models.py:65
msgid "Administrator"
msgstr ""
#: build/lib/core/models.py:66 core/models.py:66
msgid "Owner"
msgstr ""
#: build/lib/core/models.py:77 core/models.py:77
msgid "Restricted"
msgstr ""
#: build/lib/core/models.py:81 core/models.py:81
msgid "Authenticated"
msgstr ""
#: build/lib/core/models.py:83 core/models.py:83
msgid "Public"
msgstr ""
#: build/lib/core/models.py:154 core/models.py:154
msgid "id"
msgstr ""
#: build/lib/core/models.py:155 core/models.py:155
msgid "primary key for the record as UUID"
msgstr ""
#: build/lib/core/models.py:161 core/models.py:161
msgid "created on"
msgstr ""
#: build/lib/core/models.py:162 core/models.py:162
msgid "date and time at which a record was created"
msgstr ""
#: build/lib/core/models.py:167 core/models.py:167
msgid "updated on"
msgstr ""
#: build/lib/core/models.py:168 core/models.py:168
msgid "date and time at which a record was last updated"
msgstr ""
#: build/lib/core/models.py:204 core/models.py:204
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:217 core/models.py:217
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
msgstr ""
#: build/lib/core/models.py:223 core/models.py:223
msgid "sub"
msgstr ""
#: build/lib/core/models.py:225 core/models.py:225
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
msgstr ""
#: build/lib/core/models.py:234 core/models.py:234
msgid "full name"
msgstr ""
#: build/lib/core/models.py:235 core/models.py:235
msgid "short name"
msgstr ""
#: build/lib/core/models.py:237 core/models.py:237
msgid "identity email address"
msgstr ""
#: build/lib/core/models.py:242 core/models.py:242
msgid "admin email address"
msgstr ""
#: build/lib/core/models.py:249 core/models.py:249
msgid "language"
msgstr ""
#: build/lib/core/models.py:250 core/models.py:250
msgid "The language in which the user wants to see the interface."
msgstr ""
#: build/lib/core/models.py:258 core/models.py:258
msgid "The timezone in which the user wants to see times."
msgstr ""
#: build/lib/core/models.py:261 core/models.py:261
msgid "device"
msgstr ""
#: build/lib/core/models.py:263 core/models.py:263
msgid "Whether the user is a device or a real user."
msgstr ""
#: build/lib/core/models.py:266 core/models.py:266
msgid "staff status"
msgstr ""
#: build/lib/core/models.py:268 core/models.py:268
msgid "Whether the user can log into this admin site."
msgstr ""
#: build/lib/core/models.py:271 core/models.py:271
msgid "active"
msgstr ""
#: build/lib/core/models.py:274 core/models.py:274
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: build/lib/core/models.py:286 core/models.py:286
msgid "user"
msgstr ""
#: build/lib/core/models.py:287 core/models.py:287
msgid "users"
msgstr ""
#: build/lib/core/models.py:470 build/lib/core/models.py:1154
#: core/models.py:470 core/models.py:1154
msgid "title"
msgstr ""
#: build/lib/core/models.py:471 core/models.py:471
msgid "excerpt"
msgstr ""
#: build/lib/core/models.py:519 core/models.py:519
msgid "Document"
msgstr ""
#: build/lib/core/models.py:520 core/models.py:520
msgid "Documents"
msgstr ""
#: build/lib/core/models.py:532 build/lib/core/models.py:872 core/models.py:532
#: core/models.py:872
msgid "Untitled Document"
msgstr ""
#: build/lib/core/models.py:907 core/models.py:907
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
#: build/lib/core/models.py:911 core/models.py:911
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr ""
#: build/lib/core/models.py:917 core/models.py:917
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr ""
#: build/lib/core/models.py:1015 core/models.py:1015
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:1016 core/models.py:1016
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:1022 core/models.py:1022
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:1045 core/models.py:1045
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:1046 core/models.py:1046
msgid "Document favorites"
msgstr ""
#: build/lib/core/models.py:1052 core/models.py:1052
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:1074 core/models.py:1074
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:1075 core/models.py:1075
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:1081 core/models.py:1081
msgid "This user is already in this document."
msgstr ""
#: build/lib/core/models.py:1087 core/models.py:1087
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:1093 build/lib/core/models.py:1241
#: core/models.py:1093 core/models.py:1241
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:1155 core/models.py:1155
msgid "description"
msgstr ""
#: build/lib/core/models.py:1156 core/models.py:1156
msgid "code"
msgstr ""
#: build/lib/core/models.py:1157 core/models.py:1157
msgid "css"
msgstr ""
#: build/lib/core/models.py:1159 core/models.py:1159
msgid "public"
msgstr ""
#: build/lib/core/models.py:1161 core/models.py:1161
msgid "Whether this template is public for anyone to use."
msgstr ""
#: build/lib/core/models.py:1167 core/models.py:1167
msgid "Template"
msgstr ""
#: build/lib/core/models.py:1168 core/models.py:1168
msgid "Templates"
msgstr ""
#: build/lib/core/models.py:1222 core/models.py:1222
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1223 core/models.py:1223
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1229 core/models.py:1229
msgid "This user is already in this template."
msgstr ""
#: build/lib/core/models.py:1235 core/models.py:1235
msgid "This team is already in this template."
msgstr ""
#: build/lib/core/models.py:1258 core/models.py:1258
msgid "email address"
msgstr ""
#: build/lib/core/models.py:1277 core/models.py:1277
msgid "Document invitation"
msgstr ""
#: build/lib/core/models.py:1278 core/models.py:1278
msgid "Document invitations"
msgstr ""
#: build/lib/core/models.py:1298 core/models.py:1298
msgid "This email is already associated to a registered user."
msgstr ""
#: core/templates/mail/html/invitation.html:162
#: core/templates/mail/text/invitation.txt:3
msgid "Logo email"
msgstr ""
#: core/templates/mail/html/invitation.html:209
#: core/templates/mail/text/invitation.txt:10
msgid "Open"
msgstr ""
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr ""
#: core/templates/mail/html/invitation.html:233
#: core/templates/mail/text/invitation.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
msgstr ""

View File

@@ -0,0 +1,390 @@
msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-04-29 11:48+0000\n"
"PO-Revision-Date: 2025-05-05 07:07\n"
"Last-Translator: \n"
"Language-Team: Slovenian\n"
"Language: sl_SI\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=4; plural=n%100==1 ? 0 : n%100==2 ? 1 : n%100==3 || n%100==4 ? 2 : 3;\n"
"X-Crowdin-Project: lasuite-docs\n"
"X-Crowdin-Project-ID: 754523\n"
"X-Crowdin-Language: sl\n"
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:37 core/admin.py:37
msgid "Personal info"
msgstr "Osebni podatki"
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
msgid "Permissions"
msgstr "Dovoljenja"
#: build/lib/core/admin.py:62 core/admin.py:62
msgid "Important dates"
msgstr "Pomembni datumi"
#: build/lib/core/admin.py:148 core/admin.py:148
msgid "Tree structure"
msgstr "Drevesna struktura"
#: build/lib/core/api/filters.py:47 core/api/filters.py:47
msgid "Title"
msgstr "Naslov"
#: build/lib/core/api/filters.py:61 core/api/filters.py:61
msgid "Creator is me"
msgstr "Ustvaril sem jaz"
#: build/lib/core/api/filters.py:64 core/api/filters.py:64
msgid "Favorite"
msgstr "Priljubljena"
#: build/lib/core/api/serializers.py:446 core/api/serializers.py:446
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:450 core/api/serializers.py:450
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:586 core/api/serializers.py:586
msgid "Body"
msgstr "Telo"
#: build/lib/core/api/serializers.py:589 core/api/serializers.py:589
msgid "Body type"
msgstr "Vrsta telesa"
#: build/lib/core/api/serializers.py:595 core/api/serializers.py:595
msgid "Format"
msgstr "Oblika"
#: build/lib/core/api/viewsets.py:966 core/api/viewsets.py:966
#, python-brace-format
msgid "copy of {title}"
msgstr ""
#: build/lib/core/enums.py:35 core/enums.py:35
msgid "First child"
msgstr "Prvi otrok"
#: build/lib/core/enums.py:36 core/enums.py:36
msgid "Last child"
msgstr "Zadnji otrok"
#: build/lib/core/enums.py:37 core/enums.py:37
msgid "First sibling"
msgstr "Prvi brat in sestra"
#: build/lib/core/enums.py:38 core/enums.py:38
msgid "Last sibling"
msgstr "Zadnji brat in sestra"
#: build/lib/core/enums.py:39 core/enums.py:39
msgid "Left"
msgstr "Levo"
#: build/lib/core/enums.py:40 core/enums.py:40
msgid "Right"
msgstr "Desno"
#: build/lib/core/models.py:56 build/lib/core/models.py:63 core/models.py:56
#: core/models.py:63
msgid "Reader"
msgstr "Bralec"
#: build/lib/core/models.py:57 build/lib/core/models.py:64 core/models.py:57
#: core/models.py:64
msgid "Editor"
msgstr "Urednik"
#: build/lib/core/models.py:65 core/models.py:65
msgid "Administrator"
msgstr "Skrbnik"
#: build/lib/core/models.py:66 core/models.py:66
msgid "Owner"
msgstr "Lastnik"
#: build/lib/core/models.py:77 core/models.py:77
msgid "Restricted"
msgstr "Omejeno"
#: build/lib/core/models.py:81 core/models.py:81
msgid "Authenticated"
msgstr "Preverjeno"
#: build/lib/core/models.py:83 core/models.py:83
msgid "Public"
msgstr "Javno"
#: build/lib/core/models.py:154 core/models.py:154
msgid "id"
msgstr ""
#: build/lib/core/models.py:155 core/models.py:155
msgid "primary key for the record as UUID"
msgstr "primarni ključ za zapis kot UUID"
#: build/lib/core/models.py:161 core/models.py:161
msgid "created on"
msgstr "ustvarjen na"
#: build/lib/core/models.py:162 core/models.py:162
msgid "date and time at which a record was created"
msgstr "datum in čas, ko je bil zapis ustvarjen"
#: build/lib/core/models.py:167 core/models.py:167
msgid "updated on"
msgstr "posodobljeno dne"
#: build/lib/core/models.py:168 core/models.py:168
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:204 core/models.py:204
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:217 core/models.py:217
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
msgstr "Vnesite veljavno sub. Ta vrednost lahko vsebuje samo črke, številke in znake @/./+/-/_/:."
#: build/lib/core/models.py:223 core/models.py:223
msgid "sub"
msgstr ""
#: build/lib/core/models.py:225 core/models.py:225
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
msgstr "Obvezno. 255 znakov ali manj. Samo črke, številke in znaki @/./+/-/_/: ."
#: build/lib/core/models.py:234 core/models.py:234
msgid "full name"
msgstr "polno ime"
#: build/lib/core/models.py:235 core/models.py:235
msgid "short name"
msgstr "kratko ime"
#: build/lib/core/models.py:237 core/models.py:237
msgid "identity email address"
msgstr "elektronski naslov identitete"
#: build/lib/core/models.py:242 core/models.py:242
msgid "admin email address"
msgstr "elektronski naslov skrbnika"
#: build/lib/core/models.py:249 core/models.py:249
msgid "language"
msgstr "jezik"
#: build/lib/core/models.py:250 core/models.py:250
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:258 core/models.py:258
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:261 core/models.py:261
msgid "device"
msgstr "naprava"
#: build/lib/core/models.py:263 core/models.py:263
msgid "Whether the user is a device or a real user."
msgstr "Ali je uporabnik naprava ali pravi uporabnik."
#: build/lib/core/models.py:266 core/models.py:266
msgid "staff status"
msgstr "kadrovski status"
#: build/lib/core/models.py:268 core/models.py:268
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:271 core/models.py:271
msgid "active"
msgstr "aktivni"
#: build/lib/core/models.py:274 core/models.py:274
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:286 core/models.py:286
msgid "user"
msgstr "uporabnik"
#: build/lib/core/models.py:287 core/models.py:287
msgid "users"
msgstr "uporabniki"
#: build/lib/core/models.py:470 build/lib/core/models.py:1154
#: core/models.py:470 core/models.py:1154
msgid "title"
msgstr "naslov"
#: build/lib/core/models.py:471 core/models.py:471
msgid "excerpt"
msgstr "odlomek"
#: build/lib/core/models.py:519 core/models.py:519
msgid "Document"
msgstr "Dokument"
#: build/lib/core/models.py:520 core/models.py:520
msgid "Documents"
msgstr "Dokumenti"
#: build/lib/core/models.py:532 build/lib/core/models.py:872 core/models.py:532
#: core/models.py:872
msgid "Untitled Document"
msgstr "Dokument brez naslova"
#: build/lib/core/models.py:907 core/models.py:907
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} je delil dokument z vami!"
#: build/lib/core/models.py:911 core/models.py:911
#, 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:917 core/models.py:917
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} je delil dokument z vami: {title}"
#: build/lib/core/models.py:1015 core/models.py:1015
msgid "Document/user link trace"
msgstr "Dokument/sled povezave uporabnika"
#: build/lib/core/models.py:1016 core/models.py:1016
msgid "Document/user link traces"
msgstr "Sledi povezav dokumenta/uporabnika"
#: build/lib/core/models.py:1022 core/models.py:1022
msgid "A link trace already exists for this document/user."
msgstr "Za ta dokument/uporabnika že obstaja sled povezave."
#: build/lib/core/models.py:1045 core/models.py:1045
msgid "Document favorite"
msgstr "Priljubljeni dokument"
#: build/lib/core/models.py:1046 core/models.py:1046
msgid "Document favorites"
msgstr "Priljubljeni dokumenti"
#: build/lib/core/models.py:1052 core/models.py:1052
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:1074 core/models.py:1074
msgid "Document/user relation"
msgstr "Odnos dokument/uporabnik"
#: build/lib/core/models.py:1075 core/models.py:1075
msgid "Document/user relations"
msgstr "Odnosi dokument/uporabnik"
#: build/lib/core/models.py:1081 core/models.py:1081
msgid "This user is already in this document."
msgstr "Ta uporabnik je že v tem dokumentu."
#: build/lib/core/models.py:1087 core/models.py:1087
msgid "This team is already in this document."
msgstr "Ta ekipa je že v tem dokumentu."
#: build/lib/core/models.py:1093 build/lib/core/models.py:1241
#: core/models.py:1093 core/models.py:1241
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:1155 core/models.py:1155
msgid "description"
msgstr "opis"
#: build/lib/core/models.py:1156 core/models.py:1156
msgid "code"
msgstr "koda"
#: build/lib/core/models.py:1157 core/models.py:1157
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1159 core/models.py:1159
msgid "public"
msgstr "javno"
#: build/lib/core/models.py:1161 core/models.py:1161
msgid "Whether this template is public for anyone to use."
msgstr "Ali je ta predloga javna za uporabo."
#: build/lib/core/models.py:1167 core/models.py:1167
msgid "Template"
msgstr "Predloga"
#: build/lib/core/models.py:1168 core/models.py:1168
msgid "Templates"
msgstr "Predloge"
#: build/lib/core/models.py:1222 core/models.py:1222
msgid "Template/user relation"
msgstr "Odnos predloga/uporabnik"
#: build/lib/core/models.py:1223 core/models.py:1223
msgid "Template/user relations"
msgstr "Odnosi med predlogo in uporabnikom"
#: build/lib/core/models.py:1229 core/models.py:1229
msgid "This user is already in this template."
msgstr "Ta uporabnik je že v tej predlogi."
#: build/lib/core/models.py:1235 core/models.py:1235
msgid "This team is already in this template."
msgstr "Ta ekipa je že v tej predlogi."
#: build/lib/core/models.py:1258 core/models.py:1258
msgid "email address"
msgstr "elektronski naslov"
#: build/lib/core/models.py:1277 core/models.py:1277
msgid "Document invitation"
msgstr "Vabilo na dokument"
#: build/lib/core/models.py:1278 core/models.py:1278
msgid "Document invitations"
msgstr "Vabila na dokument"
#: build/lib/core/models.py:1298 core/models.py:1298
msgid "This email is already associated to a registered user."
msgstr "Ta e-poštni naslov je že povezan z registriranim uporabnikom."
#: core/templates/mail/html/invitation.html:162
#: core/templates/mail/text/invitation.txt:3
msgid "Logo email"
msgstr "E-pošta z logotipom"
#: core/templates/mail/html/invitation.html:209
#: core/templates/mail/text/invitation.txt:10
msgid "Open"
msgstr "Odpri"
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.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/invitation.html:233
#: core/templates/mail/text/invitation.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
msgstr " Pod okriljem %(brandname)s "

View File

@@ -0,0 +1,390 @@
msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-04-29 11:48+0000\n"
"PO-Revision-Date: 2025-05-05 07:07\n"
"Last-Translator: \n"
"Language-Team: Swedish\n"
"Language: sv_SE\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Crowdin-Project: lasuite-docs\n"
"X-Crowdin-Project-ID: 754523\n"
"X-Crowdin-Language: sv-SE\n"
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:37 core/admin.py:37
msgid "Personal info"
msgstr "Personuppgifter"
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
msgid "Permissions"
msgstr "Behörigheter"
#: build/lib/core/admin.py:62 core/admin.py:62
msgid "Important dates"
msgstr "Viktiga datum"
#: build/lib/core/admin.py:148 core/admin.py:148
msgid "Tree structure"
msgstr ""
#: build/lib/core/api/filters.py:47 core/api/filters.py:47
msgid "Title"
msgstr "Titel"
#: build/lib/core/api/filters.py:61 core/api/filters.py:61
msgid "Creator is me"
msgstr "Skaparen är jag"
#: build/lib/core/api/filters.py:64 core/api/filters.py:64
msgid "Favorite"
msgstr "Favoriter"
#: build/lib/core/api/serializers.py:446 core/api/serializers.py:446
msgid "A new document was created on your behalf!"
msgstr "Ett nytt dokument skapades åt dig!"
#: build/lib/core/api/serializers.py:450 core/api/serializers.py:450
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:586 core/api/serializers.py:586
msgid "Body"
msgstr ""
#: build/lib/core/api/serializers.py:589 core/api/serializers.py:589
msgid "Body type"
msgstr ""
#: build/lib/core/api/serializers.py:595 core/api/serializers.py:595
msgid "Format"
msgstr "Format"
#: build/lib/core/api/viewsets.py:966 core/api/viewsets.py:966
#, python-brace-format
msgid "copy of {title}"
msgstr ""
#: build/lib/core/enums.py:35 core/enums.py:35
msgid "First child"
msgstr ""
#: build/lib/core/enums.py:36 core/enums.py:36
msgid "Last child"
msgstr ""
#: build/lib/core/enums.py:37 core/enums.py:37
msgid "First sibling"
msgstr ""
#: build/lib/core/enums.py:38 core/enums.py:38
msgid "Last sibling"
msgstr ""
#: build/lib/core/enums.py:39 core/enums.py:39
msgid "Left"
msgstr ""
#: build/lib/core/enums.py:40 core/enums.py:40
msgid "Right"
msgstr ""
#: build/lib/core/models.py:56 build/lib/core/models.py:63 core/models.py:56
#: core/models.py:63
msgid "Reader"
msgstr ""
#: build/lib/core/models.py:57 build/lib/core/models.py:64 core/models.py:57
#: core/models.py:64
msgid "Editor"
msgstr ""
#: build/lib/core/models.py:65 core/models.py:65
msgid "Administrator"
msgstr "Administratör"
#: build/lib/core/models.py:66 core/models.py:66
msgid "Owner"
msgstr ""
#: build/lib/core/models.py:77 core/models.py:77
msgid "Restricted"
msgstr ""
#: build/lib/core/models.py:81 core/models.py:81
msgid "Authenticated"
msgstr ""
#: build/lib/core/models.py:83 core/models.py:83
msgid "Public"
msgstr "Publik"
#: build/lib/core/models.py:154 core/models.py:154
msgid "id"
msgstr ""
#: build/lib/core/models.py:155 core/models.py:155
msgid "primary key for the record as UUID"
msgstr ""
#: build/lib/core/models.py:161 core/models.py:161
msgid "created on"
msgstr ""
#: build/lib/core/models.py:162 core/models.py:162
msgid "date and time at which a record was created"
msgstr ""
#: build/lib/core/models.py:167 core/models.py:167
msgid "updated on"
msgstr ""
#: build/lib/core/models.py:168 core/models.py:168
msgid "date and time at which a record was last updated"
msgstr ""
#: build/lib/core/models.py:204 core/models.py:204
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:217 core/models.py:217
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
msgstr ""
#: build/lib/core/models.py:223 core/models.py:223
msgid "sub"
msgstr ""
#: build/lib/core/models.py:225 core/models.py:225
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
msgstr ""
#: build/lib/core/models.py:234 core/models.py:234
msgid "full name"
msgstr ""
#: build/lib/core/models.py:235 core/models.py:235
msgid "short name"
msgstr ""
#: build/lib/core/models.py:237 core/models.py:237
msgid "identity email address"
msgstr ""
#: build/lib/core/models.py:242 core/models.py:242
msgid "admin email address"
msgstr ""
#: build/lib/core/models.py:249 core/models.py:249
msgid "language"
msgstr ""
#: build/lib/core/models.py:250 core/models.py:250
msgid "The language in which the user wants to see the interface."
msgstr ""
#: build/lib/core/models.py:258 core/models.py:258
msgid "The timezone in which the user wants to see times."
msgstr ""
#: build/lib/core/models.py:261 core/models.py:261
msgid "device"
msgstr ""
#: build/lib/core/models.py:263 core/models.py:263
msgid "Whether the user is a device or a real user."
msgstr ""
#: build/lib/core/models.py:266 core/models.py:266
msgid "staff status"
msgstr ""
#: build/lib/core/models.py:268 core/models.py:268
msgid "Whether the user can log into this admin site."
msgstr ""
#: build/lib/core/models.py:271 core/models.py:271
msgid "active"
msgstr "aktiv"
#: build/lib/core/models.py:274 core/models.py:274
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: build/lib/core/models.py:286 core/models.py:286
msgid "user"
msgstr ""
#: build/lib/core/models.py:287 core/models.py:287
msgid "users"
msgstr ""
#: build/lib/core/models.py:470 build/lib/core/models.py:1154
#: core/models.py:470 core/models.py:1154
msgid "title"
msgstr ""
#: build/lib/core/models.py:471 core/models.py:471
msgid "excerpt"
msgstr ""
#: build/lib/core/models.py:519 core/models.py:519
msgid "Document"
msgstr ""
#: build/lib/core/models.py:520 core/models.py:520
msgid "Documents"
msgstr ""
#: build/lib/core/models.py:532 build/lib/core/models.py:872 core/models.py:532
#: core/models.py:872
msgid "Untitled Document"
msgstr ""
#: build/lib/core/models.py:907 core/models.py:907
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
#: build/lib/core/models.py:911 core/models.py:911
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr ""
#: build/lib/core/models.py:917 core/models.py:917
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr ""
#: build/lib/core/models.py:1015 core/models.py:1015
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:1016 core/models.py:1016
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:1022 core/models.py:1022
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:1045 core/models.py:1045
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:1046 core/models.py:1046
msgid "Document favorites"
msgstr ""
#: build/lib/core/models.py:1052 core/models.py:1052
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:1074 core/models.py:1074
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:1075 core/models.py:1075
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:1081 core/models.py:1081
msgid "This user is already in this document."
msgstr ""
#: build/lib/core/models.py:1087 core/models.py:1087
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:1093 build/lib/core/models.py:1241
#: core/models.py:1093 core/models.py:1241
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:1155 core/models.py:1155
msgid "description"
msgstr ""
#: build/lib/core/models.py:1156 core/models.py:1156
msgid "code"
msgstr ""
#: build/lib/core/models.py:1157 core/models.py:1157
msgid "css"
msgstr ""
#: build/lib/core/models.py:1159 core/models.py:1159
msgid "public"
msgstr ""
#: build/lib/core/models.py:1161 core/models.py:1161
msgid "Whether this template is public for anyone to use."
msgstr ""
#: build/lib/core/models.py:1167 core/models.py:1167
msgid "Template"
msgstr ""
#: build/lib/core/models.py:1168 core/models.py:1168
msgid "Templates"
msgstr ""
#: build/lib/core/models.py:1222 core/models.py:1222
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1223 core/models.py:1223
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1229 core/models.py:1229
msgid "This user is already in this template."
msgstr ""
#: build/lib/core/models.py:1235 core/models.py:1235
msgid "This team is already in this template."
msgstr ""
#: build/lib/core/models.py:1258 core/models.py:1258
msgid "email address"
msgstr "e-postadress"
#: build/lib/core/models.py:1277 core/models.py:1277
msgid "Document invitation"
msgstr "Bjud in dokument"
#: build/lib/core/models.py:1278 core/models.py:1278
msgid "Document invitations"
msgstr "Inbjudningar dokument"
#: build/lib/core/models.py:1298 core/models.py:1298
msgid "This email is already associated to a registered user."
msgstr "Denna e-postadress är redan associerad med en registrerad användare."
#: core/templates/mail/html/invitation.html:162
#: core/templates/mail/text/invitation.txt:3
msgid "Logo email"
msgstr "Logotyp e-post"
#: core/templates/mail/html/invitation.html:209
#: core/templates/mail/text/invitation.txt:10
msgid "Open"
msgstr "Öppna"
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr ""
#: core/templates/mail/html/invitation.html:233
#: core/templates/mail/text/invitation.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
msgstr ""

View File

@@ -0,0 +1,390 @@
msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-04-29 11:48+0000\n"
"PO-Revision-Date: 2025-05-05 07:07\n"
"Last-Translator: \n"
"Language-Team: Turkish\n"
"Language: tr_TR\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Crowdin-Project: lasuite-docs\n"
"X-Crowdin-Project-ID: 754523\n"
"X-Crowdin-Language: tr\n"
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:37 core/admin.py:37
msgid "Personal info"
msgstr ""
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
msgid "Permissions"
msgstr ""
#: build/lib/core/admin.py:62 core/admin.py:62
msgid "Important dates"
msgstr ""
#: build/lib/core/admin.py:148 core/admin.py:148
msgid "Tree structure"
msgstr ""
#: build/lib/core/api/filters.py:47 core/api/filters.py:47
msgid "Title"
msgstr ""
#: build/lib/core/api/filters.py:61 core/api/filters.py:61
msgid "Creator is me"
msgstr ""
#: build/lib/core/api/filters.py:64 core/api/filters.py:64
msgid "Favorite"
msgstr ""
#: build/lib/core/api/serializers.py:446 core/api/serializers.py:446
msgid "A new document was created on your behalf!"
msgstr ""
#: build/lib/core/api/serializers.py:450 core/api/serializers.py:450
msgid "You have been granted ownership of a new document:"
msgstr ""
#: build/lib/core/api/serializers.py:586 core/api/serializers.py:586
msgid "Body"
msgstr ""
#: build/lib/core/api/serializers.py:589 core/api/serializers.py:589
msgid "Body type"
msgstr ""
#: build/lib/core/api/serializers.py:595 core/api/serializers.py:595
msgid "Format"
msgstr ""
#: build/lib/core/api/viewsets.py:966 core/api/viewsets.py:966
#, python-brace-format
msgid "copy of {title}"
msgstr ""
#: build/lib/core/enums.py:35 core/enums.py:35
msgid "First child"
msgstr ""
#: build/lib/core/enums.py:36 core/enums.py:36
msgid "Last child"
msgstr ""
#: build/lib/core/enums.py:37 core/enums.py:37
msgid "First sibling"
msgstr ""
#: build/lib/core/enums.py:38 core/enums.py:38
msgid "Last sibling"
msgstr ""
#: build/lib/core/enums.py:39 core/enums.py:39
msgid "Left"
msgstr ""
#: build/lib/core/enums.py:40 core/enums.py:40
msgid "Right"
msgstr ""
#: build/lib/core/models.py:56 build/lib/core/models.py:63 core/models.py:56
#: core/models.py:63
msgid "Reader"
msgstr ""
#: build/lib/core/models.py:57 build/lib/core/models.py:64 core/models.py:57
#: core/models.py:64
msgid "Editor"
msgstr ""
#: build/lib/core/models.py:65 core/models.py:65
msgid "Administrator"
msgstr ""
#: build/lib/core/models.py:66 core/models.py:66
msgid "Owner"
msgstr ""
#: build/lib/core/models.py:77 core/models.py:77
msgid "Restricted"
msgstr ""
#: build/lib/core/models.py:81 core/models.py:81
msgid "Authenticated"
msgstr ""
#: build/lib/core/models.py:83 core/models.py:83
msgid "Public"
msgstr ""
#: build/lib/core/models.py:154 core/models.py:154
msgid "id"
msgstr ""
#: build/lib/core/models.py:155 core/models.py:155
msgid "primary key for the record as UUID"
msgstr ""
#: build/lib/core/models.py:161 core/models.py:161
msgid "created on"
msgstr ""
#: build/lib/core/models.py:162 core/models.py:162
msgid "date and time at which a record was created"
msgstr ""
#: build/lib/core/models.py:167 core/models.py:167
msgid "updated on"
msgstr ""
#: build/lib/core/models.py:168 core/models.py:168
msgid "date and time at which a record was last updated"
msgstr ""
#: build/lib/core/models.py:204 core/models.py:204
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:217 core/models.py:217
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
msgstr ""
#: build/lib/core/models.py:223 core/models.py:223
msgid "sub"
msgstr ""
#: build/lib/core/models.py:225 core/models.py:225
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
msgstr ""
#: build/lib/core/models.py:234 core/models.py:234
msgid "full name"
msgstr ""
#: build/lib/core/models.py:235 core/models.py:235
msgid "short name"
msgstr ""
#: build/lib/core/models.py:237 core/models.py:237
msgid "identity email address"
msgstr ""
#: build/lib/core/models.py:242 core/models.py:242
msgid "admin email address"
msgstr ""
#: build/lib/core/models.py:249 core/models.py:249
msgid "language"
msgstr ""
#: build/lib/core/models.py:250 core/models.py:250
msgid "The language in which the user wants to see the interface."
msgstr ""
#: build/lib/core/models.py:258 core/models.py:258
msgid "The timezone in which the user wants to see times."
msgstr ""
#: build/lib/core/models.py:261 core/models.py:261
msgid "device"
msgstr ""
#: build/lib/core/models.py:263 core/models.py:263
msgid "Whether the user is a device or a real user."
msgstr ""
#: build/lib/core/models.py:266 core/models.py:266
msgid "staff status"
msgstr ""
#: build/lib/core/models.py:268 core/models.py:268
msgid "Whether the user can log into this admin site."
msgstr ""
#: build/lib/core/models.py:271 core/models.py:271
msgid "active"
msgstr ""
#: build/lib/core/models.py:274 core/models.py:274
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: build/lib/core/models.py:286 core/models.py:286
msgid "user"
msgstr ""
#: build/lib/core/models.py:287 core/models.py:287
msgid "users"
msgstr ""
#: build/lib/core/models.py:470 build/lib/core/models.py:1154
#: core/models.py:470 core/models.py:1154
msgid "title"
msgstr ""
#: build/lib/core/models.py:471 core/models.py:471
msgid "excerpt"
msgstr ""
#: build/lib/core/models.py:519 core/models.py:519
msgid "Document"
msgstr ""
#: build/lib/core/models.py:520 core/models.py:520
msgid "Documents"
msgstr ""
#: build/lib/core/models.py:532 build/lib/core/models.py:872 core/models.py:532
#: core/models.py:872
msgid "Untitled Document"
msgstr ""
#: build/lib/core/models.py:907 core/models.py:907
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
#: build/lib/core/models.py:911 core/models.py:911
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr ""
#: build/lib/core/models.py:917 core/models.py:917
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr ""
#: build/lib/core/models.py:1015 core/models.py:1015
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:1016 core/models.py:1016
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:1022 core/models.py:1022
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:1045 core/models.py:1045
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:1046 core/models.py:1046
msgid "Document favorites"
msgstr ""
#: build/lib/core/models.py:1052 core/models.py:1052
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:1074 core/models.py:1074
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:1075 core/models.py:1075
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:1081 core/models.py:1081
msgid "This user is already in this document."
msgstr ""
#: build/lib/core/models.py:1087 core/models.py:1087
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:1093 build/lib/core/models.py:1241
#: core/models.py:1093 core/models.py:1241
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:1155 core/models.py:1155
msgid "description"
msgstr ""
#: build/lib/core/models.py:1156 core/models.py:1156
msgid "code"
msgstr ""
#: build/lib/core/models.py:1157 core/models.py:1157
msgid "css"
msgstr ""
#: build/lib/core/models.py:1159 core/models.py:1159
msgid "public"
msgstr ""
#: build/lib/core/models.py:1161 core/models.py:1161
msgid "Whether this template is public for anyone to use."
msgstr ""
#: build/lib/core/models.py:1167 core/models.py:1167
msgid "Template"
msgstr ""
#: build/lib/core/models.py:1168 core/models.py:1168
msgid "Templates"
msgstr ""
#: build/lib/core/models.py:1222 core/models.py:1222
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1223 core/models.py:1223
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1229 core/models.py:1229
msgid "This user is already in this template."
msgstr ""
#: build/lib/core/models.py:1235 core/models.py:1235
msgid "This team is already in this template."
msgstr ""
#: build/lib/core/models.py:1258 core/models.py:1258
msgid "email address"
msgstr ""
#: build/lib/core/models.py:1277 core/models.py:1277
msgid "Document invitation"
msgstr ""
#: build/lib/core/models.py:1278 core/models.py:1278
msgid "Document invitations"
msgstr ""
#: build/lib/core/models.py:1298 core/models.py:1298
msgid "This email is already associated to a registered user."
msgstr ""
#: core/templates/mail/html/invitation.html:162
#: core/templates/mail/text/invitation.txt:3
msgid "Logo email"
msgstr ""
#: core/templates/mail/html/invitation.html:209
#: core/templates/mail/text/invitation.txt:10
msgid "Open"
msgstr ""
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr ""
#: core/templates/mail/html/invitation.html:233
#: core/templates/mail/text/invitation.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
msgstr ""

View File

@@ -0,0 +1,390 @@
msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-04-29 11:48+0000\n"
"PO-Revision-Date: 2025-05-05 07:07\n"
"Last-Translator: \n"
"Language-Team: Chinese Simplified\n"
"Language: zh_CN\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=1; plural=0;\n"
"X-Crowdin-Project: lasuite-docs\n"
"X-Crowdin-Project-ID: 754523\n"
"X-Crowdin-Language: zh-CN\n"
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:37 core/admin.py:37
msgid "Personal info"
msgstr "个人信息"
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
msgid "Permissions"
msgstr "权限"
#: build/lib/core/admin.py:62 core/admin.py:62
msgid "Important dates"
msgstr "重要日期"
#: build/lib/core/admin.py:148 core/admin.py:148
msgid "Tree structure"
msgstr "树状结构"
#: build/lib/core/api/filters.py:47 core/api/filters.py:47
msgid "Title"
msgstr "标题"
#: build/lib/core/api/filters.py:61 core/api/filters.py:61
msgid "Creator is me"
msgstr "创建者是我"
#: build/lib/core/api/filters.py:64 core/api/filters.py:64
msgid "Favorite"
msgstr "收藏"
#: build/lib/core/api/serializers.py:446 core/api/serializers.py:446
msgid "A new document was created on your behalf!"
msgstr "已为您创建了一份新文档!"
#: build/lib/core/api/serializers.py:450 core/api/serializers.py:450
msgid "You have been granted ownership of a new document:"
msgstr "您已被授予新文档的所有权:"
#: build/lib/core/api/serializers.py:586 core/api/serializers.py:586
msgid "Body"
msgstr "正文"
#: build/lib/core/api/serializers.py:589 core/api/serializers.py:589
msgid "Body type"
msgstr "正文类型"
#: build/lib/core/api/serializers.py:595 core/api/serializers.py:595
msgid "Format"
msgstr "格式"
#: build/lib/core/api/viewsets.py:966 core/api/viewsets.py:966
#, python-brace-format
msgid "copy of {title}"
msgstr "{title} 的副本"
#: build/lib/core/enums.py:35 core/enums.py:35
msgid "First child"
msgstr "第一个子项"
#: build/lib/core/enums.py:36 core/enums.py:36
msgid "Last child"
msgstr "最后一个子项"
#: build/lib/core/enums.py:37 core/enums.py:37
msgid "First sibling"
msgstr "第一个同级项"
#: build/lib/core/enums.py:38 core/enums.py:38
msgid "Last sibling"
msgstr "最后一个同级项"
#: build/lib/core/enums.py:39 core/enums.py:39
msgid "Left"
msgstr "左"
#: build/lib/core/enums.py:40 core/enums.py:40
msgid "Right"
msgstr "右"
#: build/lib/core/models.py:56 build/lib/core/models.py:63 core/models.py:56
#: core/models.py:63
msgid "Reader"
msgstr "阅读者"
#: build/lib/core/models.py:57 build/lib/core/models.py:64 core/models.py:57
#: core/models.py:64
msgid "Editor"
msgstr "编辑者"
#: build/lib/core/models.py:65 core/models.py:65
msgid "Administrator"
msgstr "超级管理员"
#: build/lib/core/models.py:66 core/models.py:66
msgid "Owner"
msgstr "所有者"
#: build/lib/core/models.py:77 core/models.py:77
msgid "Restricted"
msgstr "受限的"
#: build/lib/core/models.py:81 core/models.py:81
msgid "Authenticated"
msgstr "已验证"
#: build/lib/core/models.py:83 core/models.py:83
msgid "Public"
msgstr "公开"
#: build/lib/core/models.py:154 core/models.py:154
msgid "id"
msgstr "id"
#: build/lib/core/models.py:155 core/models.py:155
msgid "primary key for the record as UUID"
msgstr "记录的主密钥为 UUID"
#: build/lib/core/models.py:161 core/models.py:161
msgid "created on"
msgstr "创建时间"
#: build/lib/core/models.py:162 core/models.py:162
msgid "date and time at which a record was created"
msgstr "记录的创建日期和时间"
#: build/lib/core/models.py:167 core/models.py:167
msgid "updated on"
msgstr "更新时间"
#: build/lib/core/models.py:168 core/models.py:168
msgid "date and time at which a record was last updated"
msgstr "记录的最后更新时间"
#: build/lib/core/models.py:204 core/models.py:204
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
msgstr "未找到具有该 sub 的用户,但该邮箱已关联到一个注册用户。"
#: build/lib/core/models.py:217 core/models.py:217
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
msgstr "请输入有效的 sub。该值只能包含字母、数字及 @/./+/-/_/: 字符。"
#: build/lib/core/models.py:223 core/models.py:223
msgid "sub"
msgstr "sub"
#: build/lib/core/models.py:225 core/models.py:225
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
msgstr "必填。最多 255 个字符,仅允许字母、数字及 @/./+/-/_/: 字符。"
#: build/lib/core/models.py:234 core/models.py:234
msgid "full name"
msgstr "全名"
#: build/lib/core/models.py:235 core/models.py:235
msgid "short name"
msgstr "简称"
#: build/lib/core/models.py:237 core/models.py:237
msgid "identity email address"
msgstr "身份电子邮件地址"
#: build/lib/core/models.py:242 core/models.py:242
msgid "admin email address"
msgstr "管理员电子邮件地址"
#: build/lib/core/models.py:249 core/models.py:249
msgid "language"
msgstr "语言"
#: build/lib/core/models.py:250 core/models.py:250
msgid "The language in which the user wants to see the interface."
msgstr "用户希望看到的界面语言。"
#: build/lib/core/models.py:258 core/models.py:258
msgid "The timezone in which the user wants to see times."
msgstr "用户查看时间希望的时区。"
#: build/lib/core/models.py:261 core/models.py:261
msgid "device"
msgstr "设备"
#: build/lib/core/models.py:263 core/models.py:263
msgid "Whether the user is a device or a real user."
msgstr "用户是设备还是真实用户。"
#: build/lib/core/models.py:266 core/models.py:266
msgid "staff status"
msgstr "员工状态"
#: build/lib/core/models.py:268 core/models.py:268
msgid "Whether the user can log into this admin site."
msgstr "用户是否可以登录该管理员站点。"
#: build/lib/core/models.py:271 core/models.py:271
msgid "active"
msgstr "激活"
#: build/lib/core/models.py:274 core/models.py:274
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "是否应将此用户视为活跃用户。取消选择此选项而不是删除账户。"
#: build/lib/core/models.py:286 core/models.py:286
msgid "user"
msgstr "用户"
#: build/lib/core/models.py:287 core/models.py:287
msgid "users"
msgstr "个用户"
#: build/lib/core/models.py:470 build/lib/core/models.py:1154
#: core/models.py:470 core/models.py:1154
msgid "title"
msgstr "标题"
#: build/lib/core/models.py:471 core/models.py:471
msgid "excerpt"
msgstr "摘要"
#: build/lib/core/models.py:519 core/models.py:519
msgid "Document"
msgstr "文档"
#: build/lib/core/models.py:520 core/models.py:520
msgid "Documents"
msgstr "个文档"
#: build/lib/core/models.py:532 build/lib/core/models.py:872 core/models.py:532
#: core/models.py:872
msgid "Untitled Document"
msgstr "未命名文档"
#: build/lib/core/models.py:907 core/models.py:907
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} 与您共享了一个文档!"
#: build/lib/core/models.py:911 core/models.py:911
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} 邀请您以“{role}”角色访问以下文档:"
#: build/lib/core/models.py:917 core/models.py:917
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} 与您共享了一个文档:{title}"
#: build/lib/core/models.py:1015 core/models.py:1015
msgid "Document/user link trace"
msgstr "文档/用户链接跟踪"
#: build/lib/core/models.py:1016 core/models.py:1016
msgid "Document/user link traces"
msgstr "个文档/用户链接跟踪"
#: build/lib/core/models.py:1022 core/models.py:1022
msgid "A link trace already exists for this document/user."
msgstr "此文档/用户的链接跟踪已存在。"
#: build/lib/core/models.py:1045 core/models.py:1045
msgid "Document favorite"
msgstr "文档收藏"
#: build/lib/core/models.py:1046 core/models.py:1046
msgid "Document favorites"
msgstr "文档收藏夹"
#: build/lib/core/models.py:1052 core/models.py:1052
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "该文档已被同一用户的收藏关系实例关联。"
#: build/lib/core/models.py:1074 core/models.py:1074
msgid "Document/user relation"
msgstr "文档/用户关系"
#: build/lib/core/models.py:1075 core/models.py:1075
msgid "Document/user relations"
msgstr "文档/用户关系集"
#: build/lib/core/models.py:1081 core/models.py:1081
msgid "This user is already in this document."
msgstr "该用户已在此文档中。"
#: build/lib/core/models.py:1087 core/models.py:1087
msgid "This team is already in this document."
msgstr "该团队已在此文档中。"
#: build/lib/core/models.py:1093 build/lib/core/models.py:1241
#: core/models.py:1093 core/models.py:1241
msgid "Either user or team must be set, not both."
msgstr "必须设置用户或团队之一,不能同时设置两者。"
#: build/lib/core/models.py:1155 core/models.py:1155
msgid "description"
msgstr "说明"
#: build/lib/core/models.py:1156 core/models.py:1156
msgid "code"
msgstr "代码"
#: build/lib/core/models.py:1157 core/models.py:1157
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1159 core/models.py:1159
msgid "public"
msgstr "公开"
#: build/lib/core/models.py:1161 core/models.py:1161
msgid "Whether this template is public for anyone to use."
msgstr "该模板是否公开供任何人使用。"
#: build/lib/core/models.py:1167 core/models.py:1167
msgid "Template"
msgstr "模板"
#: build/lib/core/models.py:1168 core/models.py:1168
msgid "Templates"
msgstr "模板"
#: build/lib/core/models.py:1222 core/models.py:1222
msgid "Template/user relation"
msgstr "模板/用户关系"
#: build/lib/core/models.py:1223 core/models.py:1223
msgid "Template/user relations"
msgstr "模板/用户关系集"
#: build/lib/core/models.py:1229 core/models.py:1229
msgid "This user is already in this template."
msgstr "该用户已在此模板中。"
#: build/lib/core/models.py:1235 core/models.py:1235
msgid "This team is already in this template."
msgstr "该团队已在此模板中。"
#: build/lib/core/models.py:1258 core/models.py:1258
msgid "email address"
msgstr "电子邮件地址"
#: build/lib/core/models.py:1277 core/models.py:1277
msgid "Document invitation"
msgstr "文档邀请"
#: build/lib/core/models.py:1278 core/models.py:1278
msgid "Document invitations"
msgstr "文档邀请"
#: build/lib/core/models.py:1298 core/models.py:1298
msgid "This email is already associated to a registered user."
msgstr "此电子邮件已经与现有注册用户关联。"
#: core/templates/mail/html/invitation.html:162
#: core/templates/mail/text/invitation.txt:3
msgid "Logo email"
msgstr "徽标邮件"
#: core/templates/mail/html/invitation.html:209
#: core/templates/mail/text/invitation.txt:10
msgid "Open"
msgstr "打开"
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.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/invitation.html:233
#: core/templates/mail/text/invitation.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
msgstr " 由 %(brandname)s 倾力打造。 "

View File

@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "impress"
version = "2.2.0"
version = "3.1.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -25,38 +25,41 @@ license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"boto3==1.36.7",
"beautifulsoup4==4.13.3",
"boto3==1.37.33",
"Brotli==1.1.0",
"celery[redis]==5.4.0",
"celery[redis]==5.5.1",
"django-configurations==2.5.1",
"django-cors-headers==4.6.0",
"django-cors-headers==4.7.0",
"django-countries==7.6.1",
"django-filter==24.3",
"django-filter==25.1",
"django-lasuite==0.0.7",
"django-parler==2.3",
"redis==5.2.1",
"django-redis==5.4.0",
"django-storages[s3]==1.14.4",
"django-storages[s3]==1.14.6",
"django-timezone-field>=5.1",
"django==5.1.5",
"django==5.1.8",
"django-treebeard==4.7.1",
"djangorestframework==3.15.2",
"djangorestframework==3.16.0",
"drf_spectacular==0.28.0",
"dockerflow==2024.4.2",
"easy_thumbnails==2.10",
"factory_boy==3.3.1",
"factory_boy==3.3.3",
"gunicorn==23.0.0",
"jsonschema==4.23.0",
"markdown==3.7",
"lxml==5.3.2",
"markdown==3.8",
"mozilla-django-oidc==4.0.1",
"nested-multipart-parser==1.5.0",
"openai==1.60.2",
"psycopg[binary]==3.2.4",
"openai==1.73.0",
"psycopg[binary]==3.2.6",
"pycrdt==0.12.12",
"PyJWT==2.10.1",
"python-magic==0.4.27",
"requests==2.32.3",
"sentry-sdk==2.20.0",
"url-normalize==1.4.3",
"whitenoise==6.8.2",
"mozilla-django-oidc==4.0.1",
"sentry-sdk==2.25.1",
"whitenoise==6.9.0",
]
[project.urls]
@@ -67,23 +70,23 @@ dependencies = [
[project.optional-dependencies]
dev = [
"django-extensions==3.2.3",
"django-extensions==4.1",
"django-test-migrations==1.4.0",
"drf-spectacular-sidecar==2024.12.1",
"drf-spectacular-sidecar==2025.4.1",
"freezegun==1.5.1",
"ipdb==0.13.13",
"ipython==8.31.0",
"pyfakefs==5.7.4",
"ipython==9.1.0",
"pyfakefs==5.8.0",
"pylint-django==2.6.1",
"pylint==3.3.4",
"pytest-cov==6.0.0",
"pytest-django==4.9.0",
"pytest==8.3.4",
"pylint==3.3.6",
"pytest-cov==6.1.1",
"pytest-django==4.11.1",
"pytest==8.3.5",
"pytest-icdiff==0.9",
"pytest-xdist==3.6.1",
"responses==0.25.6",
"ruff==0.9.3",
"types-requests==2.32.0.20241016",
"responses==0.25.7",
"ruff==0.11.5",
"types-requests==2.32.0.20250328",
]
[tool.setuptools]

View File

@@ -1,9 +1,9 @@
module.exports = {
root: true,
extends: ["impress/playwright"],
extends: ['impress/playwright'],
parserOptions: {
tsconfigRootDir: __dirname,
project: ["./tsconfig.json"],
project: ['./tsconfig.json'],
},
ignorePatterns: ["node_modules"],
ignorePatterns: ['node_modules'],
};

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