mirror of
https://github.com/suitenumerique/docs.git
synced 2026-05-06 15:12:27 +02:00
Compare commits
62 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5bb7ad643a | ||
|
|
57b8881fc6 | ||
|
|
89ad610ba6 | ||
|
|
251787b835 | ||
|
|
f95173e096 | ||
|
|
a7944cce80 | ||
|
|
7941fc91d5 | ||
|
|
7fc83a4fcd | ||
|
|
2bf47b7705 | ||
|
|
23b0214a2a | ||
|
|
f244509de3 | ||
|
|
fda5f8f008 | ||
|
|
9a79b09b07 | ||
|
|
b24acd14e2 | ||
|
|
1531846115 | ||
|
|
ebf6d46e37 | ||
|
|
b9b5f86cf4 | ||
|
|
56412b0be5 | ||
|
|
af052cd06b | ||
|
|
8927635c5f | ||
|
|
76bce4313b | ||
|
|
5ac71bfac1 | ||
|
|
cb4e148afc | ||
|
|
2d24825be0 | ||
|
|
7b1ddc0e05 | ||
|
|
22a665e535 | ||
|
|
a22bf95bce | ||
|
|
3ce1826355 | ||
|
|
d099d58f77 | ||
|
|
ebd49f05a8 | ||
|
|
315c2c2c43 | ||
|
|
e442908c50 | ||
|
|
6672292d93 | ||
|
|
7dda74421f | ||
|
|
9c25b684e3 | ||
|
|
cd5ee3fb7c | ||
|
|
942c0f059c | ||
|
|
3acee1e6fa | ||
|
|
26ea32bd0b | ||
|
|
7f6ffa0123 | ||
|
|
ef2127585c | ||
|
|
54a75bc338 | ||
|
|
50d098c777 | ||
|
|
757c09b189 | ||
|
|
30c5cfab62 | ||
|
|
f069329e18 | ||
|
|
ef8ee67553 | ||
|
|
ad47fc2d60 | ||
|
|
009f5d6ed4 | ||
|
|
64d0072c8d | ||
|
|
aefbc2e0b9 | ||
|
|
15dc1e3012 | ||
|
|
6cc20aeacb | ||
|
|
7da7214afb | ||
|
|
c369419512 | ||
|
|
d9ad397c94 | ||
|
|
3191d890f3 | ||
|
|
68f3387539 | ||
|
|
0dc8b4556c | ||
|
|
e123e91959 | ||
|
|
2709400773 | ||
|
|
8281c6159b |
22
.github/workflows/impress-frontend.yml
vendored
22
.github/workflows/impress-frontend.yml
vendored
@@ -88,28 +88,6 @@ jobs:
|
||||
- name: Start Docker services
|
||||
run: make bootstrap FLUSH_ARGS='--no-input' cache=
|
||||
|
||||
# Tool to wait for a service to be ready
|
||||
- name: Install Dockerize
|
||||
run: |
|
||||
curl -sSL https://github.com/jwilder/dockerize/releases/download/v0.8.0/dockerize-linux-amd64-v0.8.0.tar.gz | sudo tar -C /usr/local/bin -xzv
|
||||
|
||||
- name: Wait for services to be ready
|
||||
run: |
|
||||
printf "Minio check...\n"
|
||||
dockerize -wait tcp://localhost:9000 -timeout 20s
|
||||
printf "Keyclock check...\n"
|
||||
dockerize -wait tcp://localhost:8080 -timeout 20s
|
||||
printf "Server collaboration check...\n"
|
||||
dockerize -wait tcp://localhost:4444 -timeout 20s
|
||||
printf "Ngnix check...\n"
|
||||
dockerize -wait tcp://localhost:8083 -timeout 20s
|
||||
printf "DRF check...\n"
|
||||
dockerize -wait tcp://localhost:8071 -timeout 20s
|
||||
printf "Postgres Keyclock check...\n"
|
||||
dockerize -wait tcp://localhost:5433 -timeout 20s
|
||||
printf "Postgres back check...\n"
|
||||
dockerize -wait tcp://localhost:15432 -timeout 20s
|
||||
|
||||
- name: Run e2e tests
|
||||
run: cd src/frontend/ && yarn e2e:test --project='chromium'
|
||||
|
||||
|
||||
68
CHANGELOG.md
68
CHANGELOG.md
@@ -6,9 +6,46 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0),
|
||||
and this project adheres to
|
||||
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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
|
||||
|
||||
- 💄(frontend) add error pages #643
|
||||
- 🔒️ Manage unsafe attachments #663
|
||||
- ✨(frontend) Custom block quote with export #646
|
||||
- ✨(frontend) add open source section homepage #666
|
||||
- ✨(frontend) synchronize language-choice #401
|
||||
|
||||
## Changed
|
||||
|
||||
- 🛂(frontend) Restore version visibility #629
|
||||
- 📝(doc) minor README.md formatting and wording enhancements
|
||||
- ♻️Stop setting a default title on doc creation #634
|
||||
- ♻️(frontend) misc ui improvements #644
|
||||
|
||||
## Fixed
|
||||
|
||||
- 🐛(backend) allow any type of extensions for media download #671
|
||||
- ♻️(frontend) improve table pdf rendering
|
||||
- 🐛(email) invitation emails in receivers language
|
||||
|
||||
|
||||
## [2.2.0] - 2025-02-10
|
||||
|
||||
## Added
|
||||
@@ -28,7 +65,6 @@ and this project adheres to
|
||||
- 🐛(frontend) fix cursor breakline #609
|
||||
- 🐛(frontend) fix style pdf export #609
|
||||
|
||||
|
||||
## [2.1.0] - 2025-01-29
|
||||
|
||||
## Added
|
||||
@@ -44,7 +80,7 @@ and this project adheres to
|
||||
## Changed
|
||||
|
||||
- 💄(frontend) add abilities on doc row #581
|
||||
- 💄(frontend) improve DocsGridItem responsive padding #582
|
||||
- 💄(frontend) improve DocsGridItem responsive padding #582
|
||||
- 🔧(backend) Bump maximum page size to 200 #516
|
||||
- 📝(doc) Improve Read me #558
|
||||
|
||||
@@ -56,7 +92,6 @@ and this project adheres to
|
||||
|
||||
- 🔥(backend) remove "content" field from list serializer # 516
|
||||
|
||||
|
||||
## [2.0.1] - 2025-01-17
|
||||
|
||||
## Fixed
|
||||
@@ -111,12 +146,11 @@ and this project adheres to
|
||||
|
||||
- ⚡️(e2e) reduce flakiness on e2e tests #511
|
||||
|
||||
|
||||
## Fixed
|
||||
|
||||
- 🐛(frontend) update doc editor height #481
|
||||
- 💄(frontend) add doc search #485
|
||||
|
||||
|
||||
## [1.9.0] - 2024-12-11
|
||||
|
||||
## Added
|
||||
@@ -138,21 +172,18 @@ and this project adheres to
|
||||
- 🐛(frontend) Fix hidden menu on Firefox #468
|
||||
- 🐛(backend) fix sanitize problem IA #490
|
||||
|
||||
|
||||
## [1.8.2] - 2024-11-28
|
||||
|
||||
## Changed
|
||||
|
||||
- ♻️(SW) change strategy html caching #460
|
||||
|
||||
|
||||
## [1.8.1] - 2024-11-27
|
||||
|
||||
## Fixed
|
||||
|
||||
- 🐛(frontend) link not clickable and flickering firefox #457
|
||||
|
||||
|
||||
## [1.8.0] - 2024-11-25
|
||||
|
||||
## Added
|
||||
@@ -181,7 +212,6 @@ and this project adheres to
|
||||
- 🐛(frontend) users have view access when revoked #387
|
||||
- 🐛(frontend) fix placeholder editable when double clicks #454
|
||||
|
||||
|
||||
## [1.7.0] - 2024-10-24
|
||||
|
||||
## Added
|
||||
@@ -209,7 +239,6 @@ and this project adheres to
|
||||
|
||||
- 🔥(helm) remove infra related codes #366
|
||||
|
||||
|
||||
## [1.6.0] - 2024-10-17
|
||||
|
||||
## Added
|
||||
@@ -232,7 +261,6 @@ and this project adheres to
|
||||
- 🐛(backend) fix nginx docker container #340
|
||||
- 🐛(frontend) fix copy paste firefox #353
|
||||
|
||||
|
||||
## [1.5.1] - 2024-10-10
|
||||
|
||||
## Fixed
|
||||
@@ -267,7 +295,6 @@ and this project adheres to
|
||||
- 🔧(backend) fix configuration to avoid different ssl warning #297
|
||||
- 🐛(frontend) fix editor break line not working #302
|
||||
|
||||
|
||||
## [1.4.0] - 2024-09-17
|
||||
|
||||
## Added
|
||||
@@ -288,7 +315,6 @@ and this project adheres to
|
||||
- 🐛(backend) Fix forcing ID when creating a document via API endpoint #234
|
||||
- 🐛 Rebuild frontend dev container from makefile #248
|
||||
|
||||
|
||||
## [1.3.0] - 2024-09-05
|
||||
|
||||
## Added
|
||||
@@ -313,7 +339,6 @@ and this project adheres to
|
||||
|
||||
- 🔥(frontend) remove saving modal #213
|
||||
|
||||
|
||||
## [1.2.1] - 2024-08-23
|
||||
|
||||
## Changed
|
||||
@@ -321,7 +346,6 @@ and this project adheres to
|
||||
- ♻️ Change ordering docs datagrid #195
|
||||
- 🔥(helm) use scaleway email #194
|
||||
|
||||
|
||||
## [1.2.0] - 2024-08-22
|
||||
|
||||
## Added
|
||||
@@ -345,14 +369,14 @@ and this project adheres to
|
||||
- ⚡️(CI) only e2e chrome mandatory #177
|
||||
|
||||
## Removed
|
||||
- 🔥(helm) remove htaccess #181
|
||||
|
||||
- 🔥(helm) remove htaccess #181
|
||||
|
||||
## [1.1.0] - 2024-07-15
|
||||
|
||||
## Added
|
||||
|
||||
- 🤡(demo) generate dummy documents on dev users #120
|
||||
- 🤡(demo) generate dummy documents on dev users #120
|
||||
- ✨(frontend) create side modal component #134
|
||||
- ✨(frontend) Doc grid actions (update / delete) #136
|
||||
- ✨(frontend) Doc editor header information #137
|
||||
@@ -363,12 +387,11 @@ and this project adheres to
|
||||
- ♻️(frontend) create a doc from a modal #132
|
||||
- ♻️(frontend) manage members from the share modal #140
|
||||
|
||||
|
||||
## [1.0.0] - 2024-07-02
|
||||
|
||||
## Added
|
||||
|
||||
- 🛂(frontend) Manage the document's right (#75)
|
||||
- 🛂(frontend) Manage the document's right (#75)
|
||||
- ✨(frontend) Update document (#68)
|
||||
- ✨(frontend) Remove document (#68)
|
||||
- 🐳(docker) dockerize dev frontend (#63)
|
||||
@@ -402,7 +425,6 @@ and this project adheres to
|
||||
- 💚(CI) Remove trigger workflow on push tags on CI (#68)
|
||||
- 🔥(frontend) Remove coming soon page (#121)
|
||||
|
||||
|
||||
## [0.1.0] - 2024-05-24
|
||||
|
||||
## Added
|
||||
@@ -410,8 +432,8 @@ and this project adheres to
|
||||
- ✨(frontend) Coming Soon page (#67)
|
||||
- 🚀 Impress, project to manage your documents easily and collaboratively.
|
||||
|
||||
|
||||
[unreleased]: https://github.com/numerique-gouv/impress/compare/v2.2.0...main
|
||||
[unreleased]: https://github.com/numerique-gouv/impress/compare/v2.3.0...main
|
||||
[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
|
||||
|
||||
30
Makefile
30
Makefile
@@ -44,7 +44,6 @@ COMPOSE_EXEC_APP = $(COMPOSE_EXEC) app-dev
|
||||
COMPOSE_RUN = $(COMPOSE) run --rm
|
||||
COMPOSE_RUN_APP = $(COMPOSE_RUN) app-dev
|
||||
COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
|
||||
WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT) -timeout 60s
|
||||
|
||||
# -- Backend
|
||||
MANAGE = $(COMPOSE_RUN_APP) python manage.py
|
||||
@@ -81,12 +80,12 @@ bootstrap: \
|
||||
data/static \
|
||||
create-env-files \
|
||||
build \
|
||||
run-with-frontend \
|
||||
migrate \
|
||||
demo \
|
||||
back-i18n-compile \
|
||||
mails-install \
|
||||
mails-build
|
||||
mails-build \
|
||||
run
|
||||
.PHONY: bootstrap
|
||||
|
||||
# -- Docker/compose
|
||||
@@ -109,7 +108,7 @@ build-yjs-provider: ## build the y-provider container
|
||||
|
||||
build-frontend: cache ?=
|
||||
build-frontend: ## build the frontend container
|
||||
@$(COMPOSE) build frontend-dev $(cache)
|
||||
@$(COMPOSE) build frontend $(cache)
|
||||
.PHONY: build-frontend
|
||||
|
||||
down: ## stop and remove containers, networks, images, and volumes
|
||||
@@ -120,18 +119,17 @@ logs: ## display app-dev logs (follow mode)
|
||||
@$(COMPOSE) logs -f app-dev
|
||||
.PHONY: logs
|
||||
|
||||
run: ## start the wsgi (production) and development server
|
||||
run-backend: ## Start only the backend application and all needed services
|
||||
@$(COMPOSE) up --force-recreate -d celery-dev
|
||||
@$(COMPOSE) up --force-recreate -d y-provider
|
||||
@$(COMPOSE) up --force-recreate -d nginx
|
||||
@echo "Wait for postgresql to be up..."
|
||||
@$(WAIT_DB)
|
||||
.PHONY: run
|
||||
.PHONY: run-backend
|
||||
|
||||
run-with-frontend: ## Start all the containers needed (backend to frontend)
|
||||
@$(MAKE) run
|
||||
@$(COMPOSE) up --force-recreate -d frontend-dev
|
||||
.PHONY: run-with-frontend
|
||||
run: ## start the wsgi (production) and development server
|
||||
run:
|
||||
@$(MAKE) run-backend
|
||||
@$(COMPOSE) up --force-recreate -d frontend
|
||||
.PHONY: run
|
||||
|
||||
status: ## an alias for "docker compose ps"
|
||||
@$(COMPOSE) ps
|
||||
@@ -188,14 +186,12 @@ test-back-parallel: ## run all back-end tests in parallel
|
||||
makemigrations: ## run django makemigrations for the impress project.
|
||||
@echo "$(BOLD)Running makemigrations$(RESET)"
|
||||
@$(COMPOSE) up -d postgresql
|
||||
@$(WAIT_DB)
|
||||
@$(MANAGE) makemigrations
|
||||
.PHONY: makemigrations
|
||||
|
||||
migrate: ## run django migrations for the impress project.
|
||||
@echo "$(BOLD)Running migrations$(RESET)"
|
||||
@$(COMPOSE) up -d postgresql
|
||||
@$(WAIT_DB)
|
||||
@$(MANAGE) migrate
|
||||
.PHONY: migrate
|
||||
|
||||
@@ -310,16 +306,16 @@ help:
|
||||
.PHONY: help
|
||||
|
||||
# Front
|
||||
frontend-install: ## install the frontend locally
|
||||
frontend-development-install: ## install the frontend locally
|
||||
cd $(PATH_FRONT_IMPRESS) && yarn
|
||||
.PHONY: frontend-install
|
||||
.PHONY: frontend-development-install
|
||||
|
||||
frontend-lint: ## run the frontend linter
|
||||
cd $(PATH_FRONT) && yarn lint
|
||||
.PHONY: frontend-lint
|
||||
|
||||
run-frontend-development: ## Run the frontend in development mode
|
||||
@$(COMPOSE) stop frontend-dev
|
||||
@$(COMPOSE) stop frontend
|
||||
cd $(PATH_FRONT_IMPRESS) && yarn dev
|
||||
.PHONY: run-frontend-development
|
||||
|
||||
|
||||
40
README.md
40
README.md
@@ -23,6 +23,7 @@ Welcome to Docs! The open source document editor where your notes can become kno
|
||||
<img src="/docs/assets/docs_live_collaboration_light.gif" width="100%" align="center"/>
|
||||
|
||||
## Why use Docs ❓
|
||||
|
||||
Docs is a collaborative text editor designed to address common challenges in knowledge building and sharing.
|
||||
|
||||
### Write
|
||||
@@ -33,23 +34,31 @@ Docs is a collaborative text editor designed to address common challenges in kno
|
||||
* ✨ Save time thanks to our AI actions (generate, sum up, correct, translate)
|
||||
|
||||
### Collaborate
|
||||
* 🤝 Collaborate in realtime with your team mates
|
||||
* 🔒 Granular access control to keep your information secure and shared with the right people
|
||||
* 🤝 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 transform 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 02/2025`
|
||||
|
||||
### Self-host
|
||||
* 🚀 Easy to install, scalable and secure alternative to Notion, Outline or Confluence
|
||||
|
||||
## Getting started 🔧
|
||||
|
||||
### Test it
|
||||
|
||||
Test Docs on your browser by logging in on this [environment](https://impress-preprod.beta.numerique.gouv.fr/docs/0aa856e9-da41-4d59-b73d-a61cb2c1245f/)
|
||||
|
||||
```
|
||||
email: test.docs@yopmail.com
|
||||
password: I'd<3ToTestDocs
|
||||
```
|
||||
|
||||
### Run it locally
|
||||
|
||||
> ⚠️ Running Docs locally using the methods described below is for testing purposes only. It is based on building Docs using Minio as the S3 storage solution: if you want to use Minio for production deployment of Docs, you will need to comply with Minio's AGPL-3.0 licence.
|
||||
|
||||
**Prerequisite**
|
||||
|
||||
Make sure you have a recent version of Docker and [Docker Compose](https://docs.docker.com/compose/install) installed on your laptop:
|
||||
|
||||
```shellscript
|
||||
@@ -57,23 +66,22 @@ $ docker -v
|
||||
|
||||
Docker version 20.10.2, build 2291f61
|
||||
|
||||
$ docker compose -v
|
||||
$ docker compose version
|
||||
|
||||
docker compose version 1.27.4, build 40524192
|
||||
Docker Compose version v2.32.4
|
||||
```
|
||||
|
||||
> ⚠️ You may need to run the following commands with sudo but this can be avoided by adding your user to the `docker` group.
|
||||
|
||||
**Project bootstrap**
|
||||
|
||||
The easiest way to start working on the project is to use GNU Make:
|
||||
|
||||
```shellscript
|
||||
$ make bootstrap FLUSH_ARGS='--no-input'
|
||||
```
|
||||
|
||||
This command builds the `app` container, installs dependencies, performs database migrations and compile translations. It's a good idea to use this
|
||||
|
||||
command each time you are pulling code from the project repository to avoid dependency-related or migration-related issues.
|
||||
This command builds the `app` container, installs dependencies, performs database migrations and compile translations. It's a good idea to use this command each time you are pulling code from the project repository to avoid dependency-related or migration-related issues.
|
||||
|
||||
Your Docker services should now be up and running 🎉
|
||||
|
||||
@@ -89,7 +97,7 @@ password: impress
|
||||
📝 Note that if you need to run them afterwards, you can use the eponym Make rule:
|
||||
|
||||
```shellscript
|
||||
$ make run-with-frontend
|
||||
$ make run
|
||||
```
|
||||
|
||||
⚠️ For the frontend developer, it is often better to run the frontend in development mode locally.
|
||||
@@ -97,7 +105,7 @@ $ make run-with-frontend
|
||||
To do so, install the frontend dependencies with the following command:
|
||||
|
||||
```shellscript
|
||||
$ make frontend-install
|
||||
$ make frontend-development-install
|
||||
```
|
||||
|
||||
And run the frontend locally in development mode with the following command:
|
||||
@@ -109,7 +117,7 @@ $ make run-frontend-development
|
||||
To start all the services, except the frontend container, you can use the following command:
|
||||
|
||||
```shellscript
|
||||
$ make run
|
||||
$ make run-backend
|
||||
```
|
||||
|
||||
**Adding content**
|
||||
@@ -126,6 +134,7 @@ $ make help
|
||||
```
|
||||
|
||||
**Django admin**
|
||||
|
||||
You can access the Django admin site at
|
||||
|
||||
<http://localhost:8071/admin>.
|
||||
@@ -137,17 +146,21 @@ $ make superuser
|
||||
```
|
||||
|
||||
## Feedback 🙋♂️🙋♀️
|
||||
|
||||
We'd love to hear your thoughts and hear about your experiments, so come and say hi on [Matrix](https://matrix.to/#/#docs-official:matrix.org).
|
||||
|
||||
## Roadmap
|
||||
|
||||
Want to know where the project is headed? [🗺️ Checkout our roadmap](https://github.com/orgs/numerique-gouv/projects/13/views/11)
|
||||
|
||||
## Licence 📝
|
||||
|
||||
This work is released under the MIT License (see [LICENSE](https://github.com/suitenumerique/docs/blob/main/LICENSE)).
|
||||
|
||||
While Docs is a public driven initiative our licence choice is an invitation for private sector actors to use, sell and contribute to the project.
|
||||
|
||||
## Contributing 🙌
|
||||
|
||||
This project is intended to be community-driven, so please, do not hesitate to [get in touch](https://matrix.to/#/#docs-official:matrix.org) if you have any question related to our implementation or design decisions.
|
||||
|
||||
You can help us with translations on [Crowdin](https://crowdin.com/project/lasuite-docs).
|
||||
@@ -169,10 +182,13 @@ docs
|
||||
```
|
||||
|
||||
## Credits ❤️
|
||||
|
||||
### Stack
|
||||
Docs is built on top of [Django Rest Framework](https://www.django-rest-framework.org/), [Next.js](https://nextjs.org/), [MinIO](https://min.io/), [BlockNote.js](https://www.blocknotejs.org/), [HocusPocus](https://tiptap.dev/docs/hocuspocus/introduction) and [Yjs](https://yjs.dev/)
|
||||
|
||||
Docs is built on top of [Django Rest Framework](https://www.django-rest-framework.org/), [Next.js](https://nextjs.org/), [BlockNote.js](https://www.blocknotejs.org/), [HocusPocus](https://tiptap.dev/docs/hocuspocus/introduction) and [Yjs](https://yjs.dev/).
|
||||
|
||||
### Gov ❤️ open source
|
||||
|
||||
Docs is the result of a joint effort led by the French 🇫🇷🥖 ([DINUM](https://www.numerique.gouv.fr/dinum/)) and German 🇩🇪🥨 governments ([ZenDiS](https://zendis.de/)).
|
||||
|
||||
We are proud sponsors of [BlockNotejs](https://www.blocknotejs.org/) and [Yjs](https://yjs.dev/).
|
||||
|
||||
@@ -7,7 +7,6 @@ UNSET_USER=0
|
||||
|
||||
TERRAFORM_DIRECTORY="./env.d/terraform"
|
||||
COMPOSE_FILE="${REPO_DIR}/docker-compose.yml"
|
||||
COMPOSE_PROJECT="docs"
|
||||
|
||||
|
||||
# _set_user: set (or unset) default user id used to run docker commands
|
||||
@@ -40,9 +39,8 @@ function _set_user() {
|
||||
# ARGS : docker compose command arguments
|
||||
function _docker_compose() {
|
||||
|
||||
echo "🐳(compose) project: '${COMPOSE_PROJECT}' file: '${COMPOSE_FILE}'"
|
||||
echo "🐳(compose) file: '${COMPOSE_FILE}'"
|
||||
docker compose \
|
||||
-p "${COMPOSE_PROJECT}" \
|
||||
-f "${COMPOSE_FILE}" \
|
||||
--project-directory "${REPO_DIR}" \
|
||||
"$@"
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
name: docs
|
||||
|
||||
services:
|
||||
postgresql:
|
||||
image: postgres:16
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready"]
|
||||
interval: 1s
|
||||
timeout: 2s
|
||||
retries: 300
|
||||
env_file:
|
||||
- env.d/development/postgresql
|
||||
ports:
|
||||
@@ -15,7 +22,7 @@ services:
|
||||
- "1081:1080"
|
||||
|
||||
minio:
|
||||
# user: ${DOCKER_USER:-1000}
|
||||
user: ${DOCKER_USER:-1000}
|
||||
image: minio/minio
|
||||
environment:
|
||||
- MINIO_ROOT_USER=impress
|
||||
@@ -23,6 +30,11 @@ services:
|
||||
ports:
|
||||
- '9000:9000'
|
||||
- '9001:9001'
|
||||
healthcheck:
|
||||
test: ["CMD", "mc", "ready", "local"]
|
||||
interval: 1s
|
||||
timeout: 20s
|
||||
retries: 300
|
||||
entrypoint: ""
|
||||
command: minio server --console-address :9001 /data
|
||||
volumes:
|
||||
@@ -31,7 +43,9 @@ services:
|
||||
createbuckets:
|
||||
image: minio/mc
|
||||
depends_on:
|
||||
- minio
|
||||
minio:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
entrypoint: >
|
||||
sh -c "
|
||||
/usr/bin/mc alias set impress http://minio:9000 impress password && \
|
||||
@@ -59,10 +73,15 @@ services:
|
||||
- ./src/backend:/app
|
||||
- ./data/static:/data/static
|
||||
depends_on:
|
||||
- postgresql
|
||||
- mailcatcher
|
||||
- redis
|
||||
- createbuckets
|
||||
postgresql:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
mailcatcher:
|
||||
condition: service_started
|
||||
redis:
|
||||
condition: service_started
|
||||
createbuckets:
|
||||
condition: service_started
|
||||
|
||||
celery-dev:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
@@ -93,9 +112,13 @@ services:
|
||||
- env.d/development/common
|
||||
- env.d/development/postgresql
|
||||
depends_on:
|
||||
- postgresql
|
||||
- redis
|
||||
- minio
|
||||
postgresql:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
redis:
|
||||
condition: service_started
|
||||
minio:
|
||||
condition: service_started
|
||||
|
||||
celery:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
@@ -116,11 +139,15 @@ services:
|
||||
volumes:
|
||||
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
|
||||
depends_on:
|
||||
- keycloak
|
||||
- app-dev
|
||||
- y-provider
|
||||
app-dev:
|
||||
condition: service_started
|
||||
y-provider:
|
||||
condition: service_started
|
||||
keycloak:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
|
||||
frontend-dev:
|
||||
frontend:
|
||||
user: "${DOCKER_USER:-1000}"
|
||||
build:
|
||||
context: .
|
||||
@@ -135,9 +162,6 @@ services:
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
dockerize:
|
||||
image: jwilder/dockerize
|
||||
|
||||
crowdin:
|
||||
image: crowdin/cli:3.16.0
|
||||
volumes:
|
||||
@@ -169,6 +193,11 @@ services:
|
||||
|
||||
kc_postgresql:
|
||||
image: postgres:14.3
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready"]
|
||||
interval: 1s
|
||||
timeout: 2s
|
||||
retries: 300
|
||||
ports:
|
||||
- "5433:5432"
|
||||
env_file:
|
||||
@@ -187,6 +216,13 @@ services:
|
||||
- --hostname-admin-url=http://localhost:8083/
|
||||
- --hostname-strict=false
|
||||
- --hostname-strict-https=false
|
||||
- --health-enabled=true
|
||||
- --metrics-enabled=true
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "--head", "-fsS", "http://localhost:8080/health/ready"]
|
||||
interval: 1s
|
||||
timeout: 2s
|
||||
retries: 300
|
||||
environment:
|
||||
KEYCLOAK_ADMIN: admin
|
||||
KEYCLOAK_ADMIN_PASSWORD: admin
|
||||
@@ -200,4 +236,6 @@ services:
|
||||
ports:
|
||||
- "8080:8080"
|
||||
depends_on:
|
||||
- kc_postgresql
|
||||
kc_postgresql:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
|
||||
@@ -68,6 +68,8 @@ server {
|
||||
# Get resource from Minio
|
||||
proxy_pass http://minio:9000/impress-media-storage/;
|
||||
proxy_set_header Host minio:9000;
|
||||
|
||||
add_header Content-Security-Policy "default-src 'none'" always;
|
||||
}
|
||||
|
||||
location /media-auth {
|
||||
@@ -88,5 +90,11 @@ server {
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
# Increase proxy buffer size to allow keycloak to send large
|
||||
# header responses when a user is created.
|
||||
proxy_buffer_size 128k;
|
||||
proxy_buffers 4 256k;
|
||||
proxy_busy_buffers_size 256k;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ class UserSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = ["id", "email", "full_name", "short_name"]
|
||||
fields = ["id", "email", "full_name", "short_name", "language"]
|
||||
read_only_fields = ["id", "email", "full_name", "short_name"]
|
||||
|
||||
|
||||
@@ -418,6 +418,7 @@ class FileUploadSerializer(serializers.Serializer):
|
||||
|
||||
self.context["expected_extension"] = extension
|
||||
self.context["content_type"] = magic_mime_type
|
||||
self.context["file_name"] = file.name
|
||||
|
||||
return file
|
||||
|
||||
@@ -426,6 +427,7 @@ class FileUploadSerializer(serializers.Serializer):
|
||||
attrs["expected_extension"] = self.context["expected_extension"]
|
||||
attrs["is_unsafe"] = self.context["is_unsafe"]
|
||||
attrs["content_type"] = self.context["content_type"]
|
||||
attrs["file_name"] = self.context["file_name"]
|
||||
return attrs
|
||||
|
||||
|
||||
|
||||
@@ -38,10 +38,10 @@ 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}"
|
||||
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<key>{ATTACHMENTS_FOLDER:s}/{UUID_REGEX:s}{FILE_EXT_REGEX:s})$"
|
||||
f"(?P<key>{ATTACHMENTS_FOLDER:s}/{UUID_REGEX:s}(?:-unsafe)?{FILE_EXT_REGEX:s})$"
|
||||
)
|
||||
COLLABORATION_WS_URL_PATTERN = re.compile(rf"(?:^|&)room=(?P<pk>{UUID_REGEX})(?:&|$)")
|
||||
|
||||
@@ -915,15 +915,31 @@ 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}"
|
||||
|
||||
# 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}/{ATTACHMENTS_FOLDER:s}/{file_id!s}{file_unsafe}.{extension:s}"
|
||||
|
||||
file_name = serializer.validated_data["file_name"]
|
||||
if (
|
||||
not serializer.validated_data["content_type"].startswith("image/")
|
||||
or serializer.validated_data["is_unsafe"]
|
||||
):
|
||||
extra_args.update(
|
||||
{"ContentDisposition": f'attachment; filename="{file_name:s}"'}
|
||||
)
|
||||
else:
|
||||
extra_args.update(
|
||||
{"ContentDisposition": f'inline; filename="{file_name:s}"'}
|
||||
)
|
||||
|
||||
file = serializer.validated_data["file"]
|
||||
default_storage.connection.meta.client.upload_fileobj(
|
||||
@@ -1151,13 +1167,14 @@ class DocumentAccessViewSet(
|
||||
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):
|
||||
@@ -1383,10 +1400,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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,166 +1,552 @@
|
||||
# Generated by Django 5.0.3 on 2024-05-28 20:29
|
||||
|
||||
import uuid
|
||||
|
||||
import django.contrib.auth.models
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
import timezone_field.fields
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
import timezone_field.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('auth', '0012_alter_user_first_name_max_length'),
|
||||
("auth", "0012_alter_user_first_name_max_length"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Document',
|
||||
name="Document",
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
|
||||
('title', models.CharField(max_length=255, verbose_name='title')),
|
||||
('is_public', models.BooleanField(default=False, help_text='Whether this document is public for anyone to use.', verbose_name='public')),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
help_text="primary key for the record as UUID",
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="id",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
help_text="date and time at which a record was created",
|
||||
verbose_name="created on",
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(
|
||||
auto_now=True,
|
||||
help_text="date and time at which a record was last updated",
|
||||
verbose_name="updated on",
|
||||
),
|
||||
),
|
||||
("title", models.CharField(max_length=255, verbose_name="title")),
|
||||
(
|
||||
"is_public",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether this document is public for anyone to use.",
|
||||
verbose_name="public",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Document',
|
||||
'verbose_name_plural': 'Documents',
|
||||
'db_table': 'impress_document',
|
||||
'ordering': ('title',),
|
||||
"verbose_name": "Document",
|
||||
"verbose_name_plural": "Documents",
|
||||
"db_table": "impress_document",
|
||||
"ordering": ("title",),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Template',
|
||||
name="Template",
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
|
||||
('title', models.CharField(max_length=255, verbose_name='title')),
|
||||
('description', models.TextField(blank=True, verbose_name='description')),
|
||||
('code', models.TextField(blank=True, verbose_name='code')),
|
||||
('css', models.TextField(blank=True, verbose_name='css')),
|
||||
('is_public', models.BooleanField(default=False, help_text='Whether this template is public for anyone to use.', verbose_name='public')),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
help_text="primary key for the record as UUID",
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="id",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
help_text="date and time at which a record was created",
|
||||
verbose_name="created on",
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(
|
||||
auto_now=True,
|
||||
help_text="date and time at which a record was last updated",
|
||||
verbose_name="updated on",
|
||||
),
|
||||
),
|
||||
("title", models.CharField(max_length=255, verbose_name="title")),
|
||||
(
|
||||
"description",
|
||||
models.TextField(blank=True, verbose_name="description"),
|
||||
),
|
||||
("code", models.TextField(blank=True, verbose_name="code")),
|
||||
("css", models.TextField(blank=True, verbose_name="css")),
|
||||
(
|
||||
"is_public",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether this template is public for anyone to use.",
|
||||
verbose_name="public",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Template',
|
||||
'verbose_name_plural': 'Templates',
|
||||
'db_table': 'impress_template',
|
||||
'ordering': ('title',),
|
||||
"verbose_name": "Template",
|
||||
"verbose_name_plural": "Templates",
|
||||
"db_table": "impress_template",
|
||||
"ordering": ("title",),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='User',
|
||||
name="User",
|
||||
fields=[
|
||||
('password', models.CharField(max_length=128, verbose_name='password')),
|
||||
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
||||
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
|
||||
('sub', models.CharField(blank=True, help_text='Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only.', max_length=255, null=True, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters.', regex='^[\\w.@+-]+\\Z')], verbose_name='sub')),
|
||||
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='identity email address')),
|
||||
('admin_email', models.EmailField(blank=True, max_length=254, null=True, unique=True, verbose_name='admin email address')),
|
||||
('language', models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language')),
|
||||
('timezone', timezone_field.fields.TimeZoneField(choices_display='WITH_GMT_OFFSET', default='UTC', help_text='The timezone in which the user wants to see times.', use_pytz=False)),
|
||||
('is_device', models.BooleanField(default=False, help_text='Whether the user is a device or a real user.', verbose_name='device')),
|
||||
('is_staff', models.BooleanField(default=False, help_text='Whether the user can log into this admin site.', verbose_name='staff status')),
|
||||
('is_active', models.BooleanField(default=True, help_text='Whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
|
||||
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
|
||||
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
|
||||
("password", models.CharField(max_length=128, verbose_name="password")),
|
||||
(
|
||||
"last_login",
|
||||
models.DateTimeField(
|
||||
blank=True, null=True, verbose_name="last login"
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_superuser",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="Designates that this user has all permissions without explicitly assigning them.",
|
||||
verbose_name="superuser status",
|
||||
),
|
||||
),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
help_text="primary key for the record as UUID",
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="id",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
help_text="date and time at which a record was created",
|
||||
verbose_name="created on",
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(
|
||||
auto_now=True,
|
||||
help_text="date and time at which a record was last updated",
|
||||
verbose_name="updated on",
|
||||
),
|
||||
),
|
||||
(
|
||||
"sub",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only.",
|
||||
max_length=255,
|
||||
null=True,
|
||||
unique=True,
|
||||
validators=[
|
||||
django.core.validators.RegexValidator(
|
||||
message="Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters.",
|
||||
regex="^[\\w.@+-]+\\Z",
|
||||
)
|
||||
],
|
||||
verbose_name="sub",
|
||||
),
|
||||
),
|
||||
(
|
||||
"email",
|
||||
models.EmailField(
|
||||
blank=True,
|
||||
max_length=254,
|
||||
null=True,
|
||||
verbose_name="identity email address",
|
||||
),
|
||||
),
|
||||
(
|
||||
"admin_email",
|
||||
models.EmailField(
|
||||
blank=True,
|
||||
max_length=254,
|
||||
null=True,
|
||||
unique=True,
|
||||
verbose_name="admin email address",
|
||||
),
|
||||
),
|
||||
(
|
||||
"language",
|
||||
models.CharField(
|
||||
choices="(('en-us', 'English'), ('fr-fr', 'French'))",
|
||||
default="en-us",
|
||||
help_text="The language in which the user wants to see the interface.",
|
||||
max_length=10,
|
||||
verbose_name="language",
|
||||
),
|
||||
),
|
||||
(
|
||||
"timezone",
|
||||
timezone_field.fields.TimeZoneField(
|
||||
choices_display="WITH_GMT_OFFSET",
|
||||
default="UTC",
|
||||
help_text="The timezone in which the user wants to see times.",
|
||||
use_pytz=False,
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_device",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether the user is a device or a real user.",
|
||||
verbose_name="device",
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_staff",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether the user can log into this admin site.",
|
||||
verbose_name="staff status",
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_active",
|
||||
models.BooleanField(
|
||||
default=True,
|
||||
help_text="Whether this user should be treated as active. Unselect this instead of deleting accounts.",
|
||||
verbose_name="active",
|
||||
),
|
||||
),
|
||||
(
|
||||
"groups",
|
||||
models.ManyToManyField(
|
||||
blank=True,
|
||||
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
|
||||
related_name="user_set",
|
||||
related_query_name="user",
|
||||
to="auth.group",
|
||||
verbose_name="groups",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user_permissions",
|
||||
models.ManyToManyField(
|
||||
blank=True,
|
||||
help_text="Specific permissions for this user.",
|
||||
related_name="user_set",
|
||||
related_query_name="user",
|
||||
to="auth.permission",
|
||||
verbose_name="user permissions",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'user',
|
||||
'verbose_name_plural': 'users',
|
||||
'db_table': 'impress_user',
|
||||
"verbose_name": "user",
|
||||
"verbose_name_plural": "users",
|
||||
"db_table": "impress_user",
|
||||
},
|
||||
managers=[
|
||||
('objects', django.contrib.auth.models.UserManager()),
|
||||
("objects", django.contrib.auth.models.UserManager()),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='DocumentAccess',
|
||||
name="DocumentAccess",
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
|
||||
('team', models.CharField(blank=True, max_length=100)),
|
||||
('role', models.CharField(choices=[('reader', 'Reader'), ('editor', 'Editor'), ('administrator', 'Administrator'), ('owner', 'Owner')], default='reader', max_length=20)),
|
||||
('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='accesses', to='core.document')),
|
||||
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
help_text="primary key for the record as UUID",
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="id",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
help_text="date and time at which a record was created",
|
||||
verbose_name="created on",
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(
|
||||
auto_now=True,
|
||||
help_text="date and time at which a record was last updated",
|
||||
verbose_name="updated on",
|
||||
),
|
||||
),
|
||||
("team", models.CharField(blank=True, max_length=100)),
|
||||
(
|
||||
"role",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("reader", "Reader"),
|
||||
("editor", "Editor"),
|
||||
("administrator", "Administrator"),
|
||||
("owner", "Owner"),
|
||||
],
|
||||
default="reader",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"document",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="accesses",
|
||||
to="core.document",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Document/user relation',
|
||||
'verbose_name_plural': 'Document/user relations',
|
||||
'db_table': 'impress_document_access',
|
||||
'ordering': ('-created_at',),
|
||||
"verbose_name": "Document/user relation",
|
||||
"verbose_name_plural": "Document/user relations",
|
||||
"db_table": "impress_document_access",
|
||||
"ordering": ("-created_at",),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Invitation',
|
||||
name="Invitation",
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
|
||||
('email', models.EmailField(max_length=254, verbose_name='email address')),
|
||||
('role', models.CharField(choices=[('reader', 'Reader'), ('editor', 'Editor'), ('administrator', 'Administrator'), ('owner', 'Owner')], default='reader', max_length=20)),
|
||||
('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to='core.document')),
|
||||
('issuer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to=settings.AUTH_USER_MODEL)),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
help_text="primary key for the record as UUID",
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="id",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
help_text="date and time at which a record was created",
|
||||
verbose_name="created on",
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(
|
||||
auto_now=True,
|
||||
help_text="date and time at which a record was last updated",
|
||||
verbose_name="updated on",
|
||||
),
|
||||
),
|
||||
(
|
||||
"email",
|
||||
models.EmailField(max_length=254, verbose_name="email address"),
|
||||
),
|
||||
(
|
||||
"role",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("reader", "Reader"),
|
||||
("editor", "Editor"),
|
||||
("administrator", "Administrator"),
|
||||
("owner", "Owner"),
|
||||
],
|
||||
default="reader",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"document",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="invitations",
|
||||
to="core.document",
|
||||
),
|
||||
),
|
||||
(
|
||||
"issuer",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="invitations",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Document invitation',
|
||||
'verbose_name_plural': 'Document invitations',
|
||||
'db_table': 'impress_invitation',
|
||||
"verbose_name": "Document invitation",
|
||||
"verbose_name_plural": "Document invitations",
|
||||
"db_table": "impress_invitation",
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TemplateAccess',
|
||||
name="TemplateAccess",
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
|
||||
('team', models.CharField(blank=True, max_length=100)),
|
||||
('role', models.CharField(choices=[('reader', 'Reader'), ('editor', 'Editor'), ('administrator', 'Administrator'), ('owner', 'Owner')], default='reader', max_length=20)),
|
||||
('template', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='accesses', to='core.template')),
|
||||
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
help_text="primary key for the record as UUID",
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="id",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
help_text="date and time at which a record was created",
|
||||
verbose_name="created on",
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(
|
||||
auto_now=True,
|
||||
help_text="date and time at which a record was last updated",
|
||||
verbose_name="updated on",
|
||||
),
|
||||
),
|
||||
("team", models.CharField(blank=True, max_length=100)),
|
||||
(
|
||||
"role",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("reader", "Reader"),
|
||||
("editor", "Editor"),
|
||||
("administrator", "Administrator"),
|
||||
("owner", "Owner"),
|
||||
],
|
||||
default="reader",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"template",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="accesses",
|
||||
to="core.template",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Template/user relation',
|
||||
'verbose_name_plural': 'Template/user relations',
|
||||
'db_table': 'impress_template_access',
|
||||
'ordering': ('-created_at',),
|
||||
"verbose_name": "Template/user relation",
|
||||
"verbose_name_plural": "Template/user relations",
|
||||
"db_table": "impress_template_access",
|
||||
"ordering": ("-created_at",),
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='documentaccess',
|
||||
constraint=models.UniqueConstraint(condition=models.Q(('user__isnull', False)), fields=('user', 'document'), name='unique_document_user', violation_error_message='This user is already in this document.'),
|
||||
model_name="documentaccess",
|
||||
constraint=models.UniqueConstraint(
|
||||
condition=models.Q(("user__isnull", False)),
|
||||
fields=("user", "document"),
|
||||
name="unique_document_user",
|
||||
violation_error_message="This user is already in this document.",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='documentaccess',
|
||||
constraint=models.UniqueConstraint(condition=models.Q(('team__gt', '')), fields=('team', 'document'), name='unique_document_team', violation_error_message='This team is already in this document.'),
|
||||
model_name="documentaccess",
|
||||
constraint=models.UniqueConstraint(
|
||||
condition=models.Q(("team__gt", "")),
|
||||
fields=("team", "document"),
|
||||
name="unique_document_team",
|
||||
violation_error_message="This team is already in this document.",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='documentaccess',
|
||||
constraint=models.CheckConstraint(check=models.Q(models.Q(('team', ''), ('user__isnull', False)), models.Q(('team__gt', ''), ('user__isnull', True)), _connector='OR'), name='check_document_access_either_user_or_team', violation_error_message='Either user or team must be set, not both.'),
|
||||
model_name="documentaccess",
|
||||
constraint=models.CheckConstraint(
|
||||
check=models.Q(
|
||||
models.Q(("team", ""), ("user__isnull", False)),
|
||||
models.Q(("team__gt", ""), ("user__isnull", True)),
|
||||
_connector="OR",
|
||||
),
|
||||
name="check_document_access_either_user_or_team",
|
||||
violation_error_message="Either user or team must be set, not both.",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='invitation',
|
||||
constraint=models.UniqueConstraint(fields=('email', 'document'), name='email_and_document_unique_together'),
|
||||
model_name="invitation",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("email", "document"), name="email_and_document_unique_together"
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='templateaccess',
|
||||
constraint=models.UniqueConstraint(condition=models.Q(('user__isnull', False)), fields=('user', 'template'), name='unique_template_user', violation_error_message='This user is already in this template.'),
|
||||
model_name="templateaccess",
|
||||
constraint=models.UniqueConstraint(
|
||||
condition=models.Q(("user__isnull", False)),
|
||||
fields=("user", "template"),
|
||||
name="unique_template_user",
|
||||
violation_error_message="This user is already in this template.",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='templateaccess',
|
||||
constraint=models.UniqueConstraint(condition=models.Q(('team__gt', '')), fields=('team', 'template'), name='unique_template_team', violation_error_message='This team is already in this template.'),
|
||||
model_name="templateaccess",
|
||||
constraint=models.UniqueConstraint(
|
||||
condition=models.Q(("team__gt", "")),
|
||||
fields=("team", "template"),
|
||||
name="unique_template_team",
|
||||
violation_error_message="This team is already in this template.",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='templateaccess',
|
||||
constraint=models.CheckConstraint(check=models.Q(models.Q(('team', ''), ('user__isnull', False)), models.Q(('team__gt', ''), ('user__isnull', True)), _connector='OR'), name='check_template_access_either_user_or_team', violation_error_message='Either user or team must be set, not both.'),
|
||||
model_name="templateaccess",
|
||||
constraint=models.CheckConstraint(
|
||||
check=models.Q(
|
||||
models.Q(("team", ""), ("user__isnull", False)),
|
||||
models.Q(("team__gt", ""), ("user__isnull", True)),
|
||||
_connector="OR",
|
||||
),
|
||||
name="check_template_access_either_user_or_team",
|
||||
violation_error_message="Either user or team must be set, not both.",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
from django.db import migrations
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('core', '0001_initial'),
|
||||
("core", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
|
||||
@@ -1,52 +1,114 @@
|
||||
# Generated by Django 5.1 on 2024-09-08 16:55
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0002_create_pg_trgm_extension'),
|
||||
("core", "0002_create_pg_trgm_extension"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='document',
|
||||
name='link_reach',
|
||||
field=models.CharField(choices=[('restricted', 'Restricted'), ('authenticated', 'Authenticated'), ('public', 'Public')], default='authenticated', max_length=20),
|
||||
model_name="document",
|
||||
name="link_reach",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("restricted", "Restricted"),
|
||||
("authenticated", "Authenticated"),
|
||||
("public", "Public"),
|
||||
],
|
||||
default="authenticated",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='document',
|
||||
name='link_role',
|
||||
field=models.CharField(choices=[('reader', 'Reader'), ('editor', 'Editor')], default='reader', max_length=20),
|
||||
model_name="document",
|
||||
name="link_role",
|
||||
field=models.CharField(
|
||||
choices=[("reader", "Reader"), ("editor", "Editor")],
|
||||
default="reader",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='document',
|
||||
name='is_public',
|
||||
model_name="document",
|
||||
name="is_public",
|
||||
field=models.BooleanField(null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='language',
|
||||
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
|
||||
model_name="user",
|
||||
name="language",
|
||||
field=models.CharField(
|
||||
choices="(('en-us', 'English'), ('fr-fr', 'French'))",
|
||||
default="en-us",
|
||||
help_text="The language in which the user wants to see the interface.",
|
||||
max_length=10,
|
||||
verbose_name="language",
|
||||
),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='LinkTrace',
|
||||
name="LinkTrace",
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
|
||||
('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='link_traces', to='core.document')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='link_traces', to=settings.AUTH_USER_MODEL)),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
help_text="primary key for the record as UUID",
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="id",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
help_text="date and time at which a record was created",
|
||||
verbose_name="created on",
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(
|
||||
auto_now=True,
|
||||
help_text="date and time at which a record was last updated",
|
||||
verbose_name="updated on",
|
||||
),
|
||||
),
|
||||
(
|
||||
"document",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="link_traces",
|
||||
to="core.document",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="link_traces",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Document/user link trace',
|
||||
'verbose_name_plural': 'Document/user link traces',
|
||||
'db_table': 'impress_link_trace',
|
||||
'constraints': [models.UniqueConstraint(fields=('user', 'document'), name='unique_link_trace_document_user', violation_error_message='A link trace already exists for this document/user.')],
|
||||
"verbose_name": "Document/user link trace",
|
||||
"verbose_name_plural": "Document/user link traces",
|
||||
"db_table": "impress_link_trace",
|
||||
"constraints": [
|
||||
models.UniqueConstraint(
|
||||
fields=("user", "document"),
|
||||
name="unique_link_trace_document_user",
|
||||
violation_error_message="A link trace already exists for this document/user.",
|
||||
)
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
# Generated by Django 5.1 on 2024-09-08 17:04
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def migrate_is_public_to_link_reach(apps, schema_editor):
|
||||
"""
|
||||
Forward migration: Migrate 'is_public' to 'link_reach'.
|
||||
If is_public == True, set link_reach to 'public'
|
||||
"""
|
||||
Document = apps.get_model('core', 'Document')
|
||||
Document.objects.filter(is_public=True).update(link_reach='public')
|
||||
Document = apps.get_model("core", "Document")
|
||||
Document.objects.filter(is_public=True).update(link_reach="public")
|
||||
|
||||
|
||||
def reverse_migrate_link_reach_to_is_public(apps, schema_editor):
|
||||
@@ -16,20 +17,20 @@ def reverse_migrate_link_reach_to_is_public(apps, schema_editor):
|
||||
- If link_reach == 'public', set is_public to True
|
||||
- Else set is_public to False
|
||||
"""
|
||||
Document = apps.get_model('core', 'Document')
|
||||
Document.objects.filter(link_reach='public').update(is_public=True)
|
||||
Document.objects.filter(link_reach__in=['restricted', "authenticated"]).update(is_public=False)
|
||||
Document = apps.get_model("core", "Document")
|
||||
Document.objects.filter(link_reach="public").update(is_public=True)
|
||||
Document.objects.filter(link_reach__in=["restricted", "authenticated"]).update(
|
||||
is_public=False
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0003_document_link_reach_document_link_role_and_more'),
|
||||
("core", "0003_document_link_reach_document_link_role_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
migrate_is_public_to_link_reach,
|
||||
reverse_migrate_link_reach_to_is_public
|
||||
migrate_is_public_to_link_reach, reverse_migrate_link_reach_to_is_public
|
||||
),
|
||||
]
|
||||
|
||||
@@ -4,15 +4,16 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0004_migrate_is_public_to_link_reach'),
|
||||
("core", "0004_migrate_is_public_to_link_reach"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='document',
|
||||
name='title',
|
||||
field=models.CharField(blank=True, max_length=255, null=True, verbose_name='title'),
|
||||
model_name="document",
|
||||
name="title",
|
||||
field=models.CharField(
|
||||
blank=True, max_length=255, null=True, verbose_name="title"
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -4,25 +4,34 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0005_remove_document_is_public_alter_document_link_reach_and_more'),
|
||||
("core", "0005_remove_document_is_public_alter_document_link_reach_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='full_name',
|
||||
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='full name'),
|
||||
model_name="user",
|
||||
name="full_name",
|
||||
field=models.CharField(
|
||||
blank=True, max_length=100, null=True, verbose_name="full name"
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='short_name',
|
||||
field=models.CharField(blank=True, max_length=20, null=True, verbose_name='short name'),
|
||||
model_name="user",
|
||||
name="short_name",
|
||||
field=models.CharField(
|
||||
blank=True, max_length=20, null=True, verbose_name="short name"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='language',
|
||||
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
|
||||
model_name="user",
|
||||
name="language",
|
||||
field=models.CharField(
|
||||
choices="(('en-us', 'English'), ('fr-fr', 'French'))",
|
||||
default="en-us",
|
||||
help_text="The language in which the user wants to see the interface.",
|
||||
max_length=10,
|
||||
verbose_name="language",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -117,10 +117,10 @@ BEGIN
|
||||
END $$;
|
||||
"""
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('core', '0006_add_user_full_name_and_short_name'),
|
||||
("core", "0006_add_user_full_name_and_short_name"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
|
||||
@@ -4,15 +4,22 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0007_fix_users_duplicate'),
|
||||
("core", "0007_fix_users_duplicate"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='document',
|
||||
name='link_reach',
|
||||
field=models.CharField(choices=[('restricted', 'Restricted'), ('authenticated', 'Authenticated'), ('public', 'Public')], default='restricted', max_length=20),
|
||||
model_name="document",
|
||||
name="link_reach",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("restricted", "Restricted"),
|
||||
("authenticated", "Authenticated"),
|
||||
("public", "Public"),
|
||||
],
|
||||
default="restricted",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -1,37 +1,87 @@
|
||||
# Generated by Django 5.1.2 on 2024-11-08 07:59
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0008_alter_document_link_reach'),
|
||||
("core", "0008_alter_document_link_reach"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='language',
|
||||
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
|
||||
model_name="user",
|
||||
name="language",
|
||||
field=models.CharField(
|
||||
choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))",
|
||||
default="en-us",
|
||||
help_text="The language in which the user wants to see the interface.",
|
||||
max_length=10,
|
||||
verbose_name="language",
|
||||
),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='DocumentFavorite',
|
||||
name="DocumentFavorite",
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
|
||||
('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorited_by_users', to='core.document')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorite_documents', to=settings.AUTH_USER_MODEL)),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
help_text="primary key for the record as UUID",
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="id",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
help_text="date and time at which a record was created",
|
||||
verbose_name="created on",
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(
|
||||
auto_now=True,
|
||||
help_text="date and time at which a record was last updated",
|
||||
verbose_name="updated on",
|
||||
),
|
||||
),
|
||||
(
|
||||
"document",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="favorited_by_users",
|
||||
to="core.document",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="favorite_documents",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Document favorite',
|
||||
'verbose_name_plural': 'Document favorites',
|
||||
'db_table': 'impress_document_favorite',
|
||||
'constraints': [models.UniqueConstraint(fields=('user', 'document'), name='unique_document_favorite_user', violation_error_message='This document is already targeted by a favorite relation instance for the same user.')],
|
||||
"verbose_name": "Document favorite",
|
||||
"verbose_name_plural": "Document favorites",
|
||||
"db_table": "impress_document_favorite",
|
||||
"constraints": [
|
||||
models.UniqueConstraint(
|
||||
fields=("user", "document"),
|
||||
name="unique_document_favorite_user",
|
||||
violation_error_message="This document is already targeted by a favorite relation instance for the same user.",
|
||||
)
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
@@ -7,25 +7,48 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0009_add_document_favorite'),
|
||||
("core", "0009_add_document_favorite"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='document',
|
||||
name='creator',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='documents_created', to=settings.AUTH_USER_MODEL),
|
||||
model_name="document",
|
||||
name="creator",
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.RESTRICT,
|
||||
related_name="documents_created",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='language',
|
||||
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
|
||||
model_name="user",
|
||||
name="language",
|
||||
field=models.CharField(
|
||||
choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))",
|
||||
default="en-us",
|
||||
help_text="The language in which the user wants to see the interface.",
|
||||
max_length=10,
|
||||
verbose_name="language",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='sub',
|
||||
field=models.CharField(blank=True, help_text='Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only.', max_length=255, null=True, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters.', regex='^[\\w.@+-:]+\\Z')], verbose_name='sub'),
|
||||
model_name="user",
|
||||
name="sub",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
help_text="Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only.",
|
||||
max_length=255,
|
||||
null=True,
|
||||
unique=True,
|
||||
validators=[
|
||||
django.core.validators.RegexValidator(
|
||||
message="Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters.",
|
||||
regex="^[\\w.@+-:]+\\Z",
|
||||
)
|
||||
],
|
||||
verbose_name="sub",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations
|
||||
from django.db.models import F, ForeignKey, Subquery, OuterRef, Q
|
||||
from django.db.models import F, ForeignKey, OuterRef, Q, Subquery
|
||||
|
||||
|
||||
def set_creator_from_document_access(apps, schema_editor):
|
||||
@@ -25,28 +25,37 @@ def set_creator_from_document_access(apps, schema_editor):
|
||||
DocumentAccess = apps.get_model("core", "DocumentAccess")
|
||||
|
||||
# Update `creator` using the "owner" role
|
||||
owner_subquery = DocumentAccess.objects.filter(
|
||||
document=OuterRef('pk'),
|
||||
user__isnull=False,
|
||||
role='owner',
|
||||
).order_by('created_at').values('user_id')[:1]
|
||||
owner_subquery = (
|
||||
DocumentAccess.objects.filter(
|
||||
document=OuterRef("pk"),
|
||||
user__isnull=False,
|
||||
role="owner",
|
||||
)
|
||||
.order_by("created_at")
|
||||
.values("user_id")[:1]
|
||||
)
|
||||
|
||||
Document.objects.filter(
|
||||
creator__isnull=True
|
||||
).update(creator=Subquery(owner_subquery))
|
||||
Document.objects.filter(creator__isnull=True).update(
|
||||
creator=Subquery(owner_subquery)
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0010_add_field_creator_to_document'),
|
||||
("core", "0010_add_field_creator_to_document"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(set_creator_from_document_access, reverse_code=migrations.RunPython.noop),
|
||||
migrations.RunPython(
|
||||
set_creator_from_document_access, reverse_code=migrations.RunPython.noop
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='document',
|
||||
name='creator',
|
||||
field=ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='documents_created', to=settings.AUTH_USER_MODEL),
|
||||
model_name="document",
|
||||
name="creator",
|
||||
field=ForeignKey(
|
||||
on_delete=django.db.models.deletion.RESTRICT,
|
||||
related_name="documents_created",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -6,25 +6,42 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0011_populate_creator_field_and_make_it_required'),
|
||||
("core", "0011_populate_creator_field_and_make_it_required"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='document',
|
||||
name='creator',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='documents_created', to=settings.AUTH_USER_MODEL),
|
||||
model_name="document",
|
||||
name="creator",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.RESTRICT,
|
||||
related_name="documents_created",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='invitation',
|
||||
name='issuer',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to=settings.AUTH_USER_MODEL),
|
||||
model_name="invitation",
|
||||
name="issuer",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="invitations",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='language',
|
||||
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
|
||||
model_name="user",
|
||||
name="language",
|
||||
field=models.CharField(
|
||||
choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))",
|
||||
default="en-us",
|
||||
help_text="The language in which the user wants to see the interface.",
|
||||
max_length=10,
|
||||
verbose_name="language",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('core', '0012_make_document_creator_and_invitation_issuer_optional'),
|
||||
("core", "0012_make_document_creator_and_invitation_issuer_optional"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
|
||||
@@ -4,28 +4,29 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0013_activate_fuzzystrmatch_extension'),
|
||||
("core", "0013_activate_fuzzystrmatch_extension"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='document',
|
||||
name='depth',
|
||||
model_name="document",
|
||||
name="depth",
|
||||
field=models.PositiveIntegerField(default=0),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='document',
|
||||
name='numchild',
|
||||
model_name="document",
|
||||
name="numchild",
|
||||
field=models.PositiveIntegerField(default=0),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='document',
|
||||
name='path',
|
||||
model_name="document",
|
||||
name="path",
|
||||
# Allow null values pending the next datamigration to populate the field
|
||||
field=models.CharField(db_collation='C', max_length=252, null=True, unique=True),
|
||||
field=models.CharField(
|
||||
db_collation="C", max_length=252, null=True, unique=True
|
||||
),
|
||||
preserve_default=False,
|
||||
),
|
||||
]
|
||||
|
||||
@@ -7,9 +7,10 @@ from treebeard.numconv import NumConv
|
||||
ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
STEPLEN = 7
|
||||
|
||||
|
||||
def set_path_on_existing_documents(apps, schema_editor):
|
||||
"""
|
||||
Updates the `path` and `depth` fields for all existing Document records
|
||||
Updates the `path` and `depth` fields for all existing Document records
|
||||
to ensure valid materialized paths.
|
||||
|
||||
This function assigns a unique `path` to each Document as a root node
|
||||
@@ -26,27 +27,25 @@ def set_path_on_existing_documents(apps, schema_editor):
|
||||
updates = []
|
||||
for i, pk in enumerate(documents):
|
||||
key = numconv.int2str(i)
|
||||
path = "{0}{1}".format(
|
||||
ALPHABET[0] * (STEPLEN - len(key)),
|
||||
key
|
||||
)
|
||||
path = "{0}{1}".format(ALPHABET[0] * (STEPLEN - len(key)), key)
|
||||
updates.append(Document(pk=pk, path=path, depth=1))
|
||||
|
||||
# Bulk update using the prepared updates list
|
||||
Document.objects.bulk_update(updates, ['depth', 'path'])
|
||||
Document.objects.bulk_update(updates, ["depth", "path"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0014_add_tree_structure_to_documents'),
|
||||
("core", "0014_add_tree_structure_to_documents"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(set_path_on_existing_documents, reverse_code=migrations.RunPython.noop),
|
||||
migrations.RunPython(
|
||||
set_path_on_existing_documents, reverse_code=migrations.RunPython.noop
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='document',
|
||||
name='path',
|
||||
field=models.CharField(db_collation='C', max_length=252, unique=True),
|
||||
model_name="document",
|
||||
name="path",
|
||||
field=models.CharField(db_collation="C", max_length=252, unique=True),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -4,20 +4,27 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0015_set_path_on_existing_documents'),
|
||||
("core", "0015_set_path_on_existing_documents"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='document',
|
||||
name='excerpt',
|
||||
field=models.TextField(blank=True, max_length=300, null=True, verbose_name='excerpt'),
|
||||
model_name="document",
|
||||
name="excerpt",
|
||||
field=models.TextField(
|
||||
blank=True, max_length=300, null=True, verbose_name="excerpt"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='language',
|
||||
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
|
||||
model_name="user",
|
||||
name="language",
|
||||
field=models.CharField(
|
||||
choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))",
|
||||
default="en-us",
|
||||
help_text="The language in which the user wants to see the interface.",
|
||||
max_length=10,
|
||||
verbose_name="language",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -4,33 +4,49 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0016_add_document_excerpt'),
|
||||
("core", "0016_add_document_excerpt"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='document',
|
||||
options={'ordering': ('path',), 'verbose_name': 'Document', 'verbose_name_plural': 'Documents'},
|
||||
name="document",
|
||||
options={
|
||||
"ordering": ("path",),
|
||||
"verbose_name": "Document",
|
||||
"verbose_name_plural": "Documents",
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='document',
|
||||
name='ancestors_deleted_at',
|
||||
model_name="document",
|
||||
name="ancestors_deleted_at",
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='document',
|
||||
name='deleted_at',
|
||||
model_name="document",
|
||||
name="deleted_at",
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='language',
|
||||
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
|
||||
model_name="user",
|
||||
name="language",
|
||||
field=models.CharField(
|
||||
choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))",
|
||||
default="en-us",
|
||||
help_text="The language in which the user wants to see the interface.",
|
||||
max_length=10,
|
||||
verbose_name="language",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='document',
|
||||
constraint=models.CheckConstraint(condition=models.Q(('deleted_at__isnull', True), ('deleted_at', models.F('ancestors_deleted_at')), _connector='OR'), name='check_deleted_at_matches_ancestors_deleted_at_when_set'),
|
||||
model_name="document",
|
||||
constraint=models.CheckConstraint(
|
||||
condition=models.Q(
|
||||
("deleted_at__isnull", True),
|
||||
("deleted_at", models.F("ancestors_deleted_at")),
|
||||
_connector="OR",
|
||||
),
|
||||
name="check_deleted_at_matches_ancestors_deleted_at_when_set",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
24
src/backend/core/migrations/0018_update_blank_title.py
Normal file
24
src/backend/core/migrations/0018_update_blank_title.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def update_titles_to_null(apps, schema_editor):
|
||||
"""
|
||||
If the titles are "Untitled document" or "Unbenanntes Dokument" or "Document sans titre"
|
||||
we set them to Null
|
||||
"""
|
||||
Document = apps.get_model("core", "Document")
|
||||
Document.objects.filter(
|
||||
title__in=["Untitled document", "Unbenanntes Dokument", "Document sans titre"]
|
||||
).update(title=None)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("core", "0017_add_fields_for_soft_delete"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
update_titles_to_null, reverse_code=migrations.RunPython.noop
|
||||
),
|
||||
]
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -194,9 +194,11 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
language = models.CharField(
|
||||
max_length=10,
|
||||
choices=lazy(lambda: settings.LANGUAGES, tuple)(),
|
||||
default=settings.LANGUAGE_CODE,
|
||||
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",
|
||||
@@ -697,6 +699,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 +741,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)
|
||||
@@ -787,6 +794,9 @@ class Document(MP_Node, BaseModel):
|
||||
}
|
||||
)
|
||||
|
||||
# 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 +804,17 @@ 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"])
|
||||
|
||||
# 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)
|
||||
|
||||
|
||||
class LinkTrace(BaseModel):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,11 @@ 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 +444,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 +463,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")
|
||||
|
||||
|
||||
@@ -79,6 +79,7 @@ def test_api_documents_attachment_upload_anonymous_success():
|
||||
|
||||
assert file_head["Metadata"] == {"owner": "None"}
|
||||
assert file_head["ContentType"] == "image/png"
|
||||
assert file_head["ContentDisposition"] == 'inline; filename="test.png"'
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -217,6 +218,7 @@ def test_api_documents_attachment_upload_success(via, role, mock_user_teams):
|
||||
)
|
||||
assert file_head["Metadata"] == {"owner": str(user.id)}
|
||||
assert file_head["ContentType"] == "image/png"
|
||||
assert file_head["ContentDisposition"] == 'inline; filename="test.png"'
|
||||
|
||||
|
||||
def test_api_documents_attachment_upload_invalid(client):
|
||||
@@ -291,7 +293,9 @@ def test_api_documents_attachment_upload_fix_extension(
|
||||
match = pattern.search(file_path)
|
||||
file_id = match.group(1)
|
||||
|
||||
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
|
||||
@@ -301,6 +305,7 @@ def test_api_documents_attachment_upload_fix_extension(
|
||||
)
|
||||
assert file_head["Metadata"] == {"owner": str(user.id), "is_unsafe": "true"}
|
||||
assert file_head["ContentType"] == content_type
|
||||
assert file_head["ContentDisposition"] == f'attachment; filename="{name:s}"'
|
||||
|
||||
|
||||
def test_api_documents_attachment_upload_empty_file():
|
||||
@@ -340,7 +345,9 @@ def test_api_documents_attachment_upload_unsafe():
|
||||
match = pattern.search(file_path)
|
||||
file_id = match.group(1)
|
||||
|
||||
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
|
||||
@@ -350,3 +357,4 @@ def test_api_documents_attachment_upload_unsafe():
|
||||
)
|
||||
assert file_head["Metadata"] == {"owner": str(user.id), "is_unsafe": "true"}
|
||||
assert file_head["ContentType"] == "application/octet-stream"
|
||||
assert file_head["ContentDisposition"] == 'attachment; filename="script.exe"'
|
||||
|
||||
@@ -64,6 +64,30 @@ 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."""
|
||||
document = factories.DocumentFactory(link_reach="public")
|
||||
|
||||
extensions = [
|
||||
"c",
|
||||
"go",
|
||||
"gif",
|
||||
"mp4",
|
||||
"woff2",
|
||||
"appimage",
|
||||
]
|
||||
for ext in extensions:
|
||||
filename = f"{uuid.uuid4()!s}.{ext:s}"
|
||||
key = f"{document.pk!s}/attachments/{filename:s}"
|
||||
|
||||
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):
|
||||
"""
|
||||
|
||||
0
src/backend/core/tests/migrations/__init__.py
Normal file
0
src/backend/core/tests/migrations/__init__.py
Normal file
@@ -0,0 +1,35 @@
|
||||
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"
|
||||
@@ -39,7 +39,11 @@ def test_api_config(is_authenticated):
|
||||
"CRISP_WEBSITE_ID": "123",
|
||||
"ENVIRONMENT": "test",
|
||||
"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"],
|
||||
],
|
||||
"LANGUAGE_CODE": "en-us",
|
||||
"MEDIA_BASE_URL": "http://testserver/",
|
||||
"POSTHOG_KEY": {"id": "132456", "host": "https://eu.i.posthog-test.com"},
|
||||
|
||||
@@ -158,6 +158,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,
|
||||
}
|
||||
|
||||
|
||||
@@ -636,6 +636,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 "editor" "
|
||||
"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.
|
||||
@@ -765,3 +796,122 @@ def test_models_documents_nb_accesses_cache_is_invalidated_on_access_removal(
|
||||
new_nb_accesses = document.nb_accesses
|
||||
assert new_nb_accesses == 0
|
||||
assert cache.get(key) == 0 # Cache should now contain the new value
|
||||
|
||||
|
||||
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(6):
|
||||
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(8):
|
||||
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(7):
|
||||
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
|
||||
|
||||
@@ -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"},
|
||||
]
|
||||
|
||||
@@ -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],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ 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__)))
|
||||
@@ -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,9 @@ 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"),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -647,8 +649,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
|
||||
|
||||
@@ -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-03-03 12:20+0000\n"
|
||||
"PO-Revision-Date: 2025-03-03 12:22\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: German\n"
|
||||
"Language: de_DE\n"
|
||||
@@ -54,15 +54,15 @@ msgstr "Ein neues Dokument wurde in Ihrem Namen erstellt!"
|
||||
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:455 core/api/serializers.py:455
|
||||
msgid "Body"
|
||||
msgstr "Inhalt"
|
||||
|
||||
#: build/lib/core/api/serializers.py:456 core/api/serializers.py:456
|
||||
#: build/lib/core/api/serializers.py:458 core/api/serializers.py:458
|
||||
msgid "Body type"
|
||||
msgstr "Typ"
|
||||
|
||||
#: build/lib/core/api/serializers.py:462 core/api/serializers.py:462
|
||||
#: build/lib/core/api/serializers.py:464 core/api/serializers.py:464
|
||||
msgid "Format"
|
||||
msgstr ""
|
||||
|
||||
@@ -230,8 +230,8 @@ msgstr "Benutzer"
|
||||
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:373 build/lib/core/models.py:942 core/models.py:373
|
||||
#: core/models.py:942
|
||||
msgid "title"
|
||||
msgstr "Titel"
|
||||
|
||||
@@ -251,143 +251,143 @@ msgstr "Dokumente"
|
||||
msgid "Untitled Document"
|
||||
msgstr "Unbenanntes Dokument"
|
||||
|
||||
#: build/lib/core/models.py:719 core/models.py:719
|
||||
#: build/lib/core/models.py:734 core/models.py:734
|
||||
#, 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:738 core/models.py:738
|
||||
#, 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:741 core/models.py:741
|
||||
#, 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
|
||||
#: build/lib/core/models.py:777 core/models.py:777
|
||||
msgid "This document is not deleted."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:769 core/models.py:769
|
||||
#: build/lib/core/models.py:784 core/models.py:784
|
||||
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:837 core/models.py:837
|
||||
msgid "Document/user link trace"
|
||||
msgstr "Dokument/Benutzer Linkverfolgung"
|
||||
|
||||
#: build/lib/core/models.py:821 core/models.py:821
|
||||
#: build/lib/core/models.py:838 core/models.py:838
|
||||
msgid "Document/user link traces"
|
||||
msgstr "Dokument/Benutzer Linkverfolgung"
|
||||
|
||||
#: build/lib/core/models.py:827 core/models.py:827
|
||||
#: build/lib/core/models.py:844 core/models.py:844
|
||||
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:867 core/models.py:867
|
||||
msgid "Document favorite"
|
||||
msgstr "Dokumentenfavorit"
|
||||
|
||||
#: build/lib/core/models.py:851 core/models.py:851
|
||||
#: build/lib/core/models.py:868 core/models.py:868
|
||||
msgid "Document favorites"
|
||||
msgstr "Dokumentfavoriten"
|
||||
|
||||
#: build/lib/core/models.py:857 core/models.py:857
|
||||
#: build/lib/core/models.py:874 core/models.py:874
|
||||
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:896 core/models.py:896
|
||||
msgid "Document/user relation"
|
||||
msgstr "Dokument/Benutzerbeziehung"
|
||||
|
||||
#: build/lib/core/models.py:880 core/models.py:880
|
||||
#: build/lib/core/models.py:897 core/models.py:897
|
||||
msgid "Document/user relations"
|
||||
msgstr "Dokument/Benutzerbeziehungen"
|
||||
|
||||
#: build/lib/core/models.py:886 core/models.py:886
|
||||
#: build/lib/core/models.py:903 core/models.py:903
|
||||
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:909 core/models.py:909
|
||||
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:915 build/lib/core/models.py:1029
|
||||
#: core/models.py:915 core/models.py:1029
|
||||
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:943 core/models.py:943
|
||||
msgid "description"
|
||||
msgstr "Beschreibung"
|
||||
|
||||
#: build/lib/core/models.py:927 core/models.py:927
|
||||
#: build/lib/core/models.py:944 core/models.py:944
|
||||
msgid "code"
|
||||
msgstr "Code"
|
||||
|
||||
#: build/lib/core/models.py:928 core/models.py:928
|
||||
#: build/lib/core/models.py:945 core/models.py:945
|
||||
msgid "css"
|
||||
msgstr "CSS"
|
||||
|
||||
#: build/lib/core/models.py:930 core/models.py:930
|
||||
#: build/lib/core/models.py:947 core/models.py:947
|
||||
msgid "public"
|
||||
msgstr "öffentlich"
|
||||
|
||||
#: build/lib/core/models.py:932 core/models.py:932
|
||||
#: build/lib/core/models.py:949 core/models.py:949
|
||||
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:955 core/models.py:955
|
||||
msgid "Template"
|
||||
msgstr "Vorlage"
|
||||
|
||||
#: build/lib/core/models.py:939 core/models.py:939
|
||||
#: build/lib/core/models.py:956 core/models.py:956
|
||||
msgid "Templates"
|
||||
msgstr "Vorlagen"
|
||||
|
||||
#: build/lib/core/models.py:993 core/models.py:993
|
||||
#: build/lib/core/models.py:1010 core/models.py:1010
|
||||
msgid "Template/user relation"
|
||||
msgstr "Vorlage/Benutzer-Beziehung"
|
||||
|
||||
#: build/lib/core/models.py:994 core/models.py:994
|
||||
#: build/lib/core/models.py:1011 core/models.py:1011
|
||||
msgid "Template/user relations"
|
||||
msgstr "Vorlage/Benutzerbeziehungen"
|
||||
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
#: build/lib/core/models.py:1017 core/models.py:1017
|
||||
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:1023 core/models.py:1023
|
||||
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:1046 core/models.py:1046
|
||||
msgid "email address"
|
||||
msgstr "E-Mail-Adresse"
|
||||
|
||||
#: build/lib/core/models.py:1048 core/models.py:1048
|
||||
#: build/lib/core/models.py:1065 core/models.py:1065
|
||||
msgid "Document invitation"
|
||||
msgstr "Einladung zum Dokument"
|
||||
|
||||
#: build/lib/core/models.py:1049 core/models.py:1049
|
||||
#: build/lib/core/models.py:1066 core/models.py:1066
|
||||
msgid "Document invitations"
|
||||
msgstr "Dokumenteinladungen"
|
||||
|
||||
#: build/lib/core/models.py:1069 core/models.py:1069
|
||||
#: build/lib/core/models.py:1086 core/models.py:1086
|
||||
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
|
||||
#: build/lib/impress/settings.py:235 impress/settings.py:235
|
||||
msgid "English"
|
||||
msgstr "Englisch"
|
||||
|
||||
#: build/lib/impress/settings.py:237 impress/settings.py:237
|
||||
#: build/lib/impress/settings.py:236 impress/settings.py:236
|
||||
msgid "French"
|
||||
msgstr "Französisch"
|
||||
|
||||
#: build/lib/impress/settings.py:238 impress/settings.py:238
|
||||
#: build/lib/impress/settings.py:237 impress/settings.py:237
|
||||
msgid "German"
|
||||
msgstr "Deutsch"
|
||||
|
||||
|
||||
@@ -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-03-03 12:20+0000\n"
|
||||
"PO-Revision-Date: 2025-03-03 12:22\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: English\n"
|
||||
"Language: en_US\n"
|
||||
@@ -54,15 +54,15 @@ msgstr ""
|
||||
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:455 core/api/serializers.py:455
|
||||
msgid "Body"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:456 core/api/serializers.py:456
|
||||
#: build/lib/core/api/serializers.py:458 core/api/serializers.py:458
|
||||
msgid "Body type"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:462 core/api/serializers.py:462
|
||||
#: build/lib/core/api/serializers.py:464 core/api/serializers.py:464
|
||||
msgid "Format"
|
||||
msgstr ""
|
||||
|
||||
@@ -230,8 +230,8 @@ msgstr ""
|
||||
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:373 build/lib/core/models.py:942 core/models.py:373
|
||||
#: core/models.py:942
|
||||
msgid "title"
|
||||
msgstr ""
|
||||
|
||||
@@ -251,143 +251,143 @@ msgstr ""
|
||||
msgid "Untitled Document"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:719 core/models.py:719
|
||||
#: build/lib/core/models.py:734 core/models.py:734
|
||||
#, 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:738 core/models.py:738
|
||||
#, 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:741 core/models.py:741
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:762 core/models.py:762
|
||||
#: build/lib/core/models.py:777 core/models.py:777
|
||||
msgid "This document is not deleted."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:769 core/models.py:769
|
||||
#: build/lib/core/models.py:784 core/models.py:784
|
||||
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:837 core/models.py:837
|
||||
msgid "Document/user link trace"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:821 core/models.py:821
|
||||
#: build/lib/core/models.py:838 core/models.py:838
|
||||
msgid "Document/user link traces"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:827 core/models.py:827
|
||||
#: build/lib/core/models.py:844 core/models.py:844
|
||||
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:867 core/models.py:867
|
||||
msgid "Document favorite"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:851 core/models.py:851
|
||||
#: build/lib/core/models.py:868 core/models.py:868
|
||||
msgid "Document favorites"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:857 core/models.py:857
|
||||
#: build/lib/core/models.py:874 core/models.py:874
|
||||
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:896 core/models.py:896
|
||||
msgid "Document/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:880 core/models.py:880
|
||||
#: build/lib/core/models.py:897 core/models.py:897
|
||||
msgid "Document/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:886 core/models.py:886
|
||||
#: build/lib/core/models.py:903 core/models.py:903
|
||||
msgid "This user is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:892 core/models.py:892
|
||||
#: build/lib/core/models.py:909 core/models.py:909
|
||||
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:915 build/lib/core/models.py:1029
|
||||
#: core/models.py:915 core/models.py:1029
|
||||
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:943 core/models.py:943
|
||||
msgid "description"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:927 core/models.py:927
|
||||
#: build/lib/core/models.py:944 core/models.py:944
|
||||
msgid "code"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:928 core/models.py:928
|
||||
#: build/lib/core/models.py:945 core/models.py:945
|
||||
msgid "css"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:930 core/models.py:930
|
||||
#: build/lib/core/models.py:947 core/models.py:947
|
||||
msgid "public"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:932 core/models.py:932
|
||||
#: build/lib/core/models.py:949 core/models.py:949
|
||||
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:955 core/models.py:955
|
||||
msgid "Template"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:939 core/models.py:939
|
||||
#: build/lib/core/models.py:956 core/models.py:956
|
||||
msgid "Templates"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:993 core/models.py:993
|
||||
#: build/lib/core/models.py:1010 core/models.py:1010
|
||||
msgid "Template/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:994 core/models.py:994
|
||||
#: build/lib/core/models.py:1011 core/models.py:1011
|
||||
msgid "Template/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
#: build/lib/core/models.py:1017 core/models.py:1017
|
||||
msgid "This user is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
#: build/lib/core/models.py:1023 core/models.py:1023
|
||||
msgid "This team is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
#: build/lib/core/models.py:1046 core/models.py:1046
|
||||
msgid "email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1048 core/models.py:1048
|
||||
#: build/lib/core/models.py:1065 core/models.py:1065
|
||||
msgid "Document invitation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1049 core/models.py:1049
|
||||
#: build/lib/core/models.py:1066 core/models.py:1066
|
||||
msgid "Document invitations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1069 core/models.py:1069
|
||||
#: build/lib/core/models.py:1086 core/models.py:1086
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/impress/settings.py:236 impress/settings.py:236
|
||||
#: build/lib/impress/settings.py:235 impress/settings.py:235
|
||||
msgid "English"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/impress/settings.py:237 impress/settings.py:237
|
||||
#: build/lib/impress/settings.py:236 impress/settings.py:236
|
||||
msgid "French"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/impress/settings.py:238 impress/settings.py:238
|
||||
#: build/lib/impress/settings.py:237 impress/settings.py:237
|
||||
msgid "German"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -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-03-03 12:20+0000\n"
|
||||
"PO-Revision-Date: 2025-03-04 08:20\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: French\n"
|
||||
"Language: fr_FR\n"
|
||||
@@ -54,15 +54,15 @@ msgstr "Un nouveau document a été créé pour vous !"
|
||||
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:455 core/api/serializers.py:455
|
||||
msgid "Body"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:456 core/api/serializers.py:456
|
||||
#: build/lib/core/api/serializers.py:458 core/api/serializers.py:458
|
||||
msgid "Body type"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:462 core/api/serializers.py:462
|
||||
#: build/lib/core/api/serializers.py:464 core/api/serializers.py:464
|
||||
msgid "Format"
|
||||
msgstr ""
|
||||
|
||||
@@ -230,8 +230,8 @@ msgstr ""
|
||||
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:373 build/lib/core/models.py:942 core/models.py:373
|
||||
#: core/models.py:942
|
||||
msgid "title"
|
||||
msgstr ""
|
||||
|
||||
@@ -249,145 +249,145 @@ msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:418 core/models.py:418
|
||||
msgid "Untitled Document"
|
||||
msgstr ""
|
||||
msgstr "Document sans titre"
|
||||
|
||||
#: build/lib/core/models.py:719 core/models.py:719
|
||||
#: build/lib/core/models.py:734 core/models.py:734
|
||||
#, 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:738 core/models.py:738
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr "{name} vous a invité avec le rôle \"{role}\" sur le document suivant:"
|
||||
|
||||
#: build/lib/core/models.py:726 core/models.py:726
|
||||
#: build/lib/core/models.py:741 core/models.py:741
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr "{name} a partagé un document avec vous: {title}"
|
||||
|
||||
#: build/lib/core/models.py:762 core/models.py:762
|
||||
#: build/lib/core/models.py:777 core/models.py:777
|
||||
msgid "This document is not deleted."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:769 core/models.py:769
|
||||
#: build/lib/core/models.py:784 core/models.py:784
|
||||
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:837 core/models.py:837
|
||||
msgid "Document/user link trace"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:821 core/models.py:821
|
||||
#: build/lib/core/models.py:838 core/models.py:838
|
||||
msgid "Document/user link traces"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:827 core/models.py:827
|
||||
#: build/lib/core/models.py:844 core/models.py:844
|
||||
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:867 core/models.py:867
|
||||
msgid "Document favorite"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:851 core/models.py:851
|
||||
#: build/lib/core/models.py:868 core/models.py:868
|
||||
msgid "Document favorites"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:857 core/models.py:857
|
||||
#: build/lib/core/models.py:874 core/models.py:874
|
||||
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:896 core/models.py:896
|
||||
msgid "Document/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:880 core/models.py:880
|
||||
#: build/lib/core/models.py:897 core/models.py:897
|
||||
msgid "Document/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:886 core/models.py:886
|
||||
#: build/lib/core/models.py:903 core/models.py:903
|
||||
msgid "This user is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:892 core/models.py:892
|
||||
#: build/lib/core/models.py:909 core/models.py:909
|
||||
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:915 build/lib/core/models.py:1029
|
||||
#: core/models.py:915 core/models.py:1029
|
||||
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:943 core/models.py:943
|
||||
msgid "description"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:927 core/models.py:927
|
||||
#: build/lib/core/models.py:944 core/models.py:944
|
||||
msgid "code"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:928 core/models.py:928
|
||||
#: build/lib/core/models.py:945 core/models.py:945
|
||||
msgid "css"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:930 core/models.py:930
|
||||
#: build/lib/core/models.py:947 core/models.py:947
|
||||
msgid "public"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:932 core/models.py:932
|
||||
#: build/lib/core/models.py:949 core/models.py:949
|
||||
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:955 core/models.py:955
|
||||
msgid "Template"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:939 core/models.py:939
|
||||
#: build/lib/core/models.py:956 core/models.py:956
|
||||
msgid "Templates"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:993 core/models.py:993
|
||||
#: build/lib/core/models.py:1010 core/models.py:1010
|
||||
msgid "Template/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:994 core/models.py:994
|
||||
#: build/lib/core/models.py:1011 core/models.py:1011
|
||||
msgid "Template/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
#: build/lib/core/models.py:1017 core/models.py:1017
|
||||
msgid "This user is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
#: build/lib/core/models.py:1023 core/models.py:1023
|
||||
msgid "This team is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
#: build/lib/core/models.py:1046 core/models.py:1046
|
||||
msgid "email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1048 core/models.py:1048
|
||||
#: build/lib/core/models.py:1065 core/models.py:1065
|
||||
msgid "Document invitation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1049 core/models.py:1049
|
||||
#: build/lib/core/models.py:1066 core/models.py:1066
|
||||
msgid "Document invitations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1069 core/models.py:1069
|
||||
#: build/lib/core/models.py:1086 core/models.py:1086
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/impress/settings.py:236 impress/settings.py:236
|
||||
#: build/lib/impress/settings.py:235 impress/settings.py:235
|
||||
msgid "English"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/impress/settings.py:237 impress/settings.py:237
|
||||
#: build/lib/impress/settings.py:236 impress/settings.py:236
|
||||
msgid "French"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/impress/settings.py:238 impress/settings.py:238
|
||||
#: build/lib/impress/settings.py:237 impress/settings.py:237
|
||||
msgid "German"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -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-03-03 12:20+0000\n"
|
||||
"PO-Revision-Date: 2025-03-03 12:22\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Dutch\n"
|
||||
"Language: nl_NL\n"
|
||||
@@ -54,15 +54,15 @@ msgstr ""
|
||||
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:455 core/api/serializers.py:455
|
||||
msgid "Body"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:456 core/api/serializers.py:456
|
||||
#: build/lib/core/api/serializers.py:458 core/api/serializers.py:458
|
||||
msgid "Body type"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:462 core/api/serializers.py:462
|
||||
#: build/lib/core/api/serializers.py:464 core/api/serializers.py:464
|
||||
msgid "Format"
|
||||
msgstr ""
|
||||
|
||||
@@ -230,8 +230,8 @@ msgstr ""
|
||||
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:373 build/lib/core/models.py:942 core/models.py:373
|
||||
#: core/models.py:942
|
||||
msgid "title"
|
||||
msgstr ""
|
||||
|
||||
@@ -251,143 +251,143 @@ msgstr ""
|
||||
msgid "Untitled Document"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:719 core/models.py:719
|
||||
#: build/lib/core/models.py:734 core/models.py:734
|
||||
#, 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:738 core/models.py:738
|
||||
#, 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:741 core/models.py:741
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:762 core/models.py:762
|
||||
#: build/lib/core/models.py:777 core/models.py:777
|
||||
msgid "This document is not deleted."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:769 core/models.py:769
|
||||
#: build/lib/core/models.py:784 core/models.py:784
|
||||
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:837 core/models.py:837
|
||||
msgid "Document/user link trace"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:821 core/models.py:821
|
||||
#: build/lib/core/models.py:838 core/models.py:838
|
||||
msgid "Document/user link traces"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:827 core/models.py:827
|
||||
#: build/lib/core/models.py:844 core/models.py:844
|
||||
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:867 core/models.py:867
|
||||
msgid "Document favorite"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:851 core/models.py:851
|
||||
#: build/lib/core/models.py:868 core/models.py:868
|
||||
msgid "Document favorites"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:857 core/models.py:857
|
||||
#: build/lib/core/models.py:874 core/models.py:874
|
||||
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:896 core/models.py:896
|
||||
msgid "Document/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:880 core/models.py:880
|
||||
#: build/lib/core/models.py:897 core/models.py:897
|
||||
msgid "Document/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:886 core/models.py:886
|
||||
#: build/lib/core/models.py:903 core/models.py:903
|
||||
msgid "This user is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:892 core/models.py:892
|
||||
#: build/lib/core/models.py:909 core/models.py:909
|
||||
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:915 build/lib/core/models.py:1029
|
||||
#: core/models.py:915 core/models.py:1029
|
||||
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:943 core/models.py:943
|
||||
msgid "description"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:927 core/models.py:927
|
||||
#: build/lib/core/models.py:944 core/models.py:944
|
||||
msgid "code"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:928 core/models.py:928
|
||||
#: build/lib/core/models.py:945 core/models.py:945
|
||||
msgid "css"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:930 core/models.py:930
|
||||
#: build/lib/core/models.py:947 core/models.py:947
|
||||
msgid "public"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:932 core/models.py:932
|
||||
#: build/lib/core/models.py:949 core/models.py:949
|
||||
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:955 core/models.py:955
|
||||
msgid "Template"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:939 core/models.py:939
|
||||
#: build/lib/core/models.py:956 core/models.py:956
|
||||
msgid "Templates"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:993 core/models.py:993
|
||||
#: build/lib/core/models.py:1010 core/models.py:1010
|
||||
msgid "Template/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:994 core/models.py:994
|
||||
#: build/lib/core/models.py:1011 core/models.py:1011
|
||||
msgid "Template/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
#: build/lib/core/models.py:1017 core/models.py:1017
|
||||
msgid "This user is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
#: build/lib/core/models.py:1023 core/models.py:1023
|
||||
msgid "This team is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
#: build/lib/core/models.py:1046 core/models.py:1046
|
||||
msgid "email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1048 core/models.py:1048
|
||||
#: build/lib/core/models.py:1065 core/models.py:1065
|
||||
msgid "Document invitation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1049 core/models.py:1049
|
||||
#: build/lib/core/models.py:1066 core/models.py:1066
|
||||
msgid "Document invitations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1069 core/models.py:1069
|
||||
#: build/lib/core/models.py:1086 core/models.py:1086
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/impress/settings.py:236 impress/settings.py:236
|
||||
#: build/lib/impress/settings.py:235 impress/settings.py:235
|
||||
msgid "English"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/impress/settings.py:237 impress/settings.py:237
|
||||
#: build/lib/impress/settings.py:236 impress/settings.py:236
|
||||
msgid "French"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/impress/settings.py:238 impress/settings.py:238
|
||||
#: build/lib/impress/settings.py:237 impress/settings.py:237
|
||||
msgid "German"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "impress"
|
||||
version = "2.2.0"
|
||||
version = "2.4.0"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -25,37 +25,37 @@ license = { file = "LICENSE" }
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"boto3==1.36.7",
|
||||
"boto3==1.37.5",
|
||||
"Brotli==1.1.0",
|
||||
"celery[redis]==5.4.0",
|
||||
"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-parler==2.3",
|
||||
"redis==5.2.1",
|
||||
"django-redis==5.4.0",
|
||||
"django-storages[s3]==1.14.4",
|
||||
"django-storages[s3]==1.14.5",
|
||||
"django-timezone-field>=5.1",
|
||||
"django==5.1.5",
|
||||
"django==5.1.6",
|
||||
"django-treebeard==4.7.1",
|
||||
"djangorestframework==3.15.2",
|
||||
"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",
|
||||
"nested-multipart-parser==1.5.0",
|
||||
"openai==1.60.2",
|
||||
"psycopg[binary]==3.2.4",
|
||||
"openai==1.65.2",
|
||||
"psycopg[binary]==3.2.5",
|
||||
"PyJWT==2.10.1",
|
||||
"python-magic==0.4.27",
|
||||
"requests==2.32.3",
|
||||
"sentry-sdk==2.20.0",
|
||||
"sentry-sdk==2.22.0",
|
||||
"url-normalize==1.4.3",
|
||||
"whitenoise==6.8.2",
|
||||
"whitenoise==6.9.0",
|
||||
"mozilla-django-oidc==4.0.1",
|
||||
]
|
||||
|
||||
@@ -68,21 +68,22 @@ dependencies = [
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"django-extensions==3.2.3",
|
||||
"drf-spectacular-sidecar==2024.12.1",
|
||||
"django-test-migrations==1.4.0",
|
||||
"drf-spectacular-sidecar==2025.3.1",
|
||||
"freezegun==1.5.1",
|
||||
"ipdb==0.13.13",
|
||||
"ipython==8.31.0",
|
||||
"ipython==9.0.1",
|
||||
"pyfakefs==5.7.4",
|
||||
"pylint-django==2.6.1",
|
||||
"pylint==3.3.4",
|
||||
"pytest-cov==6.0.0",
|
||||
"pytest-django==4.9.0",
|
||||
"pytest==8.3.4",
|
||||
"pytest-django==4.10.0",
|
||||
"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",
|
||||
"ruff==0.9.9",
|
||||
"types-requests==2.32.0.20250301",
|
||||
]
|
||||
|
||||
[tool.setuptools]
|
||||
@@ -99,7 +100,6 @@ exclude = [
|
||||
"build",
|
||||
"venv",
|
||||
"__pycache__",
|
||||
"*/migrations/*",
|
||||
]
|
||||
line-length = 88
|
||||
|
||||
|
||||
@@ -17,13 +17,13 @@ test.describe('404', () => {
|
||||
'It seems that the page you are looking for does not exist or cannot be displayed correctly.',
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText('Back to home page')).toBeVisible();
|
||||
await expect(page.getByText('Home')).toBeVisible();
|
||||
});
|
||||
|
||||
test('checks go back to home page redirects to home page', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.getByText('Back to home page').click();
|
||||
await page.getByText('Home').click();
|
||||
await expect(page).toHaveURL('/');
|
||||
});
|
||||
});
|
||||
|
||||
22
src/frontend/apps/e2e/__tests__/app-impress/assets/test.html
Normal file
22
src/frontend/apps/e2e/__tests__/app-impress/assets/test.html
Normal file
@@ -0,0 +1,22 @@
|
||||
<html>
|
||||
<head>
|
||||
<title>Test unsafe file</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Hello svg</h1>
|
||||
<img src="test.jpg" alt="test" />
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="100"
|
||||
height="100"
|
||||
viewBox="0 0 100 100"
|
||||
>
|
||||
<circle cx="50" cy="30" r="20" fill="#3498db" />
|
||||
<polygon
|
||||
points="50,10 55,20 65,20 58,30 60,40 50,35 40,40 42,30 35,20 45,20"
|
||||
fill="#f1c40f"
|
||||
/>
|
||||
<text x="50" y="70" text-anchor="middle" fill="white">Hello svg</text>
|
||||
</svg>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,27 +1,46 @@
|
||||
import { test as setup } from '@playwright/test';
|
||||
import { FullConfig, FullProject, chromium, expect } from '@playwright/test';
|
||||
|
||||
import { keyCloakSignIn } from './common';
|
||||
|
||||
setup('authenticate-chromium', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await keyCloakSignIn(page, 'chromium');
|
||||
await page
|
||||
.context()
|
||||
.storageState({ path: `playwright/.auth/user-chromium.json` });
|
||||
});
|
||||
const saveStorageState = async (
|
||||
browserConfig: FullProject<unknown, unknown>,
|
||||
) => {
|
||||
const browserName = browserConfig?.name || 'chromium';
|
||||
|
||||
setup('authenticate-webkit', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await keyCloakSignIn(page, 'webkit');
|
||||
await page
|
||||
.context()
|
||||
.storageState({ path: `playwright/.auth/user-webkit.json` });
|
||||
});
|
||||
const { storageState, ...useConfig } = browserConfig?.use;
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext(useConfig);
|
||||
const page = await context.newPage();
|
||||
|
||||
setup('authenticate-firefox', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await keyCloakSignIn(page, 'firefox');
|
||||
await page
|
||||
.context()
|
||||
.storageState({ path: `playwright/.auth/user-firefox.json` });
|
||||
});
|
||||
await page.goto('/', { waitUntil: 'networkidle' });
|
||||
await page.content();
|
||||
await expect(page.getByText('Docs').first()).toBeVisible();
|
||||
|
||||
await keyCloakSignIn(page, browserName);
|
||||
|
||||
await expect(
|
||||
page.locator('header').first().getByRole('button', {
|
||||
name: 'Logout',
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
await page.context().storageState({
|
||||
path: storageState as string,
|
||||
});
|
||||
|
||||
await browser.close();
|
||||
};
|
||||
|
||||
async function globalSetup(config: FullConfig) {
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
const chromeConfig = config.projects.find((p) => p.name === 'chromium')!;
|
||||
const firefoxConfig = config.projects.find((p) => p.name === 'firefox')!;
|
||||
const webkitConfig = config.projects.find((p) => p.name === 'webkit')!;
|
||||
/* eslint-enable @typescript-eslint/no-non-null-assertion */
|
||||
|
||||
await saveStorageState(chromeConfig);
|
||||
await saveStorageState(webkitConfig);
|
||||
await saveStorageState(firefoxConfig);
|
||||
}
|
||||
|
||||
export default globalSetup;
|
||||
|
||||
@@ -97,7 +97,7 @@ export const addNewMember = async (
|
||||
|
||||
// Choose a role
|
||||
await page.getByLabel('doc-role-dropdown').click();
|
||||
await page.getByRole('button', { name: role }).click();
|
||||
await page.getByLabel(role).click();
|
||||
await page.getByRole('button', { name: 'Invite' }).click();
|
||||
|
||||
return users[index].email;
|
||||
|
||||
@@ -12,8 +12,8 @@ const config = {
|
||||
MEDIA_BASE_URL: 'http://localhost:8083',
|
||||
LANGUAGES: [
|
||||
['en-us', 'English'],
|
||||
['fr-fr', 'French'],
|
||||
['de-de', 'German'],
|
||||
['fr-fr', 'Français'],
|
||||
['de-de', 'Deutsch'],
|
||||
],
|
||||
LANGUAGE_CODE: 'en-us',
|
||||
POSTHOG_KEY: {},
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
/* eslint-disable playwright/no-conditional-expect */
|
||||
/* eslint-disable playwright/no-conditional-in-test */
|
||||
import path from 'path';
|
||||
|
||||
import { expect, test } from '@playwright/test';
|
||||
import cs from 'convert-stream';
|
||||
|
||||
import {
|
||||
createDoc,
|
||||
@@ -16,41 +15,6 @@ test.beforeEach(async ({ page }) => {
|
||||
});
|
||||
|
||||
test.describe('Doc Editor', () => {
|
||||
test('it check translations of the slash menu when changing language', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
await createDoc(page, 'doc-toolbar', browserName, 1);
|
||||
|
||||
const header = page.locator('header').first();
|
||||
const editor = page.locator('.ProseMirror');
|
||||
// Trigger slash menu to show english menu
|
||||
await editor.click();
|
||||
await editor.fill('/');
|
||||
await expect(page.getByText('Headings', { exact: true })).toBeVisible();
|
||||
await header.click();
|
||||
await expect(page.getByText('Headings', { exact: true })).toBeHidden();
|
||||
|
||||
// Reset menu
|
||||
await editor.click();
|
||||
await editor.fill('');
|
||||
|
||||
// Change language to French
|
||||
await header.click();
|
||||
await header.getByRole('combobox').getByText('English').click();
|
||||
await header.getByRole('option', { name: 'Français' }).click();
|
||||
await expect(
|
||||
header.getByRole('combobox').getByText('Français'),
|
||||
).toBeVisible();
|
||||
|
||||
// Trigger slash menu to show french menu
|
||||
await editor.click();
|
||||
await editor.fill('/');
|
||||
await expect(page.getByText('Titres', { exact: true })).toBeVisible();
|
||||
await header.click();
|
||||
await expect(page.getByText('Titres', { exact: true })).toBeHidden();
|
||||
});
|
||||
|
||||
test('it checks default toolbar buttons are displayed', async ({
|
||||
page,
|
||||
browserName,
|
||||
@@ -129,11 +93,7 @@ test.describe('Doc Editor', () => {
|
||||
const wsClosePromise = webSocket.waitForEvent('close');
|
||||
|
||||
await selectVisibility.click();
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Connected',
|
||||
})
|
||||
.click();
|
||||
await page.getByLabel('Connected').click();
|
||||
|
||||
// Assert that the doc reconnects to the ws
|
||||
const wsClose = await wsClosePromise;
|
||||
@@ -415,6 +375,8 @@ test.describe('Doc Editor', () => {
|
||||
const editor = page.locator('.ProseMirror');
|
||||
await editor.getByText('Hello').dblclick();
|
||||
|
||||
/* eslint-disable playwright/no-conditional-expect */
|
||||
/* eslint-disable playwright/no-conditional-in-test */
|
||||
if (!ai_transform && !ai_translate) {
|
||||
await expect(page.getByRole('button', { name: 'AI' })).toBeHidden();
|
||||
return;
|
||||
@@ -441,6 +403,45 @@ test.describe('Doc Editor', () => {
|
||||
page.getByRole('menuitem', { name: 'Language' }),
|
||||
).toBeHidden();
|
||||
}
|
||||
/* eslint-enable playwright/no-conditional-expect */
|
||||
/* eslint-enable playwright/no-conditional-in-test */
|
||||
});
|
||||
});
|
||||
|
||||
test('it downloads unsafe files', async ({ page, browserName }) => {
|
||||
const [randomDoc] = await createDoc(page, 'doc-editor', browserName, 1);
|
||||
|
||||
const fileChooserPromise = page.waitForEvent('filechooser');
|
||||
const downloadPromise = page.waitForEvent('download', (download) => {
|
||||
return download.suggestedFilename().includes(`html`);
|
||||
});
|
||||
|
||||
await verifyDocName(page, randomDoc);
|
||||
|
||||
await page.locator('.ProseMirror.bn-editor').click();
|
||||
await page.locator('.ProseMirror.bn-editor').fill('Hello World');
|
||||
|
||||
await page.keyboard.press('Enter');
|
||||
await page.locator('.bn-block-outer').last().fill('/');
|
||||
await page.getByText('Embedded file').click();
|
||||
await page.getByText('Upload file').click();
|
||||
|
||||
const fileChooser = await fileChooserPromise;
|
||||
await fileChooser.setFiles(path.join(__dirname, 'assets/test.html'));
|
||||
|
||||
await page.locator('.bn-block-content[data-name="test.html"]').click();
|
||||
await page.getByRole('button', { name: 'Download file' }).click();
|
||||
|
||||
await expect(
|
||||
page.getByText('This file is flagged as unsafe.'),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Download' }).click();
|
||||
|
||||
const download = await downloadPromise;
|
||||
expect(download.suggestedFilename()).toContain(`-unsafe.html`);
|
||||
|
||||
const svgBuffer = await cs.toBuffer(await download.createReadStream());
|
||||
expect(svgBuffer.toString()).toContain('Hello svg');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -197,4 +197,49 @@ test.describe('Doc Export', () => {
|
||||
|
||||
expect(pdfText).toContain('Hello World');
|
||||
});
|
||||
|
||||
test('it exports the doc with quotes', async ({ page, browserName }) => {
|
||||
const [randomDoc] = await createDoc(page, 'export-quotes', browserName, 1);
|
||||
|
||||
const downloadPromise = page.waitForEvent('download', (download) => {
|
||||
return download.suggestedFilename().includes(`${randomDoc}.pdf`);
|
||||
});
|
||||
|
||||
const editor = page.locator('.ProseMirror');
|
||||
// Trigger slash menu to show menu
|
||||
await editor.click();
|
||||
await editor.fill('/');
|
||||
await page.getByText('Add a quote block').click();
|
||||
|
||||
await expect(
|
||||
editor.locator('.bn-block-content[data-content-type="quote"]'),
|
||||
).toBeVisible();
|
||||
|
||||
await editor.fill('Hello World');
|
||||
|
||||
await expect(editor.getByText('Hello World')).toHaveCSS(
|
||||
'font-style',
|
||||
'italic',
|
||||
);
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'download',
|
||||
})
|
||||
.click();
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Download',
|
||||
})
|
||||
.click();
|
||||
|
||||
const download = await downloadPromise;
|
||||
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
|
||||
|
||||
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
|
||||
const pdfData = await pdf(pdfBuffer);
|
||||
|
||||
expect(pdfData.text).toContain('Hello World'); // This is the pdf text
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,17 +7,9 @@ type SmallDoc = {
|
||||
title: string;
|
||||
};
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
test.describe('Documents Grid mobile', () => {
|
||||
test.use({ viewport: { width: 500, height: 1200 } });
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
test('it checks the grid when mobile', async ({ page }) => {
|
||||
await page.route('**/documents/**', async (route) => {
|
||||
const request = route.request();
|
||||
@@ -94,6 +86,10 @@ test.describe('Documents Grid mobile', () => {
|
||||
});
|
||||
|
||||
test.describe('Document grid item options', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
test('it pins a document', async ({ page, browserName }) => {
|
||||
const [docTitle] = await createDoc(page, `Favorite doc`, browserName);
|
||||
|
||||
@@ -212,6 +208,8 @@ test.describe('Document grid item options', () => {
|
||||
|
||||
test.describe('Documents filters', () => {
|
||||
test('it checks the prebuild left panel filters', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
// All Docs
|
||||
const response = await page.waitForResponse(
|
||||
(response) =>
|
||||
@@ -282,11 +280,9 @@ test.describe('Documents filters', () => {
|
||||
});
|
||||
|
||||
test.describe('Documents Grid', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
test('checks all the elements are visible', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
let docs: SmallDoc[] = [];
|
||||
const response = await page.waitForResponse(
|
||||
(response) =>
|
||||
@@ -314,11 +310,12 @@ test.describe('Documents Grid', () => {
|
||||
|
||||
test('checks the infinite scroll', async ({ page }) => {
|
||||
let docs: SmallDoc[] = [];
|
||||
const responsePromisePage1 = page.waitForResponse(
|
||||
(response) =>
|
||||
const responsePromisePage1 = page.waitForResponse((response) => {
|
||||
return (
|
||||
response.url().endsWith(`/documents/?page=1`) &&
|
||||
response.status() === 200,
|
||||
);
|
||||
response.status() === 200
|
||||
);
|
||||
});
|
||||
|
||||
const responsePromisePage2 = page.waitForResponse(
|
||||
(response) =>
|
||||
@@ -326,6 +323,8 @@ test.describe('Documents Grid', () => {
|
||||
response.status() === 200,
|
||||
);
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
const responsePage1 = await responsePromisePage1;
|
||||
expect(responsePage1.ok()).toBeTruthy();
|
||||
let result = await responsePage1.json();
|
||||
|
||||
@@ -88,20 +88,14 @@ test.describe('Doc Header', () => {
|
||||
const [randomDoc] = await createDoc(page, 'doc-delete', browserName, 1);
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Delete document',
|
||||
})
|
||||
.click();
|
||||
await page.getByLabel('Delete document').click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Delete a doc' }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByText(
|
||||
`Are you sure you want to delete the document "${randomDoc}"?`,
|
||||
),
|
||||
page.getByText(`Are you sure you want to delete this document ?`),
|
||||
).toBeVisible();
|
||||
|
||||
await page
|
||||
@@ -152,9 +146,7 @@ test.describe('Doc Header', () => {
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Delete document' }),
|
||||
).toBeDisabled();
|
||||
await expect(page.getByLabel('Delete document')).toBeDisabled();
|
||||
|
||||
// Click somewhere else to close the options
|
||||
await page.click('body', { position: { x: 0, y: 0 } });
|
||||
@@ -176,11 +168,7 @@ test.describe('Doc Header', () => {
|
||||
|
||||
await invitationCard.getByRole('button', { name: 'more_horiz' }).click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: 'delete',
|
||||
}),
|
||||
).toBeEnabled();
|
||||
await expect(page.getByLabel('Delete')).toBeEnabled();
|
||||
await invitationCard.click();
|
||||
|
||||
const memberCard = shareModal.getByLabel('List members card');
|
||||
@@ -194,11 +182,7 @@ test.describe('Doc Header', () => {
|
||||
).toBeVisible();
|
||||
await memberCard.getByRole('button', { name: 'more_horiz' }).click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: 'delete',
|
||||
}),
|
||||
).toBeEnabled();
|
||||
await expect(page.getByLabel('Delete')).toBeEnabled();
|
||||
});
|
||||
|
||||
test('it checks the options available if editor', async ({ page }) => {
|
||||
@@ -232,9 +216,7 @@ test.describe('Doc Header', () => {
|
||||
await expect(page.getByRole('button', { name: 'download' })).toBeVisible();
|
||||
await page.getByLabel('Open the document options').click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Delete document' }),
|
||||
).toBeDisabled();
|
||||
await expect(page.getByLabel('Delete document')).toBeDisabled();
|
||||
|
||||
// Click somewhere else to close the options
|
||||
await page.click('body', { position: { x: 0, y: 0 } });
|
||||
@@ -294,9 +276,7 @@ test.describe('Doc Header', () => {
|
||||
await expect(page.getByRole('button', { name: 'download' })).toBeVisible();
|
||||
await page.getByLabel('Open the document options').click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Delete document' }),
|
||||
).toBeDisabled();
|
||||
await expect(page.getByLabel('Delete document')).toBeDisabled();
|
||||
|
||||
// Click somewhere else to close the options
|
||||
await page.click('body', { position: { x: 0, y: 0 } });
|
||||
@@ -352,7 +332,7 @@ test.describe('Doc Header', () => {
|
||||
|
||||
// Copy content to clipboard
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page.getByRole('button', { name: 'Copy as Markdown' }).click();
|
||||
await page.getByLabel('Copy as Markdown').click();
|
||||
await expect(page.getByText('Copied to clipboard')).toBeVisible();
|
||||
|
||||
// Test that clipboard is in Markdown format
|
||||
@@ -387,7 +367,7 @@ test.describe('Doc Header', () => {
|
||||
|
||||
// Copy content to clipboard
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page.getByRole('button', { name: 'Copy as HTML' }).click();
|
||||
await page.getByLabel('Copy as HTML').click();
|
||||
await expect(page.getByText('Copied to clipboard')).toBeVisible();
|
||||
|
||||
// Test that clipboard is in HTML format
|
||||
@@ -460,7 +440,7 @@ test.describe('Documents Header mobile', () => {
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Copy link' })).toBeHidden();
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
await page.getByLabel('Share').click();
|
||||
await page.getByRole('button', { name: 'Copy link' }).click();
|
||||
await expect(page.getByText('Link Copied !')).toBeVisible();
|
||||
// Test that clipboard is in HTML format
|
||||
@@ -494,7 +474,7 @@ test.describe('Documents Header mobile', () => {
|
||||
await goToGridDoc(page);
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
await page.getByLabel('Share').click();
|
||||
|
||||
await expect(page.getByLabel('Share modal')).toBeVisible();
|
||||
await page.getByRole('button', { name: 'close' }).click();
|
||||
|
||||
@@ -65,15 +65,13 @@ test.describe('Document create member', () => {
|
||||
|
||||
// Check roles are displayed
|
||||
await list.getByLabel('doc-role-dropdown').click();
|
||||
await expect(page.getByRole('button', { name: 'Reader' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Editor' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Owner' })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Administrator' }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByLabel('Reader')).toBeVisible();
|
||||
await expect(page.getByLabel('Editor')).toBeVisible();
|
||||
await expect(page.getByLabel('Owner')).toBeVisible();
|
||||
await expect(page.getByLabel('Administrator')).toBeVisible();
|
||||
|
||||
// Validate
|
||||
await page.getByRole('button', { name: 'Administrator' }).click();
|
||||
await page.getByLabel('Administrator').click();
|
||||
await page.getByRole('button', { name: 'Invite' }).click();
|
||||
|
||||
// Check invitation added
|
||||
@@ -121,7 +119,7 @@ test.describe('Document create member', () => {
|
||||
// Choose a role
|
||||
const container = page.getByTestId('doc-share-add-member-list');
|
||||
await container.getByLabel('doc-role-dropdown').click();
|
||||
await page.getByRole('button', { name: 'Owner' }).click();
|
||||
await page.getByLabel('Owner').click();
|
||||
|
||||
const responsePromiseCreateInvitation = page.waitForResponse(
|
||||
(response) =>
|
||||
@@ -139,7 +137,7 @@ test.describe('Document create member', () => {
|
||||
|
||||
// Choose a role
|
||||
await container.getByLabel('doc-role-dropdown').click();
|
||||
await page.getByRole('button', { name: 'Owner' }).click();
|
||||
await page.getByLabel('Owner').click();
|
||||
|
||||
const responsePromiseCreateInvitationFail = page.waitForResponse(
|
||||
(response) =>
|
||||
@@ -155,47 +153,6 @@ test.describe('Document create member', () => {
|
||||
expect(responseCreateInvitationFail.ok()).toBeFalsy();
|
||||
});
|
||||
|
||||
test('The invitation endpoint get the language of the website', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
await createDoc(page, 'user-invitation', browserName, 1);
|
||||
|
||||
const header = page.locator('header').first();
|
||||
await header.getByRole('combobox').getByText('EN').click();
|
||||
await header.getByRole('option', { name: 'translate Français' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Partager' }).click();
|
||||
|
||||
const inputSearch = page.getByRole('combobox', {
|
||||
name: 'Saisie de recherche rapide',
|
||||
});
|
||||
|
||||
const email = randomName('test@test.fr', browserName, 1)[0];
|
||||
await inputSearch.fill(email);
|
||||
await page.getByTestId(`search-user-row-${email}`).click();
|
||||
|
||||
// Choose a role
|
||||
const container = page.getByTestId('doc-share-add-member-list');
|
||||
await container.getByLabel('doc-role-dropdown').click();
|
||||
await page.getByRole('button', { name: 'Administrateur' }).click();
|
||||
|
||||
const responsePromiseCreateInvitation = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/invitations/') && response.status() === 201,
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: 'Invite' }).click();
|
||||
|
||||
// Check invitation sent
|
||||
|
||||
const responseCreateInvitation = await responsePromiseCreateInvitation;
|
||||
expect(responseCreateInvitation.ok()).toBeTruthy();
|
||||
expect(
|
||||
responseCreateInvitation.request().headers()['content-language'],
|
||||
).toBe('fr-fr');
|
||||
});
|
||||
|
||||
test('it manages invitation', async ({ page, browserName }) => {
|
||||
await createDoc(page, 'user-invitation', browserName, 1);
|
||||
|
||||
@@ -212,7 +169,7 @@ test.describe('Document create member', () => {
|
||||
// Choose a role
|
||||
const container = page.getByTestId('doc-share-add-member-list');
|
||||
await container.getByLabel('doc-role-dropdown').click();
|
||||
await page.getByRole('button', { name: 'Administrator' }).click();
|
||||
await page.getByLabel('Administrator').click();
|
||||
|
||||
const responsePromiseCreateInvitation = page.waitForResponse(
|
||||
(response) =>
|
||||
@@ -232,14 +189,14 @@ test.describe('Document create member', () => {
|
||||
await expect(userInvitation).toBeVisible();
|
||||
|
||||
await userInvitation.getByLabel('doc-role-dropdown').click();
|
||||
await page.getByRole('button', { name: 'Reader' }).click();
|
||||
await page.getByLabel('Reader').click();
|
||||
|
||||
const moreActions = userInvitation.getByRole('button', {
|
||||
name: 'more_horiz',
|
||||
});
|
||||
await moreActions.click();
|
||||
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
await page.getByLabel('Delete').click();
|
||||
|
||||
await expect(userInvitation).toBeHidden();
|
||||
});
|
||||
|
||||
@@ -161,12 +161,12 @@ test.describe('Document list members', () => {
|
||||
await list.click();
|
||||
|
||||
await currentUserRole.click();
|
||||
await page.getByRole('button', { name: 'Administrator' }).click();
|
||||
await page.getByLabel('Administrator').click();
|
||||
await list.click();
|
||||
await expect(currentUserRole).toBeVisible();
|
||||
|
||||
await currentUserRole.click();
|
||||
await page.getByRole('button', { name: 'Reader' }).click();
|
||||
await page.getByLabel('Reader').click();
|
||||
await list.click();
|
||||
await expect(currentUserRole).toBeHidden();
|
||||
});
|
||||
@@ -215,13 +215,13 @@ test.describe('Document list members', () => {
|
||||
await expect(mySelfMoreActions).toBeVisible();
|
||||
|
||||
await userReaderMoreActions.click();
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
await page.getByLabel('Delete').click();
|
||||
await expect(userReader).toBeHidden();
|
||||
|
||||
await mySelfMoreActions.click();
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
await page.getByLabel('Delete').click();
|
||||
await expect(
|
||||
page.getByText('You do not have permission to perform this action.'),
|
||||
page.getByText('You do not have permission to view this document.'),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,11 +18,7 @@ test.describe('Doc Version', () => {
|
||||
await verifyDocName(page, randomDoc);
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Version history',
|
||||
})
|
||||
.click();
|
||||
await page.getByLabel('Version history').click();
|
||||
await expect(page.getByText('History', { exact: true })).toBeVisible();
|
||||
|
||||
const modal = page.getByLabel('version history modal');
|
||||
@@ -58,11 +54,7 @@ test.describe('Doc Version', () => {
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Version history',
|
||||
})
|
||||
.click();
|
||||
await page.getByLabel('Version history').click();
|
||||
|
||||
await expect(panel).toBeVisible();
|
||||
await expect(page.getByText('History', { exact: true })).toBeVisible();
|
||||
@@ -90,9 +82,7 @@ test.describe('Doc Version', () => {
|
||||
await verifyDocName(page, 'Mocked document');
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Version history' }),
|
||||
).toBeDisabled();
|
||||
await expect(page.getByLabel('Version history')).toBeDisabled();
|
||||
});
|
||||
|
||||
test('it restores the doc version', async ({ page, browserName }) => {
|
||||
@@ -119,11 +109,7 @@ test.describe('Doc Version', () => {
|
||||
await expect(page.getByText('World')).toBeVisible();
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Version history',
|
||||
})
|
||||
.click();
|
||||
await page.getByLabel('Version history').click();
|
||||
|
||||
const modal = page.getByLabel('version history modal');
|
||||
const panel = modal.getByLabel('version list');
|
||||
|
||||
@@ -49,21 +49,13 @@ test.describe('Doc Visibility', () => {
|
||||
await expect(page.getByLabel('Can read and edit')).toBeHidden();
|
||||
|
||||
await selectVisibility.click();
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Connected',
|
||||
})
|
||||
.click();
|
||||
await page.getByLabel('Connected').click();
|
||||
|
||||
await expect(page.getByLabel('Visibility mode')).toBeVisible();
|
||||
|
||||
await selectVisibility.click();
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Public',
|
||||
})
|
||||
.click();
|
||||
await page.getByLabel('Public', { exact: true }).click();
|
||||
|
||||
await expect(page.getByLabel('Visibility mode')).toBeVisible();
|
||||
});
|
||||
@@ -100,7 +92,9 @@ test.describe('Doc Visibility: Restricted', () => {
|
||||
|
||||
await page.goto(urlDoc);
|
||||
|
||||
await expect(page.getByRole('textbox', { name: 'password' })).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Log in to access the document.'),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('A doc is not accessible when authentified but not member.', async ({
|
||||
@@ -133,7 +127,7 @@ test.describe('Doc Visibility: Restricted', () => {
|
||||
await page.goto(urlDoc);
|
||||
|
||||
await expect(
|
||||
page.getByText('You do not have permission to perform this action.'),
|
||||
page.getByText('You do not have permission to view this document.'),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -160,7 +154,7 @@ test.describe('Doc Visibility: Restricted', () => {
|
||||
// Choose a role
|
||||
const container = page.getByTestId('doc-share-add-member-list');
|
||||
await container.getByLabel('doc-role-dropdown').click();
|
||||
await page.getByRole('button', { name: 'Administrator' }).click();
|
||||
await page.getByLabel('Administrator').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Invite' }).click();
|
||||
|
||||
@@ -213,7 +207,7 @@ test.describe('Doc Visibility: Public', () => {
|
||||
await selectVisibility.click();
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
.getByRole('menuitem', {
|
||||
name: 'Public',
|
||||
})
|
||||
.click();
|
||||
@@ -225,7 +219,7 @@ test.describe('Doc Visibility: Public', () => {
|
||||
await expect(page.getByLabel('Visibility mode')).toBeVisible();
|
||||
await page.getByLabel('Visibility mode').click();
|
||||
await page
|
||||
.getByRole('button', {
|
||||
.getByRole('menuitem', {
|
||||
name: 'Reading',
|
||||
})
|
||||
.click();
|
||||
@@ -287,7 +281,7 @@ test.describe('Doc Visibility: Public', () => {
|
||||
await selectVisibility.click();
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
.getByRole('menuitem', {
|
||||
name: 'Public',
|
||||
})
|
||||
.click();
|
||||
@@ -355,7 +349,7 @@ test.describe('Doc Visibility: Authenticated', () => {
|
||||
const selectVisibility = page.getByLabel('Visibility', { exact: true });
|
||||
await selectVisibility.click();
|
||||
await page
|
||||
.getByRole('button', {
|
||||
.getByRole('menuitem', {
|
||||
name: 'Connected',
|
||||
})
|
||||
.click();
|
||||
@@ -379,7 +373,10 @@ test.describe('Doc Visibility: Authenticated', () => {
|
||||
await page.goto(urlDoc);
|
||||
|
||||
await expect(page.locator('h2').getByText(docTitle)).toBeHidden();
|
||||
await expect(page.getByRole('textbox', { name: 'password' })).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByText('Log in to access the document.'),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('It checks a authenticated doc in read only mode', async ({
|
||||
@@ -402,7 +399,7 @@ test.describe('Doc Visibility: Authenticated', () => {
|
||||
const selectVisibility = page.getByLabel('Visibility', { exact: true });
|
||||
await selectVisibility.click();
|
||||
await page
|
||||
.getByRole('button', {
|
||||
.getByRole('menuitem', {
|
||||
name: 'Connected',
|
||||
})
|
||||
.click();
|
||||
@@ -411,6 +408,14 @@ test.describe('Doc Visibility: Authenticated', () => {
|
||||
page.getByText('The document visibility has been updated.'),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page
|
||||
.getByLabel('It is the card information about the document.')
|
||||
.getByText('Document accessible to any connected person', {
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'close' }).click();
|
||||
|
||||
const urlDoc = page.url();
|
||||
@@ -456,7 +461,7 @@ test.describe('Doc Visibility: Authenticated', () => {
|
||||
const selectVisibility = page.getByLabel('Visibility', { exact: true });
|
||||
await selectVisibility.click();
|
||||
await page
|
||||
.getByRole('button', {
|
||||
.getByRole('menuitem', {
|
||||
name: 'Connected',
|
||||
})
|
||||
.click();
|
||||
|
||||
@@ -88,6 +88,7 @@ test.describe('Header mobile', () => {
|
||||
test.describe('Header: Log out', () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
// eslint-disable-next-line playwright/expect-expect
|
||||
test('checks logout button', async ({ page, browserName }) => {
|
||||
await page.goto('/');
|
||||
await keyCloakSignIn(page, browserName);
|
||||
|
||||
@@ -12,7 +12,7 @@ test.describe('Home page', () => {
|
||||
const footer = page.locator('footer').first();
|
||||
await expect(header).toBeVisible();
|
||||
await expect(
|
||||
header.getByRole('combobox', { name: 'Language' }),
|
||||
header.getByRole('button', { name: /Language/ }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
header.getByRole('button', { name: 'Les services de La Suite numé' }),
|
||||
@@ -26,6 +26,7 @@ test.describe('Home page', () => {
|
||||
|
||||
// Check the titles
|
||||
const h2 = page.locator('h2');
|
||||
await expect(h2.getByText('Govs ❤️ Open Source.')).toBeVisible();
|
||||
await expect(
|
||||
h2.getByText('Collaborative writing, Simplified.'),
|
||||
).toBeVisible();
|
||||
|
||||
@@ -1,27 +1,50 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { Page, expect, test } from '@playwright/test';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
});
|
||||
import { createDoc } from './common';
|
||||
|
||||
test.describe('Language', () => {
|
||||
test('checks the language picker', async ({ page }) => {
|
||||
await expect(page.getByLabel('Logout')).toBeVisible();
|
||||
test.describe.serial('Language', () => {
|
||||
let page: Page;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
page = await browser.newPage();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await waitForLanguageSwitch(page, TestLanguage.English);
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page }) => {
|
||||
// Switch back to English - important for other tests to run as expected
|
||||
await waitForLanguageSwitch(page, TestLanguage.English);
|
||||
});
|
||||
|
||||
test('checks language switching', async ({ page }) => {
|
||||
const header = page.locator('header').first();
|
||||
await header.getByRole('combobox').getByText('English').click();
|
||||
await header.getByRole('option', { name: 'Français' }).click();
|
||||
|
||||
// initial language should be english
|
||||
await expect(
|
||||
header.getByRole('combobox').getByText('Français'),
|
||||
page.getByRole('button', {
|
||||
name: 'New doc',
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
// switch to french
|
||||
await waitForLanguageSwitch(page, TestLanguage.French);
|
||||
|
||||
await expect(
|
||||
header.getByRole('button').getByText('Français'),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(page.getByLabel('Se déconnecter')).toBeVisible();
|
||||
|
||||
await header.getByRole('combobox').getByText('Français').click();
|
||||
await header.getByRole('option', { name: 'Deutsch' }).click();
|
||||
await expect(
|
||||
header.getByRole('combobox').getByText('Deutsch'),
|
||||
).toBeVisible();
|
||||
await header.getByRole('button').getByText('Français').click();
|
||||
await page.getByLabel('Deutsch').click();
|
||||
await expect(header.getByRole('button').getByText('Deutsch')).toBeVisible();
|
||||
|
||||
await expect(page.getByLabel('Abmelden')).toBeVisible();
|
||||
});
|
||||
@@ -51,12 +74,76 @@ test.describe('Language', () => {
|
||||
// Check for English 404 response
|
||||
await check404Response('Not found.');
|
||||
|
||||
// Switch language to French
|
||||
const header = page.locator('header').first();
|
||||
await header.getByRole('combobox').getByText('English').click();
|
||||
await header.getByRole('option', { name: 'Français' }).click();
|
||||
await waitForLanguageSwitch(page, TestLanguage.French);
|
||||
|
||||
// Check for French 404 response
|
||||
await check404Response('Pas trouvé.');
|
||||
});
|
||||
|
||||
test('it check translations of the slash menu when changing language', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
await createDoc(page, 'doc-toolbar', browserName, 1);
|
||||
|
||||
const header = page.locator('header').first();
|
||||
const editor = page.locator('.ProseMirror');
|
||||
// Trigger slash menu to show english menu
|
||||
await editor.click();
|
||||
await editor.fill('/');
|
||||
await expect(page.getByText('Headings', { exact: true })).toBeVisible();
|
||||
await header.click();
|
||||
await expect(page.getByText('Headings', { exact: true })).toBeHidden();
|
||||
|
||||
// Reset menu
|
||||
await editor.click();
|
||||
await editor.fill('');
|
||||
|
||||
// Change language to French
|
||||
await waitForLanguageSwitch(page, TestLanguage.French);
|
||||
|
||||
// Trigger slash menu to show french menu
|
||||
await editor.click();
|
||||
await editor.fill('/');
|
||||
await expect(page.getByText('Titres', { exact: true })).toBeVisible();
|
||||
await header.click();
|
||||
await expect(page.getByText('Titres', { exact: true })).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
// language helper
|
||||
export const TestLanguage = {
|
||||
English: {
|
||||
label: 'English',
|
||||
expectedLocale: ['en-us'],
|
||||
},
|
||||
French: {
|
||||
label: 'Français',
|
||||
expectedLocale: ['fr-fr'],
|
||||
},
|
||||
German: {
|
||||
label: 'Deutsch',
|
||||
expectedLocale: ['de-de'],
|
||||
},
|
||||
} as const;
|
||||
|
||||
type TestLanguageKey = keyof typeof TestLanguage;
|
||||
type TestLanguageValue = (typeof TestLanguage)[TestLanguageKey];
|
||||
|
||||
export async function waitForLanguageSwitch(
|
||||
page: Page,
|
||||
lang: TestLanguageValue,
|
||||
) {
|
||||
const header = page.locator('header').first();
|
||||
await header.getByRole('button', { name: 'arrow_drop_down' }).click();
|
||||
|
||||
const responsePromise = page.waitForResponse(
|
||||
(resp) =>
|
||||
resp.url().includes('/user') && resp.request().method() === 'PATCH',
|
||||
);
|
||||
await page.getByLabel(lang.label).click();
|
||||
const resolvedResponsePromise = await responsePromise;
|
||||
const responseData = await resolvedResponsePromise.json();
|
||||
|
||||
expect(lang.expectedLocale).toContain(responseData.language);
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ test.describe('Left panel mobile', () => {
|
||||
const header = page.locator('header').first();
|
||||
const homeButton = page.getByRole('button', { name: 'house' });
|
||||
const newDocButton = page.getByRole('button', { name: 'New doc' });
|
||||
const languageButton = page.getByRole('combobox', { name: 'Language' });
|
||||
const languageButton = page.getByRole('button', { name: /Language/ });
|
||||
const logoutButton = page.getByRole('button', { name: 'Logout' });
|
||||
|
||||
await expect(homeButton).not.toBeInViewport();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-e2e",
|
||||
"version": "2.2.0",
|
||||
"version": "2.4.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"lint": "eslint . --ext .ts",
|
||||
|
||||
@@ -38,10 +38,9 @@ export default defineConfig({
|
||||
timeout: 120 * 1000,
|
||||
reuseExistingServer: true,
|
||||
},
|
||||
|
||||
globalSetup: require.resolve('./__tests__/app-impress/auth.setup'),
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
|
||||
{
|
||||
name: 'chromium',
|
||||
use: {
|
||||
@@ -53,7 +52,6 @@ export default defineConfig({
|
||||
permissions: ['clipboard-read', 'clipboard-write'],
|
||||
},
|
||||
},
|
||||
dependencies: ['setup'],
|
||||
},
|
||||
{
|
||||
name: 'webkit',
|
||||
@@ -63,7 +61,6 @@ export default defineConfig({
|
||||
timezoneId: 'Europe/Paris',
|
||||
storageState: 'playwright/.auth/user-webkit.json',
|
||||
},
|
||||
dependencies: ['setup'],
|
||||
},
|
||||
{
|
||||
name: 'firefox',
|
||||
@@ -79,7 +76,6 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
},
|
||||
dependencies: ['setup'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -357,6 +357,15 @@ const config = {
|
||||
components: {
|
||||
alert: {
|
||||
'border-radius': '0',
|
||||
error: {
|
||||
'background-color': 'var(--c--theme--colors--danger-100)',
|
||||
'border-left-color': 'var(--c--theme--colors--danger-400)',
|
||||
close: {
|
||||
color: 'white',
|
||||
'background-color': 'var(--c--theme--colors--danger-400)',
|
||||
'background-color-hover': 'var(--c--theme--colors--danger-600)',
|
||||
},
|
||||
},
|
||||
},
|
||||
modal: {
|
||||
'width-small': '342px',
|
||||
@@ -381,6 +390,7 @@ const config = {
|
||||
'color-active': 'var(--c--theme--colors--primary-100)',
|
||||
},
|
||||
'color-hover': 'var(--c--theme--colors--primary-text)',
|
||||
color: 'var(--c--theme--colors--primary-800)',
|
||||
},
|
||||
secondary: {
|
||||
background: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-impress",
|
||||
"version": "2.2.0",
|
||||
"version": "2.4.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -15,11 +15,12 @@
|
||||
"test:watch": "jest --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@blocknote/core": "0.23.2",
|
||||
"@blocknote/mantine": "0.23.2",
|
||||
"@blocknote/react": "0.23.2",
|
||||
"@blocknote/xl-docx-exporter": "0.23.2",
|
||||
"@blocknote/xl-pdf-exporter": "0.23.2",
|
||||
"@ag-media/react-pdf-table": "2.0.1",
|
||||
"@blocknote/core": "*",
|
||||
"@blocknote/mantine": "*",
|
||||
"@blocknote/react": "*",
|
||||
"@blocknote/xl-docx-exporter": "*",
|
||||
"@blocknote/xl-pdf-exporter": "*",
|
||||
"@gouvfr-lasuite/integration": "1.0.2",
|
||||
"@hocuspocus/provider": "2.15.2",
|
||||
"@openfun/cunningham-react": "2.9.4",
|
||||
|
||||
BIN
src/frontend/apps/impress/src/assets/icons/icon-401.png
Normal file
BIN
src/frontend/apps/impress/src/assets/icons/icon-401.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 258 KiB |
BIN
src/frontend/apps/impress/src/assets/icons/icon-403.png
Normal file
BIN
src/frontend/apps/impress/src/assets/icons/icon-403.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 267 KiB |
@@ -1,10 +1,4 @@
|
||||
<svg
|
||||
width="32"
|
||||
height="33"
|
||||
viewBox="0 0 32 33"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<svg viewBox="0 0 32 33" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M21.6305 29.5812C22.7983 29.2538 23.9166 28.6562 24.6505 27.6003C25.3749 26.5663 25.5789 25.2547 25.5789 23.9925V5.50099C25.5789 5.17358 25.5611 4.84557 25.5216 4.52148C26.1016 4.74961 26.5486 5.12658 26.8626 5.65239C27.2331 6.25024 27.4184 7.03757 27.4184 8.01435V26.7964C27.4184 28.1184 27.0942 29.1078 26.4458 29.7646C25.7974 30.4214 24.8207 30.7498 23.5155 30.7498H16.4209C16.5889 30.7204 16.7574 30.6901 16.9262 30.659C18.4067 30.3944 19.9713 30.0354 21.6185 29.5846L21.6305 29.5812Z"
|
||||
fill="#C9191E"
|
||||
|
||||
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.6 KiB |
@@ -8,17 +8,20 @@ import {
|
||||
import { Button, Popover } from 'react-aria-components';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { BoxProps } from './Box';
|
||||
|
||||
const StyledPopover = styled(Popover)`
|
||||
background-color: white;
|
||||
border-radius: 4px;
|
||||
box-shadow: 1px 1px 5px rgba(0, 0, 0, 0.1);
|
||||
|
||||
border: 1px solid #dddddd;
|
||||
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
`;
|
||||
|
||||
const StyledButton = styled(Button)`
|
||||
interface StyledButtonProps {
|
||||
$css?: BoxProps['$css'];
|
||||
}
|
||||
const StyledButton = styled(Button)<StyledButtonProps>`
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: none;
|
||||
@@ -29,10 +32,12 @@ const StyledButton = styled(Button)`
|
||||
font-size: 0.938rem;
|
||||
padding: 0;
|
||||
text-wrap: nowrap;
|
||||
${({ $css }) => $css};
|
||||
`;
|
||||
|
||||
export interface DropButtonProps {
|
||||
button: ReactNode;
|
||||
buttonCss?: BoxProps['$css'];
|
||||
isOpen?: boolean;
|
||||
onOpenChange?: (isOpen: boolean) => void;
|
||||
label?: string;
|
||||
@@ -40,6 +45,7 @@ export interface DropButtonProps {
|
||||
|
||||
export const DropButton = ({
|
||||
button,
|
||||
buttonCss,
|
||||
isOpen = false,
|
||||
onOpenChange,
|
||||
children,
|
||||
@@ -64,6 +70,7 @@ export const DropButton = ({
|
||||
ref={triggerRef}
|
||||
onPress={() => onOpenChangeHandler(true)}
|
||||
aria-label={label}
|
||||
$css={buttonCss}
|
||||
>
|
||||
{button}
|
||||
</StyledButton>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PropsWithChildren, useState } from 'react';
|
||||
import { PropsWithChildren, useRef, useState } from 'react';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { Box, BoxButton, BoxProps, DropButton, Icon, Text } from '@/components';
|
||||
@@ -20,6 +20,7 @@ export type DropdownMenuProps = {
|
||||
showArrow?: boolean;
|
||||
label?: string;
|
||||
arrowCss?: BoxProps['$css'];
|
||||
buttonCss?: BoxProps['$css'];
|
||||
disabled?: boolean;
|
||||
topMessage?: string;
|
||||
};
|
||||
@@ -30,6 +31,7 @@ export const DropdownMenu = ({
|
||||
disabled = false,
|
||||
showArrow = false,
|
||||
arrowCss,
|
||||
buttonCss,
|
||||
label,
|
||||
topMessage,
|
||||
}: PropsWithChildren<DropdownMenuProps>) => {
|
||||
@@ -37,6 +39,7 @@ export const DropdownMenu = ({
|
||||
const spacings = theme.spacingsTokens();
|
||||
const colors = theme.colorsTokens();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const blockButtonRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const onOpenChange = (isOpen: boolean) => {
|
||||
setIsOpen(isOpen);
|
||||
@@ -51,10 +54,17 @@ export const DropdownMenu = ({
|
||||
isOpen={isOpen}
|
||||
onOpenChange={onOpenChange}
|
||||
label={label}
|
||||
buttonCss={buttonCss}
|
||||
button={
|
||||
showArrow ? (
|
||||
<Box $direction="row" $align="center">
|
||||
<div>{children}</div>
|
||||
<Box
|
||||
ref={blockButtonRef}
|
||||
$direction="row"
|
||||
$align="center"
|
||||
$position="relative"
|
||||
aria-controls="menu"
|
||||
>
|
||||
<Box>{children}</Box>
|
||||
<Icon
|
||||
$variation="600"
|
||||
$css={
|
||||
@@ -67,11 +77,17 @@ export const DropdownMenu = ({
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
children
|
||||
<Box ref={blockButtonRef} aria-controls="menu">
|
||||
{children}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
>
|
||||
<Box $maxWidth="320px">
|
||||
<Box
|
||||
$maxWidth="320px"
|
||||
$minWidth={`${blockButtonRef.current?.clientWidth}px`}
|
||||
role="menu"
|
||||
>
|
||||
{topMessage && (
|
||||
<Text
|
||||
$variation="700"
|
||||
@@ -90,6 +106,7 @@ export const DropdownMenu = ({
|
||||
const isDisabled = option.disabled !== undefined && option.disabled;
|
||||
return (
|
||||
<BoxButton
|
||||
role="menuitem"
|
||||
aria-label={option.label}
|
||||
data-testid={option.testId}
|
||||
$direction="row"
|
||||
|
||||
@@ -25,7 +25,9 @@ export interface TextProps extends BoxProps {
|
||||
$size?: TextSizes | (string & {});
|
||||
$theme?:
|
||||
| 'primary'
|
||||
| 'primary-text'
|
||||
| 'secondary'
|
||||
| 'secondary-text'
|
||||
| 'info'
|
||||
| 'success'
|
||||
| 'warning'
|
||||
|
||||
@@ -35,6 +35,7 @@ export const TextErrors = ({
|
||||
<Text
|
||||
key={`causes-${i}`}
|
||||
$theme="danger"
|
||||
$variation="600"
|
||||
$textAlign="center"
|
||||
{...textProps}
|
||||
>
|
||||
@@ -43,7 +44,12 @@ export const TextErrors = ({
|
||||
))}
|
||||
|
||||
{!causes && (
|
||||
<Text $theme="danger" $textAlign="center" {...textProps}>
|
||||
<Text
|
||||
$theme="danger"
|
||||
$variation="600"
|
||||
$textAlign="center"
|
||||
{...textProps}
|
||||
>
|
||||
{defaultMessage || t('Something bad happens, please retry.')}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useEffect } from 'react';
|
||||
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { Auth } from '@/features/auth';
|
||||
import '@/i18n/initI18n';
|
||||
import { useResponsiveStore } from '@/stores/';
|
||||
|
||||
import { ConfigProvider } from './config/';
|
||||
|
||||
@@ -3,7 +3,8 @@ import { PropsWithChildren, useEffect } from 'react';
|
||||
|
||||
import { Box } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { PostHogProvider, configureCrispSession } from '@/services';
|
||||
import { useLanguageSynchronizer } from '@/features/language/hooks/useLanguageSynchronizer';
|
||||
import { CrispProvider, PostHogProvider } from '@/services';
|
||||
import { useSentryStore } from '@/stores/useSentryStore';
|
||||
|
||||
import { useConfig } from './api/useConfig';
|
||||
@@ -12,6 +13,7 @@ export const ConfigProvider = ({ children }: PropsWithChildren) => {
|
||||
const { data: conf } = useConfig();
|
||||
const { setSentry } = useSentryStore();
|
||||
const { setTheme } = useCunninghamTheme();
|
||||
const { synchronizeLanguage } = useLanguageSynchronizer();
|
||||
|
||||
useEffect(() => {
|
||||
if (!conf?.SENTRY_DSN) {
|
||||
@@ -30,12 +32,8 @@ export const ConfigProvider = ({ children }: PropsWithChildren) => {
|
||||
}, [conf?.FRONTEND_THEME, setTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!conf?.CRISP_WEBSITE_ID) {
|
||||
return;
|
||||
}
|
||||
|
||||
configureCrispSession(conf.CRISP_WEBSITE_ID);
|
||||
}, [conf?.CRISP_WEBSITE_ID]);
|
||||
void synchronizeLanguage();
|
||||
}, [synchronizeLanguage]);
|
||||
|
||||
if (!conf) {
|
||||
return (
|
||||
@@ -45,5 +43,11 @@ export const ConfigProvider = ({ children }: PropsWithChildren) => {
|
||||
);
|
||||
}
|
||||
|
||||
return <PostHogProvider conf={conf.POSTHOG_KEY}>{children}</PostHogProvider>;
|
||||
return (
|
||||
<PostHogProvider conf={conf.POSTHOG_KEY}>
|
||||
<CrispProvider websiteId={conf?.CRISP_WEBSITE_ID}>
|
||||
{children}
|
||||
</CrispProvider>
|
||||
</PostHogProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -310,7 +310,7 @@ input:-webkit-autofill:focus {
|
||||
}
|
||||
|
||||
/**
|
||||
* Others
|
||||
* Checkbox
|
||||
*/
|
||||
.c__checkbox:focus-within {
|
||||
border-color: transparent;
|
||||
@@ -365,7 +365,8 @@ input:-webkit-autofill:focus {
|
||||
}
|
||||
|
||||
.c__button--medium {
|
||||
padding: 0.9rem var(--c--theme--spacings--s);
|
||||
height: auto;
|
||||
min-height: var(--c--components--button--medium-height);
|
||||
}
|
||||
|
||||
.c__button--small {
|
||||
@@ -406,6 +407,10 @@ input:-webkit-autofill:focus {
|
||||
);
|
||||
}
|
||||
|
||||
.c__button--primary-text {
|
||||
color: var(--c--components--button--primary-text--color);
|
||||
}
|
||||
|
||||
.c__button--primary-text:hover,
|
||||
.c__button--primary-text:focus-visible {
|
||||
background-color: var(
|
||||
@@ -547,6 +552,8 @@ input:-webkit-autofill:focus {
|
||||
|
||||
.c__modal__close .c__button {
|
||||
padding: 0 !important;
|
||||
top: -0.65rem;
|
||||
right: -0.65rem;
|
||||
}
|
||||
|
||||
.c__modal--full .c__modal__content {
|
||||
@@ -605,3 +612,22 @@ input:-webkit-autofill:focus {
|
||||
.c__tooltip {
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alert
|
||||
*/
|
||||
.c__alert--error {
|
||||
background-color: var(--c--components--alert--error--background-color);
|
||||
border-left-color: var(--c--components--alert--error--border-left-color);
|
||||
}
|
||||
|
||||
.c__alert--error .c__button--tertiary {
|
||||
background-color: var(--c--components--alert--error--close--background-color);
|
||||
color: var(--c--components--alert--error--close--color);
|
||||
}
|
||||
|
||||
.c__alert.c__alert--error .c__button--tertiary:hover {
|
||||
background-color: var(
|
||||
--c--components--alert--error--close--background-color-hover
|
||||
);
|
||||
}
|
||||
|
||||
@@ -484,6 +484,19 @@
|
||||
--c--theme--logo--widthFooter: 220px;
|
||||
--c--theme--logo--alt: gouvernement logo;
|
||||
--c--components--alert--border-radius: 0;
|
||||
--c--components--alert--error--background-color: var(
|
||||
--c--theme--colors--danger-100
|
||||
);
|
||||
--c--components--alert--error--border-left-color: var(
|
||||
--c--theme--colors--danger-400
|
||||
);
|
||||
--c--components--alert--error--close--color: white;
|
||||
--c--components--alert--error--close--background-color: var(
|
||||
--c--theme--colors--danger-400
|
||||
);
|
||||
--c--components--alert--error--close--background-color-hover: var(
|
||||
--c--theme--colors--danger-600
|
||||
);
|
||||
--c--components--modal--width-small: 342px;
|
||||
--c--components--button--medium-height: 40px;
|
||||
--c--components--button--medium-text-height: 40px;
|
||||
@@ -505,6 +518,9 @@
|
||||
--c--components--button--primary-text--color-hover: var(
|
||||
--c--theme--colors--primary-text
|
||||
);
|
||||
--c--components--button--primary-text--color: var(
|
||||
--c--theme--colors--primary-800
|
||||
);
|
||||
--c--components--button--secondary--background--color-hover: #f6f6f6;
|
||||
--c--components--button--secondary--background--color-active: #ededed;
|
||||
--c--components--button--secondary--border--color: var(
|
||||
|
||||
@@ -483,7 +483,18 @@ export const tokens = {
|
||||
},
|
||||
},
|
||||
components: {
|
||||
alert: { 'border-radius': '0' },
|
||||
alert: {
|
||||
'border-radius': '0',
|
||||
error: {
|
||||
'background-color': 'var(--c--theme--colors--danger-100)',
|
||||
'border-left-color': 'var(--c--theme--colors--danger-400)',
|
||||
close: {
|
||||
color: 'white',
|
||||
'background-color': 'var(--c--theme--colors--danger-400)',
|
||||
'background-color-hover': 'var(--c--theme--colors--danger-600)',
|
||||
},
|
||||
},
|
||||
},
|
||||
modal: { 'width-small': '342px' },
|
||||
button: {
|
||||
'medium-height': '40px',
|
||||
@@ -505,6 +516,7 @@ export const tokens = {
|
||||
'color-active': 'var(--c--theme--colors--primary-100)',
|
||||
},
|
||||
'color-hover': 'var(--c--theme--colors--primary-text)',
|
||||
color: 'var(--c--theme--colors--primary-800)',
|
||||
},
|
||||
secondary: {
|
||||
background: { 'color-hover': '#F6F6F6', 'color-active': '#EDEDED' },
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
* @property {string} id - The id of the user.
|
||||
* @property {string} email - The email of the user.
|
||||
* @property {string} name - The name of the user.
|
||||
* @property {string} language - The language of the user. e.g. 'en-us', 'fr-fr', 'de-de'.
|
||||
*/
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
full_name: string;
|
||||
short_name: string;
|
||||
language: string;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { APIError, fetchAPI } from '@/api';
|
||||
import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||
|
||||
import { User } from './types';
|
||||
|
||||
@@ -17,7 +17,10 @@ import { User } from './types';
|
||||
export const getMe = async (): Promise<User> => {
|
||||
const response = await fetchAPI(`users/me/`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Couldn't fetch user data: ${response.statusText}`);
|
||||
throw new APIError(
|
||||
`Couldn't fetch user data: ${response.statusText}`,
|
||||
await errorCauses(response),
|
||||
);
|
||||
}
|
||||
return response.json() as Promise<User>;
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import { PropsWithChildren } from 'react';
|
||||
import { Box } from '@/components';
|
||||
|
||||
import { useAuth } from '../hooks';
|
||||
import { getAuthUrl } from '../utils';
|
||||
|
||||
export const Auth = ({ children }: PropsWithChildren) => {
|
||||
const { isLoading, pathAllowed, isFetchedAfterMount, authenticated } =
|
||||
@@ -19,6 +20,22 @@ export const Auth = ({ children }: PropsWithChildren) => {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* If the user is authenticated and wanted initially to access a document,
|
||||
* we redirect to the document page.
|
||||
*/
|
||||
if (authenticated) {
|
||||
const authUrl = getAuthUrl();
|
||||
if (authUrl) {
|
||||
void replace(authUrl);
|
||||
return (
|
||||
<Box $height="100vh" $width="100vw" $align="center" $justify="center">
|
||||
<Loader />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If the user is not authenticated and the path is not allowed, we redirect to the login page.
|
||||
*/
|
||||
|
||||
@@ -14,7 +14,11 @@ export const ButtonLogin = () => {
|
||||
|
||||
if (!authenticated) {
|
||||
return (
|
||||
<Button onClick={gotoLogin} color="primary-text" aria-label={t('Login')}>
|
||||
<Button
|
||||
onClick={() => gotoLogin()}
|
||||
color="primary-text"
|
||||
aria-label={t('Login')}
|
||||
>
|
||||
{t('Login')}
|
||||
</Button>
|
||||
);
|
||||
@@ -32,7 +36,7 @@ export const ProConnectButton = () => {
|
||||
|
||||
return (
|
||||
<BoxButton
|
||||
onClick={gotoLogin}
|
||||
onClick={() => gotoLogin()}
|
||||
aria-label={t('Proconnect Login')}
|
||||
$css={css`
|
||||
background-color: var(--c--theme--colors--primary-text);
|
||||
|
||||
@@ -2,13 +2,12 @@ import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useAuthQuery } from '../api';
|
||||
import { getAuthUrl } from '../utils';
|
||||
|
||||
const regexpUrlsAuth = [/\/docs\/$/g, /\/docs$/g, /^\/$/g];
|
||||
|
||||
export const useAuth = () => {
|
||||
const { data: user, ...authStates } = useAuthQuery();
|
||||
const { pathname, replace } = useRouter();
|
||||
const { pathname } = useRouter();
|
||||
|
||||
const [pathAllowed, setPathAllowed] = useState<boolean>(
|
||||
!regexpUrlsAuth.some((regexp) => !!pathname.match(regexp)),
|
||||
@@ -18,17 +17,10 @@ export const useAuth = () => {
|
||||
setPathAllowed(!regexpUrlsAuth.some((regexp) => !!pathname.match(regexp)));
|
||||
}, [pathname]);
|
||||
|
||||
// Redirect to the path before login
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
const authUrl = getAuthUrl();
|
||||
if (authUrl) {
|
||||
void replace(authUrl);
|
||||
}
|
||||
}, [user, replace]);
|
||||
|
||||
return { user, authenticated: !!user, pathAllowed, ...authStates };
|
||||
return {
|
||||
user,
|
||||
authenticated: !!user && authStates.isSuccess,
|
||||
pathAllowed,
|
||||
...authStates,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export * from './api/types';
|
||||
export * from './api';
|
||||
export * from './components';
|
||||
export * from './hooks';
|
||||
export * from './utils';
|
||||
|
||||
@@ -16,8 +16,11 @@ export const setAuthUrl = () => {
|
||||
}
|
||||
};
|
||||
|
||||
export const gotoLogin = () => {
|
||||
setAuthUrl();
|
||||
export const gotoLogin = (withRedirect = true) => {
|
||||
if (withRedirect) {
|
||||
setAuthUrl();
|
||||
}
|
||||
|
||||
window.location.replace(LOGIN_URL);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
BlockNoteSchema,
|
||||
Dictionary,
|
||||
defaultBlockSpecs,
|
||||
locales,
|
||||
withPageBreak,
|
||||
} from '@blocknote/core';
|
||||
@@ -25,9 +26,17 @@ import { cssEditor } from '../styles';
|
||||
import { randomColor } from '../utils';
|
||||
|
||||
import { BlockNoteSuggestionMenu } from './BlockNoteSuggestionMenu';
|
||||
import { BlockNoteToolbar } from './BlockNoteToolbar';
|
||||
import { BlockNoteToolbar } from './BlockNoteToolBar/BlockNoteToolbar';
|
||||
import { QuoteBlock } from './custom-blocks';
|
||||
|
||||
export const blockNoteSchema = withPageBreak(BlockNoteSchema.create());
|
||||
export const blockNoteSchema = withPageBreak(
|
||||
BlockNoteSchema.create({
|
||||
blockSpecs: {
|
||||
...defaultBlockSpecs,
|
||||
quote: QuoteBlock,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
interface BlockNoteEditorProps {
|
||||
doc: Doc;
|
||||
@@ -42,7 +51,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
|
||||
const readOnly = !doc.abilities.partial_update;
|
||||
useSaveDoc(doc.id, provider.document, !readOnly);
|
||||
const { i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const lang = i18n.resolvedLanguage;
|
||||
|
||||
const { uploadFile, errorAttachment } = useUploadFile(doc.id);
|
||||
|
||||
@@ -125,7 +134,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
|
||||
$css={cssEditor(readOnly)}
|
||||
>
|
||||
{errorAttachment && (
|
||||
<Box $margin={{ bottom: 'big' }}>
|
||||
<Box $margin={{ bottom: 'big', top: 'none', horizontal: 'large' }}>
|
||||
<TextErrors
|
||||
causes={errorAttachment.cause}
|
||||
canClose
|
||||
@@ -141,8 +150,8 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
|
||||
editable={!readOnly}
|
||||
theme="light"
|
||||
>
|
||||
<BlockNoteToolbar />
|
||||
<BlockNoteSuggestionMenu />
|
||||
<BlockNoteToolbar />
|
||||
</BlockNoteView>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -5,13 +5,19 @@ import {
|
||||
getDefaultReactSlashMenuItems,
|
||||
getPageBreakReactSlashMenuItems,
|
||||
useBlockNoteEditor,
|
||||
useDictionary,
|
||||
} from '@blocknote/react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { DocsBlockNoteEditor } from '../types';
|
||||
import { DocsBlockSchema } from '../types';
|
||||
|
||||
import { getQuoteReactSlashMenuItems } from './custom-blocks';
|
||||
|
||||
export const BlockNoteSuggestionMenu = () => {
|
||||
const editor = useBlockNoteEditor() as DocsBlockNoteEditor;
|
||||
const editor = useBlockNoteEditor<DocsBlockSchema>();
|
||||
const { t } = useTranslation();
|
||||
const basicBlocksName = useDictionary().slash_menu.page_break.group;
|
||||
|
||||
const getSlashMenuItems = useMemo(() => {
|
||||
return async (query: string) =>
|
||||
@@ -20,11 +26,12 @@ export const BlockNoteSuggestionMenu = () => {
|
||||
combineByGroup(
|
||||
getDefaultReactSlashMenuItems(editor),
|
||||
getPageBreakReactSlashMenuItems(editor),
|
||||
getQuoteReactSlashMenuItems(editor, t, basicBlocksName),
|
||||
),
|
||||
query,
|
||||
),
|
||||
);
|
||||
}, [editor]);
|
||||
}, [basicBlocksName, editor, t]);
|
||||
|
||||
return (
|
||||
<SuggestionMenuController
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
AITransformActions,
|
||||
useDocAITransform,
|
||||
useDocAITranslate,
|
||||
} from '../api/';
|
||||
} from '../../api';
|
||||
|
||||
type LanguageTranslate = {
|
||||
value: string;
|
||||
@@ -0,0 +1,75 @@
|
||||
import '@blocknote/mantine/style.css';
|
||||
import {
|
||||
FormattingToolbar,
|
||||
FormattingToolbarController,
|
||||
blockTypeSelectItems,
|
||||
getFormattingToolbarItems,
|
||||
useDictionary,
|
||||
} from '@blocknote/react';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { getQuoteFormattingToolbarItems } from '../custom-blocks';
|
||||
|
||||
import { AIGroupButton } from './AIButton';
|
||||
import { FileDownloadButton } from './FileDownloadButton';
|
||||
import { MarkdownButton } from './MarkdownButton';
|
||||
import { ModalConfirmDownloadUnsafe } from './ModalConfirmDownloadUnsafe';
|
||||
|
||||
export const BlockNoteToolbar = () => {
|
||||
const dict = useDictionary();
|
||||
const [confirmOpen, setIsConfirmOpen] = useState(false);
|
||||
const [onConfirm, setOnConfirm] = useState<() => void | Promise<void>>();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const toolbarItems = useMemo(() => {
|
||||
const toolbarItems = getFormattingToolbarItems([
|
||||
...blockTypeSelectItems(dict),
|
||||
getQuoteFormattingToolbarItems(t),
|
||||
]);
|
||||
const fileDownloadButtonIndex = toolbarItems.findIndex(
|
||||
(item) => item.key === 'fileDownloadButton',
|
||||
);
|
||||
if (fileDownloadButtonIndex !== -1) {
|
||||
toolbarItems.splice(
|
||||
fileDownloadButtonIndex,
|
||||
1,
|
||||
<FileDownloadButton
|
||||
key="fileDownloadButton"
|
||||
open={(onConfirm) => {
|
||||
setIsConfirmOpen(true);
|
||||
setOnConfirm(() => onConfirm);
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
return toolbarItems;
|
||||
}, [dict, t]);
|
||||
|
||||
const formattingToolbar = useCallback(() => {
|
||||
return (
|
||||
<FormattingToolbar>
|
||||
{toolbarItems}
|
||||
|
||||
{/* Extra button to do some AI powered actions */}
|
||||
<AIGroupButton key="AIButton" />
|
||||
|
||||
{/* Extra button to convert from markdown to json */}
|
||||
<MarkdownButton key="customButton" />
|
||||
</FormattingToolbar>
|
||||
);
|
||||
}, [toolbarItems]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormattingToolbarController formattingToolbar={formattingToolbar} />
|
||||
{confirmOpen && (
|
||||
<ModalConfirmDownloadUnsafe
|
||||
onClose={() => setIsConfirmOpen(false)}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
BlockSchema,
|
||||
InlineContentSchema,
|
||||
StyleSchema,
|
||||
checkBlockIsFileBlock,
|
||||
checkBlockIsFileBlockWithPlaceholder,
|
||||
} from '@blocknote/core';
|
||||
import {
|
||||
useBlockNoteEditor,
|
||||
useComponentsContext,
|
||||
useDictionary,
|
||||
useSelectedBlocks,
|
||||
} from '@blocknote/react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { RiDownload2Fill } from 'react-icons/ri';
|
||||
|
||||
import { downloadFile, exportResolveFileUrl } from '@/features/docs/doc-export';
|
||||
|
||||
export const FileDownloadButton = ({
|
||||
open,
|
||||
}: {
|
||||
open: (onConfirm: () => Promise<void> | void) => void;
|
||||
}) => {
|
||||
const dict = useDictionary();
|
||||
const Components = useComponentsContext();
|
||||
|
||||
const editor = useBlockNoteEditor<
|
||||
BlockSchema,
|
||||
InlineContentSchema,
|
||||
StyleSchema
|
||||
>();
|
||||
|
||||
const selectedBlocks = useSelectedBlocks(editor);
|
||||
|
||||
const fileBlock = useMemo(() => {
|
||||
// Checks if only one block is selected.
|
||||
if (selectedBlocks.length !== 1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const block = selectedBlocks[0];
|
||||
|
||||
if (checkBlockIsFileBlock(block, editor)) {
|
||||
return block;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [editor, selectedBlocks]);
|
||||
|
||||
const onClick = useCallback(async () => {
|
||||
if (fileBlock && fileBlock.props.url) {
|
||||
editor.focus();
|
||||
|
||||
const url = fileBlock.props.url as string;
|
||||
|
||||
/**
|
||||
* If not hosted on our domain, means not a file uploaded by the user,
|
||||
* we do what Blocknote was doing initially.
|
||||
*/
|
||||
if (!url.includes(window.location.hostname)) {
|
||||
if (!editor.resolveFileUrl) {
|
||||
window.open(url);
|
||||
} else {
|
||||
void editor
|
||||
.resolveFileUrl(url)
|
||||
.then((downloadUrl) => window.open(downloadUrl));
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!url.includes('-unsafe')) {
|
||||
const blob = (await exportResolveFileUrl(url, undefined)) as Blob;
|
||||
downloadFile(blob, url.split('/').pop() || 'file');
|
||||
} else {
|
||||
const onConfirm = async () => {
|
||||
const blob = (await exportResolveFileUrl(url, undefined)) as Blob;
|
||||
downloadFile(blob, url.split('/').pop() || 'file (unsafe)');
|
||||
};
|
||||
|
||||
open(onConfirm);
|
||||
}
|
||||
}
|
||||
}, [editor, fileBlock, open]);
|
||||
|
||||
if (
|
||||
!fileBlock ||
|
||||
checkBlockIsFileBlockWithPlaceholder(fileBlock, editor) ||
|
||||
!Components
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Components.FormattingToolbar.Button
|
||||
className="bn-button"
|
||||
label={
|
||||
dict.formatting_toolbar.file_download.tooltip[fileBlock.type] ||
|
||||
dict.formatting_toolbar.file_download.tooltip['file']
|
||||
}
|
||||
mainTooltip={
|
||||
dict.formatting_toolbar.file_download.tooltip[fileBlock.type] ||
|
||||
dict.formatting_toolbar.file_download.tooltip['file']
|
||||
}
|
||||
icon={<RiDownload2Fill />}
|
||||
onClick={() => void onClick()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Button, Modal, ModalSize } from '@openfun/cunningham-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box, Text } from '@/components';
|
||||
|
||||
interface ModalConfirmDownloadUnsafeProps {
|
||||
onClose: () => void;
|
||||
onConfirm?: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
export const ModalConfirmDownloadUnsafe = ({
|
||||
onConfirm,
|
||||
onClose,
|
||||
}: ModalConfirmDownloadUnsafeProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
closeOnClickOutside
|
||||
onClose={() => onClose()}
|
||||
rightActions={
|
||||
<>
|
||||
<Button
|
||||
aria-label={t('Close the modal')}
|
||||
color="secondary"
|
||||
onClick={() => onClose()}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
aria-label={t('Download')}
|
||||
color="danger"
|
||||
onClick={() => {
|
||||
console.log('onClick');
|
||||
if (onConfirm) {
|
||||
void onConfirm();
|
||||
}
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{t('Download anyway')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
size={ModalSize.SMALL}
|
||||
title={
|
||||
<Text
|
||||
$gap="0.7rem"
|
||||
$size="h6"
|
||||
$align="flex-start"
|
||||
$variation="1000"
|
||||
$direction="row"
|
||||
>
|
||||
<Text $isMaterialIcon $theme="warning">
|
||||
warning
|
||||
</Text>
|
||||
{t('Warning')}
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Box aria-label={t('Modal confirmation to download the attachment')}>
|
||||
<Box>
|
||||
<Box $direction="column" $gap="0.35rem" $margin={{ top: 'sm' }}>
|
||||
<Text $variation="700">{t('This file is flagged as unsafe.')}</Text>
|
||||
<Text $variation="600">
|
||||
{t('Please download it only if it comes from a trusted source.')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -1,30 +0,0 @@
|
||||
import '@blocknote/mantine/style.css';
|
||||
import {
|
||||
FormattingToolbar,
|
||||
FormattingToolbarController,
|
||||
FormattingToolbarProps,
|
||||
getFormattingToolbarItems,
|
||||
} from '@blocknote/react';
|
||||
import React, { useCallback } from 'react';
|
||||
|
||||
import { AIGroupButton } from './AIButton';
|
||||
import { MarkdownButton } from './MarkdownButton';
|
||||
|
||||
export const BlockNoteToolbar = () => {
|
||||
const formattingToolbar = useCallback(
|
||||
({ blockTypeSelectItems }: FormattingToolbarProps) => (
|
||||
<FormattingToolbar>
|
||||
{getFormattingToolbarItems(blockTypeSelectItems)}
|
||||
|
||||
{/* Extra button to do some AI powered actions */}
|
||||
<AIGroupButton key="AIButton" />
|
||||
|
||||
{/* Extra button to convert from markdown to json */}
|
||||
<MarkdownButton key="customButton" />
|
||||
</FormattingToolbar>
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
return <FormattingToolbarController formattingToolbar={formattingToolbar} />;
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
import { defaultProps, insertOrUpdateBlock } from '@blocknote/core';
|
||||
import { BlockTypeSelectItem, createReactBlockSpec } from '@blocknote/react';
|
||||
import { TFunction } from 'i18next';
|
||||
import React from 'react';
|
||||
|
||||
import { Text } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
|
||||
import { DocsBlockNoteEditor } from '../../types';
|
||||
|
||||
export const QuoteBlock = createReactBlockSpec(
|
||||
{
|
||||
type: 'quote',
|
||||
propSchema: {
|
||||
textAlignment: defaultProps.textAlignment,
|
||||
textColor: defaultProps.textColor,
|
||||
},
|
||||
content: 'inline',
|
||||
},
|
||||
{
|
||||
render: (props) => {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
|
||||
return (
|
||||
<Text
|
||||
className="inline-content"
|
||||
$margin="0 0 1rem 0"
|
||||
$padding="0.5rem 1rem"
|
||||
$variation="600"
|
||||
style={{
|
||||
borderLeft: `4px solid ${colorsTokens()['greyscale-300']}`,
|
||||
fontStyle: 'italic',
|
||||
flexGrow: 1,
|
||||
}}
|
||||
ref={props.contentRef}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export const getQuoteReactSlashMenuItems = (
|
||||
editor: DocsBlockNoteEditor,
|
||||
t: TFunction<'translation', undefined>,
|
||||
group: string,
|
||||
) => [
|
||||
{
|
||||
title: t('Quote'),
|
||||
onItemClick: () => {
|
||||
insertOrUpdateBlock(editor, {
|
||||
type: 'quote',
|
||||
});
|
||||
},
|
||||
aliases: ['quote', 'blockquote', 'citation'],
|
||||
group,
|
||||
icon: (
|
||||
<Text $isMaterialIcon $size="18px">
|
||||
format_quote
|
||||
</Text>
|
||||
),
|
||||
subtext: t('Add a quote block'),
|
||||
},
|
||||
];
|
||||
|
||||
export const getQuoteFormattingToolbarItems = (
|
||||
t: TFunction<'translation', undefined>,
|
||||
): BlockTypeSelectItem => ({
|
||||
name: t('Quote'),
|
||||
type: 'quote',
|
||||
icon: () => (
|
||||
<Text $isMaterialIcon $size="16px">
|
||||
format_quote
|
||||
</Text>
|
||||
),
|
||||
isSelected: (block) => block.type === 'quote',
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from './QuoteBlock';
|
||||
@@ -1 +1,2 @@
|
||||
export * from './DocEditor';
|
||||
export * from './custom-blocks/';
|
||||
|
||||
@@ -6,6 +6,14 @@ export const cssEditor = (readonly: boolean) => css`
|
||||
& .ProseMirror {
|
||||
height: 100%;
|
||||
|
||||
img.bn-visual-media[src*='-unsafe'] {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.bn-side-menu[data-block-type='quote'] {
|
||||
height: 46px;
|
||||
}
|
||||
|
||||
.collaboration-cursor-custom__base {
|
||||
position: relative;
|
||||
}
|
||||
@@ -25,6 +33,7 @@ export const cssEditor = (readonly: boolean) => css`
|
||||
user-select: none;
|
||||
position: absolute;
|
||||
top: -17px;
|
||||
left: 0px;
|
||||
padding: 0px 6px;
|
||||
border-radius: 0px;
|
||||
white-space: nowrap;
|
||||
@@ -75,13 +84,13 @@ export const cssEditor = (readonly: boolean) => css`
|
||||
|
||||
.bn-block-outer:not(:first-child) {
|
||||
&:has(h1) {
|
||||
padding-top: 32px;
|
||||
margin-top: 32px;
|
||||
}
|
||||
&:has(h2) {
|
||||
padding-top: 24px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
&:has(h3) {
|
||||
padding-top: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,9 +100,16 @@ export const cssEditor = (readonly: boolean) => css`
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@media screen and (width <= 768px) {
|
||||
& .bn-editor {
|
||||
padding-right: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (width <= 560px) {
|
||||
& .bn-editor {
|
||||
${readonly && `padding-left: 10px;`}
|
||||
padding-right: 10px;
|
||||
}
|
||||
.bn-side-menu[data-block-type='heading'][data-level='1'] {
|
||||
height: 46px;
|
||||
|
||||
@@ -17,8 +17,12 @@ export type HeadingBlock = {
|
||||
};
|
||||
};
|
||||
|
||||
export type DocsBlockSchema = typeof blockNoteSchema.blockSchema;
|
||||
export type DocsInlineContentSchema =
|
||||
typeof blockNoteSchema.inlineContentSchema;
|
||||
export type DocsStyleSchema = typeof blockNoteSchema.styleSchema;
|
||||
export type DocsBlockNoteEditor = BlockNoteEditor<
|
||||
typeof blockNoteSchema.blockSchema,
|
||||
typeof blockNoteSchema.inlineContentSchema,
|
||||
typeof blockNoteSchema.styleSchema
|
||||
DocsBlockSchema,
|
||||
DocsInlineContentSchema,
|
||||
DocsStyleSchema
|
||||
>;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user