mirror of
https://github.com/suitenumerique/docs.git
synced 2026-05-06 23:22:15 +02:00
Compare commits
36 Commits
accessibil
...
feature/ad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a981ad11f | ||
|
|
625258be76 | ||
|
|
8849470d05 | ||
|
|
a571f9bc6e | ||
|
|
8bc56f0000 | ||
|
|
68b185e49a | ||
|
|
910983ece7 | ||
|
|
9f4c0d2737 | ||
|
|
668e61019f | ||
|
|
cc7d4adf65 | ||
|
|
9bf0fa80cd | ||
|
|
80ee5b1356 | ||
|
|
13dd86e8d5 | ||
|
|
7f6e4cdeff | ||
|
|
a3fb229a4f | ||
|
|
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'
|
||||
|
||||
|
||||
47
CHANGELOG.md
47
CHANGELOG.md
@@ -6,9 +6,28 @@ 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]
|
||||
|
||||
## Added
|
||||
|
||||
- ✨(backend) limit link reach/role select options depending on ancestors #645
|
||||
- ✨(backend) add new "descendants" action to document API endpoint #645
|
||||
- ✨(backend) new "tree" action on document detail endpoint #645
|
||||
- ✨(backend) allow forcing page size within limits #645
|
||||
- 💄(frontend) add error pages #643
|
||||
|
||||
## 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
|
||||
|
||||
- ♻️(frontend) improve table pdf rendering
|
||||
- 🐛(backend) refactor to fix filtering on children and descendants views #645
|
||||
|
||||
## [2.2.0] - 2025-02-10
|
||||
|
||||
## Added
|
||||
@@ -28,7 +47,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 +62,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 +74,6 @@ and this project adheres to
|
||||
|
||||
- 🔥(backend) remove "content" field from list serializer # 516
|
||||
|
||||
|
||||
## [2.0.1] - 2025-01-17
|
||||
|
||||
## Fixed
|
||||
@@ -111,12 +128,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 +154,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 +194,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 +221,6 @@ and this project adheres to
|
||||
|
||||
- 🔥(helm) remove infra related codes #366
|
||||
|
||||
|
||||
## [1.6.0] - 2024-10-17
|
||||
|
||||
## Added
|
||||
@@ -232,7 +243,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 +277,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 +297,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 +321,6 @@ and this project adheres to
|
||||
|
||||
- 🔥(frontend) remove saving modal #213
|
||||
|
||||
|
||||
## [1.2.1] - 2024-08-23
|
||||
|
||||
## Changed
|
||||
@@ -321,7 +328,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 +351,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 +369,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 +407,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,7 +414,6 @@ 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
|
||||
[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
|
||||
|
||||
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
|
||||
|
||||
@@ -88,5 +88,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;
|
||||
}
|
||||
}
|
||||
|
||||
3853
package-lock.json
generated
3853
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"@blocknote/core": "^0.23.4",
|
||||
"next": "^15.1.7"
|
||||
}
|
||||
}
|
||||
@@ -12,15 +12,26 @@ class DocumentFilter(django_filters.FilterSet):
|
||||
Custom filter for filtering documents.
|
||||
"""
|
||||
|
||||
title = django_filters.CharFilter(
|
||||
field_name="title", lookup_expr="icontains", label=_("Title")
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = models.Document
|
||||
fields = ["title"]
|
||||
|
||||
|
||||
class ListDocumentFilter(DocumentFilter):
|
||||
"""
|
||||
Custom filter for filtering documents.
|
||||
"""
|
||||
|
||||
is_creator_me = django_filters.BooleanFilter(
|
||||
method="filter_is_creator_me", label=_("Creator is me")
|
||||
)
|
||||
is_favorite = django_filters.BooleanFilter(
|
||||
method="filter_is_favorite", label=_("Favorite")
|
||||
)
|
||||
title = django_filters.CharFilter(
|
||||
field_name="title", lookup_expr="icontains", label=_("Title")
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = models.Document
|
||||
|
||||
@@ -128,26 +128,14 @@ class TemplateAccessSerializer(BaseAccessSerializer):
|
||||
read_only_fields = ["id", "abilities"]
|
||||
|
||||
|
||||
class BaseResourceSerializer(serializers.ModelSerializer):
|
||||
"""Serialize documents."""
|
||||
|
||||
abilities = serializers.SerializerMethodField(read_only=True)
|
||||
accesses = TemplateAccessSerializer(many=True, read_only=True)
|
||||
|
||||
def get_abilities(self, document) -> dict:
|
||||
"""Return abilities of the logged-in user on the instance."""
|
||||
request = self.context.get("request")
|
||||
if request:
|
||||
return document.get_abilities(request.user)
|
||||
return {}
|
||||
|
||||
|
||||
class ListDocumentSerializer(BaseResourceSerializer):
|
||||
class ListDocumentSerializer(serializers.ModelSerializer):
|
||||
"""Serialize documents with limited fields for display in lists."""
|
||||
|
||||
is_favorite = serializers.BooleanField(read_only=True)
|
||||
nb_accesses = serializers.IntegerField(read_only=True)
|
||||
nb_accesses_ancestors = serializers.IntegerField(read_only=True)
|
||||
nb_accesses_direct = serializers.IntegerField(read_only=True)
|
||||
user_roles = serializers.SerializerMethodField(read_only=True)
|
||||
abilities = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.Document
|
||||
@@ -161,7 +149,8 @@ class ListDocumentSerializer(BaseResourceSerializer):
|
||||
"is_favorite",
|
||||
"link_role",
|
||||
"link_reach",
|
||||
"nb_accesses",
|
||||
"nb_accesses_ancestors",
|
||||
"nb_accesses_direct",
|
||||
"numchild",
|
||||
"path",
|
||||
"title",
|
||||
@@ -178,13 +167,30 @@ class ListDocumentSerializer(BaseResourceSerializer):
|
||||
"is_favorite",
|
||||
"link_role",
|
||||
"link_reach",
|
||||
"nb_accesses",
|
||||
"nb_accesses_ancestors",
|
||||
"nb_accesses_direct",
|
||||
"numchild",
|
||||
"path",
|
||||
"updated_at",
|
||||
"user_roles",
|
||||
]
|
||||
|
||||
def get_abilities(self, document) -> dict:
|
||||
"""Return abilities of the logged-in user on the instance."""
|
||||
request = self.context.get("request")
|
||||
|
||||
if request:
|
||||
paths_links_mapping = self.context.get("paths_links_mapping", None)
|
||||
# Retrieve ancestor links from paths_links_mapping (if provided)
|
||||
ancestors_links = (
|
||||
paths_links_mapping.get(document.path[: -document.steplen])
|
||||
if paths_links_mapping
|
||||
else None
|
||||
)
|
||||
return document.get_abilities(request.user, ancestors_links=ancestors_links)
|
||||
|
||||
return {}
|
||||
|
||||
def get_user_roles(self, document):
|
||||
"""
|
||||
Return roles of the logged-in user for the current document,
|
||||
@@ -214,7 +220,8 @@ class DocumentSerializer(ListDocumentSerializer):
|
||||
"is_favorite",
|
||||
"link_role",
|
||||
"link_reach",
|
||||
"nb_accesses",
|
||||
"nb_accesses_ancestors",
|
||||
"nb_accesses_direct",
|
||||
"numchild",
|
||||
"path",
|
||||
"title",
|
||||
@@ -230,7 +237,8 @@ class DocumentSerializer(ListDocumentSerializer):
|
||||
"is_favorite",
|
||||
"link_role",
|
||||
"link_reach",
|
||||
"nb_accesses",
|
||||
"nb_accesses_ancestors",
|
||||
"nb_accesses_direct",
|
||||
"numchild",
|
||||
"path",
|
||||
"updated_at",
|
||||
@@ -359,7 +367,7 @@ class ServerCreateDocumentSerializer(serializers.Serializer):
|
||||
raise NotImplementedError("Update is not supported for this serializer.")
|
||||
|
||||
|
||||
class LinkDocumentSerializer(BaseResourceSerializer):
|
||||
class LinkDocumentSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
Serialize link configuration for documents.
|
||||
We expose it separately from document in order to simplify and secure access control.
|
||||
@@ -429,9 +437,12 @@ class FileUploadSerializer(serializers.Serializer):
|
||||
return attrs
|
||||
|
||||
|
||||
class TemplateSerializer(BaseResourceSerializer):
|
||||
class TemplateSerializer(serializers.ModelSerializer):
|
||||
"""Serialize templates."""
|
||||
|
||||
abilities = serializers.SerializerMethodField(read_only=True)
|
||||
accesses = TemplateAccessSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.Template
|
||||
fields = [
|
||||
@@ -445,6 +456,13 @@ class TemplateSerializer(BaseResourceSerializer):
|
||||
]
|
||||
read_only_fields = ["id", "accesses", "abilities"]
|
||||
|
||||
def get_abilities(self, document) -> dict:
|
||||
"""Return abilities of the logged-in user on the instance."""
|
||||
request = self.context.get("request")
|
||||
if request:
|
||||
return document.get_abilities(request.user)
|
||||
return {}
|
||||
|
||||
|
||||
# pylint: disable=abstract-method
|
||||
class DocumentGenerationSerializer(serializers.Serializer):
|
||||
|
||||
@@ -11,6 +11,35 @@ import botocore
|
||||
from rest_framework.throttling import BaseThrottle
|
||||
|
||||
|
||||
def nest_tree(flat_list, steplen):
|
||||
"""
|
||||
Convert a flat list of serialized documents into a nested tree making advantage
|
||||
of the`path` field and its step length.
|
||||
"""
|
||||
node_dict = {}
|
||||
roots = []
|
||||
|
||||
# Sort the flat list by path to ensure parent nodes are processed first
|
||||
flat_list.sort(key=lambda x: x["path"])
|
||||
|
||||
for node in flat_list:
|
||||
node["children"] = [] # Initialize children list
|
||||
node_dict[node["path"]] = node
|
||||
|
||||
# Determine parent path
|
||||
parent_path = node["path"][:-steplen]
|
||||
|
||||
if parent_path in node_dict:
|
||||
node_dict[parent_path]["children"].append(node)
|
||||
else:
|
||||
roots.append(node) # Collect root nodes
|
||||
|
||||
if len(roots) > 1:
|
||||
raise ValueError("More than one root element detected.")
|
||||
|
||||
return roots[0] if roots else None
|
||||
|
||||
|
||||
def filter_root_paths(paths, skip_sorting=False):
|
||||
"""
|
||||
Filters root paths from a list of paths representing a tree structure.
|
||||
|
||||
@@ -20,7 +20,6 @@ from django.http import Http404
|
||||
|
||||
import rest_framework as drf
|
||||
from botocore.exceptions import ClientError
|
||||
from django_filters import rest_framework as drf_filters
|
||||
from rest_framework import filters, status, viewsets
|
||||
from rest_framework import response as drf_response
|
||||
from rest_framework.permissions import AllowAny
|
||||
@@ -30,7 +29,7 @@ from core.services.ai_services import AIService
|
||||
from core.services.collaboration_services import CollaborationService
|
||||
|
||||
from . import permissions, serializers, utils
|
||||
from .filters import DocumentFilter
|
||||
from .filters import DocumentFilter, ListDocumentFilter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -315,7 +314,6 @@ class DocumentViewSet(
|
||||
SerializerPerActionMixin,
|
||||
drf.mixins.CreateModelMixin,
|
||||
drf.mixins.DestroyModelMixin,
|
||||
drf.mixins.ListModelMixin,
|
||||
drf.mixins.UpdateModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
@@ -413,20 +411,21 @@ class DocumentViewSet(
|
||||
- Implements soft delete logic to retain document tree structures.
|
||||
"""
|
||||
|
||||
filter_backends = [drf_filters.DjangoFilterBackend]
|
||||
filterset_class = DocumentFilter
|
||||
metadata_class = DocumentMetadata
|
||||
ordering = ["-updated_at"]
|
||||
ordering_fields = ["created_at", "updated_at", "title"]
|
||||
pagination_class = Pagination
|
||||
permission_classes = [
|
||||
permissions.DocumentAccessPermission,
|
||||
]
|
||||
queryset = models.Document.objects.all()
|
||||
serializer_class = serializers.DocumentSerializer
|
||||
ai_translate_serializer_class = serializers.AITranslateSerializer
|
||||
children_serializer_class = serializers.ListDocumentSerializer
|
||||
descendants_serializer_class = serializers.ListDocumentSerializer
|
||||
list_serializer_class = serializers.ListDocumentSerializer
|
||||
trashbin_serializer_class = serializers.ListDocumentSerializer
|
||||
children_serializer_class = serializers.ListDocumentSerializer
|
||||
ai_translate_serializer_class = serializers.AITranslateSerializer
|
||||
tree_serializer_class = serializers.ListDocumentSerializer
|
||||
|
||||
def annotate_is_favorite(self, queryset):
|
||||
"""
|
||||
@@ -499,8 +498,38 @@ class DocumentViewSet(
|
||||
)
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
"""Apply annotations and filters sequentially."""
|
||||
filterset = DocumentFilter(
|
||||
"""Override to apply annotations to generic views."""
|
||||
queryset = super().filter_queryset(queryset)
|
||||
queryset = self.annotate_is_favorite(queryset)
|
||||
queryset = self.annotate_user_roles(queryset)
|
||||
return queryset
|
||||
|
||||
def get_response_for_queryset(self, queryset):
|
||||
"""Return paginated response for the queryset if requested."""
|
||||
page = self.paginate_queryset(queryset)
|
||||
if page is not None:
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return drf.response.Response(serializer.data)
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
"""
|
||||
Returns a DRF response containing the filtered, annotated and ordered document list.
|
||||
|
||||
This method applies filtering based on request parameters using `ListDocumentFilter`.
|
||||
It performs early filtering on model fields, annotates user roles, and removes
|
||||
descendant documents to keep only the highest ancestors readable by the current user.
|
||||
|
||||
Additional annotations (e.g., `is_highest_ancestor_for_user`, favorite status) are
|
||||
applied before ordering and returning the response.
|
||||
"""
|
||||
queryset = (
|
||||
self.get_queryset()
|
||||
) # Not calling filter_queryset. We do our own cooking.
|
||||
|
||||
filterset = ListDocumentFilter(
|
||||
self.request.GET, queryset=queryset, request=self.request
|
||||
)
|
||||
filterset.is_valid()
|
||||
@@ -512,22 +541,19 @@ class DocumentViewSet(
|
||||
|
||||
queryset = self.annotate_user_roles(queryset)
|
||||
|
||||
if self.action == "list":
|
||||
# Among the results, we may have documents that are ancestors/descendants
|
||||
# of each other. In this case we want to keep only the highest ancestors.
|
||||
root_paths = utils.filter_root_paths(
|
||||
queryset.order_by("path").values_list("path", flat=True),
|
||||
skip_sorting=True,
|
||||
)
|
||||
queryset = queryset.filter(path__in=root_paths)
|
||||
# Among the results, we may have documents that are ancestors/descendants
|
||||
# of each other. In this case we want to keep only the highest ancestors.
|
||||
root_paths = utils.filter_root_paths(
|
||||
queryset.order_by("path").values_list("path", flat=True),
|
||||
skip_sorting=True,
|
||||
)
|
||||
queryset = queryset.filter(path__in=root_paths)
|
||||
|
||||
# Annotate the queryset with an attribute marking instances as highest ancestor
|
||||
# in order to save some time while computing abilities in the instance
|
||||
queryset = queryset.annotate(
|
||||
is_highest_ancestor_for_user=db.Value(
|
||||
True, output_field=db.BooleanField()
|
||||
)
|
||||
)
|
||||
# Annotate the queryset with an attribute marking instances as highest ancestor
|
||||
# in order to save some time while computing abilities on the instance
|
||||
queryset = queryset.annotate(
|
||||
is_highest_ancestor_for_user=db.Value(True, output_field=db.BooleanField())
|
||||
)
|
||||
|
||||
# Annotate favorite status and filter if applicable as late as possible
|
||||
queryset = self.annotate_is_favorite(queryset)
|
||||
@@ -536,18 +562,11 @@ class DocumentViewSet(
|
||||
)
|
||||
|
||||
# Apply ordering only now that everyting is filtered and annotated
|
||||
return filters.OrderingFilter().filter_queryset(self.request, queryset, self)
|
||||
queryset = filters.OrderingFilter().filter_queryset(
|
||||
self.request, queryset, self
|
||||
)
|
||||
|
||||
def get_response_for_queryset(self, queryset):
|
||||
"""Return paginated response for the queryset if requested."""
|
||||
page = self.paginate_queryset(queryset)
|
||||
if page is not None:
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
result = self.get_paginated_response(serializer.data)
|
||||
return result
|
||||
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return drf.response.Response(serializer.data)
|
||||
return self.get_response_for_queryset(queryset)
|
||||
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
"""
|
||||
@@ -600,7 +619,7 @@ class DocumentViewSet(
|
||||
user=user
|
||||
).values_list("document_id", flat=True)
|
||||
|
||||
queryset = self.get_queryset()
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
queryset = queryset.filter(id__in=favorite_documents_ids)
|
||||
return self.get_response_for_queryset(queryset)
|
||||
|
||||
@@ -727,7 +746,6 @@ class DocumentViewSet(
|
||||
detail=True,
|
||||
methods=["get", "post"],
|
||||
ordering=["path"],
|
||||
url_path="children",
|
||||
)
|
||||
def children(self, request, *args, **kwargs):
|
||||
"""Handle listing and creating children of a document"""
|
||||
@@ -759,12 +777,102 @@ class DocumentViewSet(
|
||||
)
|
||||
|
||||
# GET: List children
|
||||
queryset = document.get_children().filter(deleted_at__isnull=True)
|
||||
queryset = document.get_children().filter(ancestors_deleted_at__isnull=True)
|
||||
queryset = self.filter_queryset(queryset)
|
||||
queryset = self.annotate_is_favorite(queryset)
|
||||
queryset = self.annotate_user_roles(queryset)
|
||||
|
||||
filterset = DocumentFilter(request.GET, queryset=queryset)
|
||||
if filterset.is_valid():
|
||||
queryset = filterset.qs
|
||||
|
||||
return self.get_response_for_queryset(queryset)
|
||||
|
||||
@drf.decorators.action(
|
||||
detail=True,
|
||||
methods=["get"],
|
||||
ordering=["path"],
|
||||
)
|
||||
def descendants(self, request, *args, **kwargs):
|
||||
"""Handle listing descendants of a document"""
|
||||
document = self.get_object()
|
||||
|
||||
queryset = document.get_descendants().filter(ancestors_deleted_at__isnull=True)
|
||||
queryset = self.filter_queryset(queryset)
|
||||
|
||||
filterset = DocumentFilter(request.GET, queryset=queryset)
|
||||
if filterset.is_valid():
|
||||
queryset = filterset.qs
|
||||
|
||||
return self.get_response_for_queryset(queryset)
|
||||
|
||||
@drf.decorators.action(
|
||||
detail=True,
|
||||
methods=["get"],
|
||||
ordering=["path"],
|
||||
)
|
||||
def tree(self, request, pk, *args, **kwargs):
|
||||
"""
|
||||
List ancestors tree above the document.
|
||||
What we need to display is the tree structure opened for the current document.
|
||||
"""
|
||||
try:
|
||||
current_document = self.queryset.only("depth", "path").get(pk=pk)
|
||||
except models.Document.DoesNotExist as excpt:
|
||||
raise drf.exceptions.NotFound from excpt
|
||||
|
||||
ancestors = (
|
||||
(current_document.get_ancestors() | self.queryset.filter(pk=pk))
|
||||
.filter(ancestors_deleted_at__isnull=True)
|
||||
.order_by("path")
|
||||
)
|
||||
|
||||
# Get the highest readable ancestor
|
||||
highest_readable = ancestors.readable_per_se(request.user).only("depth").first()
|
||||
if highest_readable is None:
|
||||
raise (
|
||||
drf.exceptions.PermissionDenied()
|
||||
if request.user.is_authenticated
|
||||
else drf.exceptions.NotAuthenticated()
|
||||
)
|
||||
|
||||
paths_links_mapping = {}
|
||||
ancestors_links = []
|
||||
children_clause = db.Q()
|
||||
for ancestor in ancestors:
|
||||
if ancestor.depth < highest_readable.depth:
|
||||
continue
|
||||
|
||||
children_clause |= db.Q(
|
||||
path__startswith=ancestor.path, depth=ancestor.depth + 1
|
||||
)
|
||||
|
||||
# Compute cache for ancestors links to avoid many queries while computing
|
||||
# abilties for his documents in the tree!
|
||||
ancestors_links.append(
|
||||
{"link_reach": ancestor.link_reach, "link_role": ancestor.link_role}
|
||||
)
|
||||
paths_links_mapping[ancestor.path] = ancestors_links.copy()
|
||||
|
||||
children = self.queryset.filter(children_clause, deleted_at__isnull=True)
|
||||
|
||||
queryset = ancestors.filter(depth__gte=highest_readable.depth) | children
|
||||
queryset = queryset.order_by("path")
|
||||
queryset = self.annotate_user_roles(queryset)
|
||||
queryset = self.annotate_is_favorite(queryset)
|
||||
|
||||
# Pass ancestors' links definitions to the serializer as a context variable
|
||||
# in order to allow saving time while computing abilities on the instance
|
||||
serializer = self.get_serializer(
|
||||
queryset,
|
||||
many=True,
|
||||
context={
|
||||
"request": request,
|
||||
"paths_links_mapping": paths_links_mapping,
|
||||
},
|
||||
)
|
||||
return drf.response.Response(
|
||||
utils.nest_tree(serializer.data, self.queryset.model.steplen)
|
||||
)
|
||||
|
||||
@drf.decorators.action(detail=True, methods=["get"], url_path="versions")
|
||||
def versions_list(self, request, *args, **kwargs):
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
),
|
||||
]
|
||||
@@ -6,6 +6,7 @@ Declare and configure the models for the impress core application
|
||||
import hashlib
|
||||
import smtplib
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from datetime import timedelta
|
||||
from logging import getLogger
|
||||
|
||||
@@ -29,7 +30,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
from botocore.exceptions import ClientError
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from timezone_field import TimeZoneField
|
||||
from treebeard.mp_tree import MP_Node
|
||||
from treebeard.mp_tree import MP_Node, MP_NodeManager, MP_NodeQuerySet
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
@@ -80,6 +81,55 @@ class LinkReachChoices(models.TextChoices):
|
||||
) # Any authenticated user can access the document
|
||||
PUBLIC = "public", _("Public") # Even anonymous users can access the document
|
||||
|
||||
@classmethod
|
||||
def get_select_options(cls, ancestors_links):
|
||||
"""
|
||||
Determines the valid select options for link reach and link role depending on the
|
||||
list of ancestors' link reach/role.
|
||||
|
||||
Args:
|
||||
ancestors_links: List of dictionaries, each with 'link_reach' and 'link_role' keys
|
||||
representing the reach and role of ancestors links.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping possible reach levels to their corresponding possible roles.
|
||||
"""
|
||||
# If no ancestors, return all options
|
||||
if not ancestors_links:
|
||||
return {reach: LinkRoleChoices.values for reach in cls.values}
|
||||
|
||||
# Initialize result with all possible reaches and role options as sets
|
||||
result = {reach: set(LinkRoleChoices.values) for reach in cls.values}
|
||||
|
||||
# Group roles by reach level
|
||||
reach_roles = defaultdict(set)
|
||||
for link in ancestors_links:
|
||||
reach_roles[link["link_reach"]].add(link["link_role"])
|
||||
|
||||
# Apply constraints based on ancestor links
|
||||
if LinkRoleChoices.EDITOR in reach_roles[cls.RESTRICTED]:
|
||||
result[cls.RESTRICTED].discard(LinkRoleChoices.READER)
|
||||
|
||||
if LinkRoleChoices.EDITOR in reach_roles[cls.AUTHENTICATED]:
|
||||
result[cls.AUTHENTICATED].discard(LinkRoleChoices.READER)
|
||||
result.pop(cls.RESTRICTED, None)
|
||||
elif LinkRoleChoices.READER in reach_roles[cls.AUTHENTICATED]:
|
||||
result[cls.RESTRICTED].discard(LinkRoleChoices.READER)
|
||||
|
||||
if LinkRoleChoices.EDITOR in reach_roles[cls.PUBLIC]:
|
||||
result[cls.PUBLIC].discard(LinkRoleChoices.READER)
|
||||
result.pop(cls.AUTHENTICATED, None)
|
||||
result.pop(cls.RESTRICTED, None)
|
||||
elif LinkRoleChoices.READER in reach_roles[cls.PUBLIC]:
|
||||
result[cls.AUTHENTICATED].discard(LinkRoleChoices.READER)
|
||||
result.get(cls.RESTRICTED, set()).discard(LinkRoleChoices.READER)
|
||||
|
||||
# Convert roles sets to lists while maintaining the order from LinkRoleChoices
|
||||
for reach, roles in result.items():
|
||||
result[reach] = [role for role in LinkRoleChoices.values if role in roles]
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class DuplicateEmailError(Exception):
|
||||
"""Raised when an email is already associated with a pre-existing user."""
|
||||
@@ -367,6 +417,51 @@ class BaseAccess(BaseModel):
|
||||
}
|
||||
|
||||
|
||||
class DocumentQuerySet(MP_NodeQuerySet):
|
||||
"""
|
||||
Custom queryset for the Document model, providing additional methods
|
||||
to filter documents based on user permissions.
|
||||
"""
|
||||
|
||||
def readable_per_se(self, user):
|
||||
"""
|
||||
Filters the queryset to return documents that the given user has
|
||||
permission to read.
|
||||
:param user: The user for whom readable documents are to be fetched.
|
||||
:return: A queryset of documents readable by the user.
|
||||
"""
|
||||
if user.is_authenticated:
|
||||
return self.filter(
|
||||
models.Q(accesses__user=user)
|
||||
| models.Q(accesses__team__in=user.teams)
|
||||
| ~models.Q(link_reach=LinkReachChoices.RESTRICTED)
|
||||
)
|
||||
|
||||
return self.filter(link_reach=LinkReachChoices.PUBLIC)
|
||||
|
||||
|
||||
class DocumentManager(MP_NodeManager):
|
||||
"""
|
||||
Custom manager for the Document model, enabling the use of the custom
|
||||
queryset methods directly from the model manager.
|
||||
"""
|
||||
|
||||
def get_queryset(self):
|
||||
"""
|
||||
Overrides the default get_queryset method to return a custom queryset.
|
||||
:return: An instance of DocumentQuerySet.
|
||||
"""
|
||||
return DocumentQuerySet(self.model, using=self._db)
|
||||
|
||||
def readable_per_se(self, user):
|
||||
"""
|
||||
Filters documents based on user permissions using the custom queryset.
|
||||
:param user: The user for whom readable documents are to be fetched.
|
||||
:return: A queryset of documents readable by the user.
|
||||
"""
|
||||
return self.get_queryset().readable_per_se(user)
|
||||
|
||||
|
||||
class Document(MP_Node, BaseModel):
|
||||
"""Pad document carrying the content."""
|
||||
|
||||
@@ -399,6 +494,8 @@ class Document(MP_Node, BaseModel):
|
||||
|
||||
path = models.CharField(max_length=7 * 36, unique=True, db_collation="C")
|
||||
|
||||
objects = DocumentManager()
|
||||
|
||||
class Meta:
|
||||
db_table = "impress_document"
|
||||
ordering = ("path",)
|
||||
@@ -555,24 +652,47 @@ class Document(MP_Node, BaseModel):
|
||||
"""Generate a unique cache key for each document."""
|
||||
return f"document_{self.id!s}_nb_accesses"
|
||||
|
||||
@property
|
||||
def nb_accesses(self):
|
||||
"""Calculate the number of accesses."""
|
||||
def get_nb_accesses(self):
|
||||
"""
|
||||
Calculate the number of accesses:
|
||||
- directly attached to the document
|
||||
- attached to any of the document's ancestors
|
||||
"""
|
||||
cache_key = self.get_nb_accesses_cache_key()
|
||||
nb_accesses = cache.get(cache_key)
|
||||
|
||||
if nb_accesses is None:
|
||||
nb_accesses = DocumentAccess.objects.filter(
|
||||
document__path=Left(models.Value(self.path), Length("document__path")),
|
||||
).count()
|
||||
nb_accesses = (
|
||||
DocumentAccess.objects.filter(document=self).count(),
|
||||
DocumentAccess.objects.filter(
|
||||
document__path=Left(
|
||||
models.Value(self.path), Length("document__path")
|
||||
),
|
||||
document__ancestors_deleted_at__isnull=True,
|
||||
).count(),
|
||||
)
|
||||
cache.set(cache_key, nb_accesses)
|
||||
|
||||
return nb_accesses
|
||||
|
||||
@property
|
||||
def nb_accesses_direct(self):
|
||||
"""Returns the number of accesses related to the document or one of its ancestors."""
|
||||
return self.get_nb_accesses()[0]
|
||||
|
||||
@property
|
||||
def nb_accesses_ancestors(self):
|
||||
"""Returns the number of accesses related to the document or one of its ancestors."""
|
||||
return self.get_nb_accesses()[1]
|
||||
|
||||
def invalidate_nb_accesses_cache(self):
|
||||
"""
|
||||
Invalidate the cache for number of accesses, including on affected descendants.
|
||||
Args:
|
||||
path: can optionally be passed as argument (useful when invalidating cache for a
|
||||
document we just deleted)
|
||||
"""
|
||||
|
||||
for document in Document.objects.filter(path__startswith=self.path).only("id"):
|
||||
cache_key = document.get_nb_accesses_cache_key()
|
||||
cache.delete(cache_key)
|
||||
@@ -596,25 +716,27 @@ class Document(MP_Node, BaseModel):
|
||||
roles = []
|
||||
return roles
|
||||
|
||||
@cached_property
|
||||
def links_definitions(self):
|
||||
def get_links_definitions(self, ancestors_links):
|
||||
"""Get links reach/role definitions for the current document and its ancestors."""
|
||||
links_definitions = {self.link_reach: {self.link_role}}
|
||||
|
||||
# Ancestors links definitions are only interesting if the document is not the highest
|
||||
# ancestor to which the current user has access. Look for the annotation:
|
||||
if self.depth > 1 and not getattr(self, "is_highest_ancestor_for_user", False):
|
||||
for ancestor in self.get_ancestors().values("link_reach", "link_role"):
|
||||
links_definitions.setdefault(ancestor["link_reach"], set()).add(
|
||||
ancestor["link_role"]
|
||||
)
|
||||
links_definitions = defaultdict(set)
|
||||
links_definitions[self.link_reach].add(self.link_role)
|
||||
|
||||
return links_definitions
|
||||
# Merge ancestor link definitions
|
||||
for ancestor in ancestors_links:
|
||||
links_definitions[ancestor["link_reach"]].add(ancestor["link_role"])
|
||||
|
||||
def get_abilities(self, user):
|
||||
return dict(links_definitions) # Convert defaultdict back to a normal dict
|
||||
|
||||
def get_abilities(self, user, ancestors_links=None):
|
||||
"""
|
||||
Compute and return abilities for a given user on the document.
|
||||
"""
|
||||
if self.depth <= 1 or getattr(self, "is_highest_ancestor_for_user", False):
|
||||
ancestors_links = []
|
||||
elif ancestors_links is None:
|
||||
ancestors_links = self.get_ancestors().values("link_reach", "link_role")
|
||||
|
||||
roles = set(
|
||||
self.get_roles(user)
|
||||
) # at this point only roles based on specific access
|
||||
@@ -634,9 +756,7 @@ class Document(MP_Node, BaseModel):
|
||||
) and not is_deleted
|
||||
|
||||
# Add roles provided by the document link, taking into account its ancestors
|
||||
|
||||
# Add roles provided by the document link
|
||||
links_definitions = self.links_definitions
|
||||
links_definitions = self.get_links_definitions(ancestors_links)
|
||||
public_roles = links_definitions.get(LinkReachChoices.PUBLIC, set())
|
||||
authenticated_roles = (
|
||||
links_definitions.get(LinkReachChoices.AUTHENTICATED, set())
|
||||
@@ -671,6 +791,7 @@ class Document(MP_Node, BaseModel):
|
||||
"children_list": can_get,
|
||||
"children_create": can_update and user.is_authenticated,
|
||||
"collaboration_auth": can_get,
|
||||
"descendants": can_get,
|
||||
"destroy": is_owner,
|
||||
"favorite": can_get and user.is_authenticated,
|
||||
"link_configuration": is_owner_or_admin,
|
||||
@@ -680,6 +801,8 @@ class Document(MP_Node, BaseModel):
|
||||
"restore": is_owner,
|
||||
"retrieve": can_get,
|
||||
"media_auth": can_get,
|
||||
"link_select_options": LinkReachChoices.get_select_options(ancestors_links),
|
||||
"tree": can_get,
|
||||
"update": can_update,
|
||||
"versions_destroy": is_owner_or_admin,
|
||||
"versions_list": has_access_role,
|
||||
@@ -750,19 +873,26 @@ class Document(MP_Node, BaseModel):
|
||||
Soft delete the document, marking the deletion on descendants.
|
||||
We still keep the .delete() method untouched for programmatic purposes.
|
||||
"""
|
||||
if self.deleted_at or self.ancestors_deleted_at:
|
||||
if (
|
||||
self._meta.model.objects.filter(
|
||||
models.Q(deleted_at__isnull=False)
|
||||
| models.Q(ancestors_deleted_at__isnull=False),
|
||||
pk=self.pk,
|
||||
).exists()
|
||||
or self.get_ancestors().filter(deleted_at__isnull=False).exists()
|
||||
):
|
||||
raise RuntimeError(
|
||||
"This document is already deleted or has deleted ancestors."
|
||||
)
|
||||
|
||||
# Check if any ancestors are deleted
|
||||
if self.get_ancestors().filter(deleted_at__isnull=False).exists():
|
||||
raise RuntimeError(
|
||||
"Cannot delete this document because one or more ancestors are already deleted."
|
||||
_("This document is already deleted or has deleted ancestors.")
|
||||
)
|
||||
|
||||
self.ancestors_deleted_at = self.deleted_at = timezone.now()
|
||||
self.save()
|
||||
self.invalidate_nb_accesses_cache()
|
||||
|
||||
if self.depth > 1:
|
||||
self._meta.model.objects.filter(pk=self.get_parent().pk).update(
|
||||
numchild=models.F("numchild") - 1
|
||||
)
|
||||
|
||||
# Mark all descendants as soft deleted
|
||||
self.get_descendants().filter(ancestors_deleted_at__isnull=True).update(
|
||||
@@ -773,18 +903,14 @@ class Document(MP_Node, BaseModel):
|
||||
def restore(self):
|
||||
"""Cancelling a soft delete with checks."""
|
||||
# This should not happen
|
||||
if self.deleted_at is None:
|
||||
raise ValidationError({"deleted_at": [_("This document is not deleted.")]})
|
||||
if self._meta.model.objects.filter(
|
||||
pk=self.pk, deleted_at__isnull=True
|
||||
).exists():
|
||||
raise RuntimeError(_("This document is not deleted."))
|
||||
|
||||
if self.deleted_at < get_trashbin_cutoff():
|
||||
raise ValidationError(
|
||||
{
|
||||
"deleted_at": [
|
||||
_(
|
||||
"This document was permanently deleted and cannot be restored."
|
||||
)
|
||||
]
|
||||
}
|
||||
raise RuntimeError(
|
||||
_("This document was permanently deleted and cannot be restored.")
|
||||
)
|
||||
|
||||
# Restore the current document
|
||||
@@ -798,9 +924,15 @@ class Document(MP_Node, BaseModel):
|
||||
)
|
||||
self.ancestors_deleted_at = min(ancestors_deleted_at, default=None)
|
||||
self.save()
|
||||
self.invalidate_nb_accesses_cache()
|
||||
|
||||
if self.depth > 1:
|
||||
self._meta.model.objects.filter(pk=self.get_parent().pk).update(
|
||||
numchild=models.F("numchild") + 1
|
||||
)
|
||||
|
||||
# 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)
|
||||
# current document (the ancestor_deleted_at date for those should already be good)
|
||||
# The number of deleted descendants should not be too big so we can handcraft a union
|
||||
# clause for them:
|
||||
deleted_descendants_paths = (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Tests for Documents API endpoint in impress's core app: create
|
||||
Tests for Documents API endpoint in impress's core app: children create
|
||||
"""
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Tests for Documents API endpoint in impress's core app: retrieve
|
||||
Tests for Documents API endpoint in impress's core app: children list
|
||||
"""
|
||||
|
||||
import random
|
||||
@@ -15,7 +15,7 @@ pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_documents_children_list_anonymous_public_standalone():
|
||||
"""Anonymous users should be allowed to retrieve the children of a public documents."""
|
||||
"""Anonymous users should be allowed to retrieve the children of a public document."""
|
||||
document = factories.DocumentFactory(link_reach="public")
|
||||
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||
factories.UserDocumentAccessFactory(document=child1)
|
||||
@@ -39,7 +39,8 @@ def test_api_documents_children_list_anonymous_public_standalone():
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses": 1,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 1,
|
||||
"path": child1.path,
|
||||
"title": child1.title,
|
||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
@@ -56,7 +57,8 @@ def test_api_documents_children_list_anonymous_public_standalone():
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses": 0,
|
||||
"nb_accesses_ancestors": 0,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child2.path,
|
||||
"title": child2.title,
|
||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
@@ -100,7 +102,8 @@ def test_api_documents_children_list_anonymous_public_parent():
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses": 1,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 1,
|
||||
"path": child1.path,
|
||||
"title": child1.title,
|
||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
@@ -117,7 +120,8 @@ def test_api_documents_children_list_anonymous_public_parent():
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses": 0,
|
||||
"nb_accesses_ancestors": 0,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child2.path,
|
||||
"title": child2.title,
|
||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
@@ -179,7 +183,8 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses": 1,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 1,
|
||||
"path": child1.path,
|
||||
"title": child1.title,
|
||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
@@ -196,7 +201,8 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses": 0,
|
||||
"nb_accesses_ancestors": 0,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child2.path,
|
||||
"title": child2.title,
|
||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
@@ -244,7 +250,8 @@ def test_api_documents_children_list_authenticated_public_or_authenticated_paren
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses": 1,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 1,
|
||||
"path": child1.path,
|
||||
"title": child1.title,
|
||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
@@ -261,7 +268,8 @@ def test_api_documents_children_list_authenticated_public_or_authenticated_paren
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses": 0,
|
||||
"nb_accesses_ancestors": 0,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child2.path,
|
||||
"title": child2.title,
|
||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
@@ -331,7 +339,8 @@ def test_api_documents_children_list_authenticated_related_direct():
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses": 3,
|
||||
"nb_accesses_ancestors": 3,
|
||||
"nb_accesses_direct": 1,
|
||||
"path": child1.path,
|
||||
"title": child1.title,
|
||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
@@ -348,7 +357,8 @@ def test_api_documents_children_list_authenticated_related_direct():
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses": 2,
|
||||
"nb_accesses_ancestors": 2,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child2.path,
|
||||
"title": child2.title,
|
||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
@@ -399,7 +409,8 @@ def test_api_documents_children_list_authenticated_related_parent():
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses": 2,
|
||||
"nb_accesses_ancestors": 2,
|
||||
"nb_accesses_direct": 1,
|
||||
"path": child1.path,
|
||||
"title": child1.title,
|
||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
@@ -416,7 +427,8 @@ def test_api_documents_children_list_authenticated_related_parent():
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses": 1,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child2.path,
|
||||
"title": child2.title,
|
||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
@@ -514,7 +526,8 @@ def test_api_documents_children_list_authenticated_related_team_members(
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses": 1,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child1.path,
|
||||
"title": child1.title,
|
||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
@@ -531,7 +544,8 @@ def test_api_documents_children_list_authenticated_related_team_members(
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses": 1,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child2.path,
|
||||
"title": child2.title,
|
||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
|
||||
@@ -0,0 +1,696 @@
|
||||
"""
|
||||
Tests for Documents API endpoint in impress's core app: descendants
|
||||
"""
|
||||
|
||||
import random
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_documents_descendants_list_anonymous_public_standalone():
|
||||
"""Anonymous users should be allowed to retrieve the descendants of a public document."""
|
||||
document = factories.DocumentFactory(link_reach="public")
|
||||
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||
grand_child = factories.DocumentFactory(parent=child1)
|
||||
|
||||
factories.UserDocumentAccessFactory(document=child1)
|
||||
|
||||
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/descendants/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"count": 3,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [
|
||||
{
|
||||
"abilities": child1.get_abilities(AnonymousUser()),
|
||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child1.creator.id),
|
||||
"depth": 2,
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 1,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 1,
|
||||
"path": child1.path,
|
||||
"title": child1.title,
|
||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_roles": [],
|
||||
},
|
||||
{
|
||||
"abilities": grand_child.get_abilities(AnonymousUser()),
|
||||
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(grand_child.creator.id),
|
||||
"depth": 3,
|
||||
"excerpt": grand_child.excerpt,
|
||||
"id": str(grand_child.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": grand_child.link_reach,
|
||||
"link_role": grand_child.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": grand_child.path,
|
||||
"title": grand_child.title,
|
||||
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_roles": [],
|
||||
},
|
||||
{
|
||||
"abilities": child2.get_abilities(AnonymousUser()),
|
||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child2.creator.id),
|
||||
"depth": 2,
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 0,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child2.path,
|
||||
"title": child2.title,
|
||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_roles": [],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_api_documents_descendants_list_anonymous_public_parent():
|
||||
"""
|
||||
Anonymous users should be allowed to retrieve the descendants of a document who
|
||||
has a public ancestor.
|
||||
"""
|
||||
grand_parent = factories.DocumentFactory(link_reach="public")
|
||||
parent = factories.DocumentFactory(
|
||||
parent=grand_parent, link_reach=random.choice(["authenticated", "restricted"])
|
||||
)
|
||||
document = factories.DocumentFactory(
|
||||
link_reach=random.choice(["authenticated", "restricted"]), parent=parent
|
||||
)
|
||||
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||
grand_child = factories.DocumentFactory(parent=child1)
|
||||
|
||||
factories.UserDocumentAccessFactory(document=child1)
|
||||
|
||||
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/descendants/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"count": 3,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [
|
||||
{
|
||||
"abilities": child1.get_abilities(AnonymousUser()),
|
||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child1.creator.id),
|
||||
"depth": 4,
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 1,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 1,
|
||||
"path": child1.path,
|
||||
"title": child1.title,
|
||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_roles": [],
|
||||
},
|
||||
{
|
||||
"abilities": grand_child.get_abilities(AnonymousUser()),
|
||||
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(grand_child.creator.id),
|
||||
"depth": 5,
|
||||
"excerpt": grand_child.excerpt,
|
||||
"id": str(grand_child.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": grand_child.link_reach,
|
||||
"link_role": grand_child.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": grand_child.path,
|
||||
"title": grand_child.title,
|
||||
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_roles": [],
|
||||
},
|
||||
{
|
||||
"abilities": child2.get_abilities(AnonymousUser()),
|
||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child2.creator.id),
|
||||
"depth": 4,
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 0,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child2.path,
|
||||
"title": child2.title,
|
||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_roles": [],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reach", ["restricted", "authenticated"])
|
||||
def test_api_documents_descendants_list_anonymous_restricted_or_authenticated(reach):
|
||||
"""
|
||||
Anonymous users should not be able to retrieve descendants of a document that is not public.
|
||||
"""
|
||||
document = factories.DocumentFactory(link_reach=reach)
|
||||
child = factories.DocumentFactory(parent=document)
|
||||
_grand_child = factories.DocumentFactory(parent=child)
|
||||
|
||||
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/descendants/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reach", ["public", "authenticated"])
|
||||
def test_api_documents_descendants_list_authenticated_unrelated_public_or_authenticated(
|
||||
reach,
|
||||
):
|
||||
"""
|
||||
Authenticated users should be able to retrieve the descendants of a public/authenticated
|
||||
document to which they are not related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach=reach)
|
||||
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||
grand_child = factories.DocumentFactory(parent=child1)
|
||||
|
||||
factories.UserDocumentAccessFactory(document=child1)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/descendants/",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"count": 3,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [
|
||||
{
|
||||
"abilities": child1.get_abilities(user),
|
||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child1.creator.id),
|
||||
"depth": 2,
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 1,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 1,
|
||||
"path": child1.path,
|
||||
"title": child1.title,
|
||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_roles": [],
|
||||
},
|
||||
{
|
||||
"abilities": grand_child.get_abilities(user),
|
||||
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(grand_child.creator.id),
|
||||
"depth": 3,
|
||||
"excerpt": grand_child.excerpt,
|
||||
"id": str(grand_child.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": grand_child.link_reach,
|
||||
"link_role": grand_child.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": grand_child.path,
|
||||
"title": grand_child.title,
|
||||
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_roles": [],
|
||||
},
|
||||
{
|
||||
"abilities": child2.get_abilities(user),
|
||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child2.creator.id),
|
||||
"depth": 2,
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 0,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child2.path,
|
||||
"title": child2.title,
|
||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_roles": [],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reach", ["public", "authenticated"])
|
||||
def test_api_documents_descendants_list_authenticated_public_or_authenticated_parent(
|
||||
reach,
|
||||
):
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve the descendants of a document who
|
||||
has a public or authenticated ancestor.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
grand_parent = factories.DocumentFactory(link_reach=reach)
|
||||
parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted")
|
||||
document = factories.DocumentFactory(link_reach="restricted", parent=parent)
|
||||
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||
grand_child = factories.DocumentFactory(parent=child1)
|
||||
|
||||
factories.UserDocumentAccessFactory(document=child1)
|
||||
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/descendants/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"count": 3,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [
|
||||
{
|
||||
"abilities": child1.get_abilities(user),
|
||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child1.creator.id),
|
||||
"depth": 4,
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 1,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 1,
|
||||
"path": child1.path,
|
||||
"title": child1.title,
|
||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_roles": [],
|
||||
},
|
||||
{
|
||||
"abilities": grand_child.get_abilities(user),
|
||||
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(grand_child.creator.id),
|
||||
"depth": 5,
|
||||
"excerpt": grand_child.excerpt,
|
||||
"id": str(grand_child.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": grand_child.link_reach,
|
||||
"link_role": grand_child.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": grand_child.path,
|
||||
"title": grand_child.title,
|
||||
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_roles": [],
|
||||
},
|
||||
{
|
||||
"abilities": child2.get_abilities(user),
|
||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child2.creator.id),
|
||||
"depth": 4,
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 0,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child2.path,
|
||||
"title": child2.title,
|
||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_roles": [],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_api_documents_descendants_list_authenticated_unrelated_restricted():
|
||||
"""
|
||||
Authenticated users should not be allowed to retrieve the descendants of a document that is
|
||||
restricted and to which they are not related.
|
||||
"""
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
child1, _child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||
_grand_child = factories.DocumentFactory(parent=child1)
|
||||
|
||||
factories.UserDocumentAccessFactory(document=child1)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/descendants/",
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
|
||||
def test_api_documents_descendants_list_authenticated_related_direct():
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve the descendants of a document
|
||||
to which they are directly related whatever the role.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
access = factories.UserDocumentAccessFactory(document=document, user=user)
|
||||
factories.UserDocumentAccessFactory(document=document)
|
||||
|
||||
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||
factories.UserDocumentAccessFactory(document=child1)
|
||||
|
||||
grand_child = factories.DocumentFactory(parent=child1)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/descendants/",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"count": 3,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [
|
||||
{
|
||||
"abilities": child1.get_abilities(user),
|
||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child1.creator.id),
|
||||
"depth": 2,
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 1,
|
||||
"nb_accesses_ancestors": 3,
|
||||
"nb_accesses_direct": 1,
|
||||
"path": child1.path,
|
||||
"title": child1.title,
|
||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_roles": [access.role],
|
||||
},
|
||||
{
|
||||
"abilities": grand_child.get_abilities(user),
|
||||
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(grand_child.creator.id),
|
||||
"depth": 3,
|
||||
"excerpt": grand_child.excerpt,
|
||||
"id": str(grand_child.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": grand_child.link_reach,
|
||||
"link_role": grand_child.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 3,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": grand_child.path,
|
||||
"title": grand_child.title,
|
||||
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_roles": [access.role],
|
||||
},
|
||||
{
|
||||
"abilities": child2.get_abilities(user),
|
||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child2.creator.id),
|
||||
"depth": 2,
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 2,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child2.path,
|
||||
"title": child2.title,
|
||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_roles": [access.role],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_api_documents_descendants_list_authenticated_related_parent():
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve the descendants of a document if they
|
||||
are related to one of its ancestors whatever the role.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
grand_parent = factories.DocumentFactory(link_reach="restricted")
|
||||
grand_parent_access = factories.UserDocumentAccessFactory(
|
||||
document=grand_parent, user=user
|
||||
)
|
||||
|
||||
parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted")
|
||||
document = factories.DocumentFactory(parent=parent, link_reach="restricted")
|
||||
|
||||
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||
factories.UserDocumentAccessFactory(document=child1)
|
||||
|
||||
grand_child = factories.DocumentFactory(parent=child1)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/descendants/",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"count": 3,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [
|
||||
{
|
||||
"abilities": child1.get_abilities(user),
|
||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child1.creator.id),
|
||||
"depth": 4,
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 1,
|
||||
"nb_accesses_ancestors": 2,
|
||||
"nb_accesses_direct": 1,
|
||||
"path": child1.path,
|
||||
"title": child1.title,
|
||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_roles": [grand_parent_access.role],
|
||||
},
|
||||
{
|
||||
"abilities": grand_child.get_abilities(user),
|
||||
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(grand_child.creator.id),
|
||||
"depth": 5,
|
||||
"excerpt": grand_child.excerpt,
|
||||
"id": str(grand_child.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": grand_child.link_reach,
|
||||
"link_role": grand_child.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 2,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": grand_child.path,
|
||||
"title": grand_child.title,
|
||||
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_roles": [grand_parent_access.role],
|
||||
},
|
||||
{
|
||||
"abilities": child2.get_abilities(user),
|
||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child2.creator.id),
|
||||
"depth": 4,
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child2.path,
|
||||
"title": child2.title,
|
||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_roles": [grand_parent_access.role],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_api_documents_descendants_list_authenticated_related_child():
|
||||
"""
|
||||
Authenticated users should not be allowed to retrieve all the descendants of a document
|
||||
as a result of being related to one of its children.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
child1, _child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||
_grand_child = factories.DocumentFactory(parent=child1)
|
||||
|
||||
factories.UserDocumentAccessFactory(document=child1, user=user)
|
||||
factories.UserDocumentAccessFactory(document=document)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/descendants/",
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
|
||||
def test_api_documents_descendants_list_authenticated_related_team_none(
|
||||
mock_user_teams,
|
||||
):
|
||||
"""
|
||||
Authenticated users should not be able to retrieve the descendants of a restricted document
|
||||
related to teams in which the user is not.
|
||||
"""
|
||||
mock_user_teams.return_value = []
|
||||
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
factories.DocumentFactory.create_batch(2, parent=document)
|
||||
|
||||
factories.TeamDocumentAccessFactory(document=document, team="myteam")
|
||||
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/descendants/")
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
|
||||
def test_api_documents_descendants_list_authenticated_related_team_members(
|
||||
mock_user_teams,
|
||||
):
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve the descendants of a document to which they
|
||||
are related via a team whatever the role.
|
||||
"""
|
||||
mock_user_teams.return_value = ["myteam"]
|
||||
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||
grand_child = factories.DocumentFactory(parent=child1)
|
||||
|
||||
access = factories.TeamDocumentAccessFactory(document=document, team="myteam")
|
||||
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/descendants/")
|
||||
|
||||
# pylint: disable=R0801
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"count": 3,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [
|
||||
{
|
||||
"abilities": child1.get_abilities(user),
|
||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child1.creator.id),
|
||||
"depth": 2,
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 1,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child1.path,
|
||||
"title": child1.title,
|
||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_roles": [access.role],
|
||||
},
|
||||
{
|
||||
"abilities": grand_child.get_abilities(user),
|
||||
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(grand_child.creator.id),
|
||||
"depth": 3,
|
||||
"excerpt": grand_child.excerpt,
|
||||
"id": str(grand_child.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": grand_child.link_reach,
|
||||
"link_role": grand_child.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": grand_child.path,
|
||||
"title": grand_child.title,
|
||||
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_roles": [access.role],
|
||||
},
|
||||
{
|
||||
"abilities": child2.get_abilities(user),
|
||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child2.creator.id),
|
||||
"depth": 2,
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child2.path,
|
||||
"title": child2.title,
|
||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_roles": [access.role],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
"""
|
||||
Tests for Documents API endpoint in impress's core app: list
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from faker import Faker
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
|
||||
fake = Faker()
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
# Filters: unknown field
|
||||
|
||||
|
||||
def test_api_documents_descendants_filter_unknown_field():
|
||||
"""
|
||||
Trying to filter by an unknown field should be ignored.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.DocumentFactory()
|
||||
|
||||
document = factories.DocumentFactory(users=[user])
|
||||
expected_ids = {
|
||||
str(document.id)
|
||||
for document in factories.DocumentFactory.create_batch(2, parent=document)
|
||||
}
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/descendants/?unknown=true"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 2
|
||||
assert {result["id"] for result in results} == expected_ids
|
||||
|
||||
|
||||
# Filters: title
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"query,nb_results",
|
||||
[
|
||||
("Project Alpha", 1), # Exact match
|
||||
("project", 2), # Partial match (case-insensitive)
|
||||
("Guide", 1), # Word match within a title
|
||||
("Special", 0), # No match (nonexistent keyword)
|
||||
("2024", 2), # Match by numeric keyword
|
||||
("", 5), # Empty string
|
||||
],
|
||||
)
|
||||
def test_api_documents_descendants_filter_title(query, nb_results):
|
||||
"""Authenticated users should be able to search documents by their title."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(users=[user])
|
||||
|
||||
# Create documents with predefined titles
|
||||
titles = [
|
||||
"Project Alpha Documentation",
|
||||
"Project Beta Overview",
|
||||
"User Guide",
|
||||
"Financial Report 2024",
|
||||
"Annual Review 2024",
|
||||
]
|
||||
for title in titles:
|
||||
factories.DocumentFactory(title=title, parent=document)
|
||||
|
||||
# Perform the search query
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/descendants/?title={query:s}"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == nb_results
|
||||
|
||||
# Ensure all results contain the query in their title
|
||||
for result in results:
|
||||
assert query.lower().strip() in result["title"].lower()
|
||||
@@ -70,7 +70,8 @@ def test_api_documents_list_format():
|
||||
"is_favorite": True,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses": 3,
|
||||
"nb_accesses_ancestors": 3,
|
||||
"nb_accesses_direct": 3,
|
||||
"numchild": 0,
|
||||
"path": document.path,
|
||||
"title": document.title,
|
||||
@@ -147,7 +148,7 @@ def test_api_documents_list_authenticated_direct(django_assert_num_queries):
|
||||
str(child4_with_access.id),
|
||||
}
|
||||
|
||||
with django_assert_num_queries(8):
|
||||
with django_assert_num_queries(12):
|
||||
response = client.get("/api/v1.0/documents/")
|
||||
|
||||
# nb_accesses should now be cached
|
||||
@@ -185,7 +186,7 @@ def test_api_documents_list_authenticated_via_team(
|
||||
|
||||
expected_ids = {str(document.id) for document in documents_team1 + documents_team2}
|
||||
|
||||
with django_assert_num_queries(9):
|
||||
with django_assert_num_queries(14):
|
||||
response = client.get("/api/v1.0/documents/")
|
||||
|
||||
# nb_accesses should now be cached
|
||||
@@ -218,7 +219,7 @@ def test_api_documents_list_authenticated_link_reach_restricted(
|
||||
other_document = factories.DocumentFactory(link_reach="public")
|
||||
models.LinkTrace.objects.create(document=other_document, user=user)
|
||||
|
||||
with django_assert_num_queries(5):
|
||||
with django_assert_num_queries(6):
|
||||
response = client.get("/api/v1.0/documents/")
|
||||
|
||||
# nb_accesses should now be cached
|
||||
@@ -267,7 +268,7 @@ def test_api_documents_list_authenticated_link_reach_public_or_authenticated(
|
||||
|
||||
expected_ids = {str(document1.id), str(document2.id), str(visible_child.id)}
|
||||
|
||||
with django_assert_num_queries(7):
|
||||
with django_assert_num_queries(10):
|
||||
response = client.get("/api/v1.0/documents/")
|
||||
|
||||
# nb_accesses should now be cached
|
||||
@@ -328,6 +329,35 @@ def test_api_documents_list_pagination(
|
||||
assert document_ids == []
|
||||
|
||||
|
||||
def test_api_documents_list_pagination_force_page_size():
|
||||
"""Page size can be set via querystring."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document_ids = [
|
||||
str(access.document_id)
|
||||
for access in factories.UserDocumentAccessFactory.create_batch(3, user=user)
|
||||
]
|
||||
|
||||
# Force page size
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/?page_size=2",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
|
||||
assert content["count"] == 3
|
||||
assert content["next"] == "http://testserver/api/v1.0/documents/?page=2&page_size=2"
|
||||
assert content["previous"] is None
|
||||
|
||||
assert len(content["results"]) == 2
|
||||
for item in content["results"]:
|
||||
document_ids.remove(item["id"])
|
||||
|
||||
|
||||
def test_api_documents_list_authenticated_distinct():
|
||||
"""A document with several related users should only be listed once."""
|
||||
user = factories.UserFactory()
|
||||
@@ -362,7 +392,7 @@ def test_api_documents_list_favorites_no_extra_queries(django_assert_num_queries
|
||||
factories.DocumentFactory.create_batch(2, users=[user])
|
||||
|
||||
url = "/api/v1.0/documents/"
|
||||
with django_assert_num_queries(9):
|
||||
with django_assert_num_queries(14):
|
||||
response = client.get(url)
|
||||
|
||||
# nb_accesses should now be cached
|
||||
|
||||
@@ -34,16 +34,23 @@ def test_api_documents_retrieve_anonymous_public_standalone():
|
||||
"children_create": False,
|
||||
"children_list": True,
|
||||
"collaboration_auth": True,
|
||||
"descendants": True,
|
||||
"destroy": False,
|
||||
# Anonymous user can't favorite a document even with read access
|
||||
"favorite": False,
|
||||
"invite_owner": False,
|
||||
"link_configuration": False,
|
||||
"link_select_options": {
|
||||
"authenticated": ["reader", "editor"],
|
||||
"public": ["reader", "editor"],
|
||||
"restricted": ["reader", "editor"],
|
||||
},
|
||||
"media_auth": True,
|
||||
"move": False,
|
||||
"partial_update": document.link_role == "editor",
|
||||
"restore": False,
|
||||
"retrieve": True,
|
||||
"tree": True,
|
||||
"update": document.link_role == "editor",
|
||||
"versions_destroy": False,
|
||||
"versions_list": False,
|
||||
@@ -57,7 +64,8 @@ def test_api_documents_retrieve_anonymous_public_standalone():
|
||||
"is_favorite": False,
|
||||
"link_reach": "public",
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses": 0,
|
||||
"nb_accesses_ancestors": 0,
|
||||
"nb_accesses_direct": 0,
|
||||
"numchild": 0,
|
||||
"path": document.path,
|
||||
"title": document.title,
|
||||
@@ -79,6 +87,7 @@ def test_api_documents_retrieve_anonymous_public_parent():
|
||||
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
links = document.get_ancestors().values("link_reach", "link_role")
|
||||
assert response.json() == {
|
||||
"id": str(document.id),
|
||||
"abilities": {
|
||||
@@ -90,16 +99,19 @@ def test_api_documents_retrieve_anonymous_public_parent():
|
||||
"children_create": False,
|
||||
"children_list": True,
|
||||
"collaboration_auth": True,
|
||||
"descendants": True,
|
||||
"destroy": False,
|
||||
# Anonymous user can't favorite a document even with read access
|
||||
"favorite": False,
|
||||
"invite_owner": False,
|
||||
"link_configuration": False,
|
||||
"link_select_options": models.LinkReachChoices.get_select_options(links),
|
||||
"media_auth": True,
|
||||
"move": False,
|
||||
"partial_update": grand_parent.link_role == "editor",
|
||||
"restore": False,
|
||||
"retrieve": True,
|
||||
"tree": True,
|
||||
"update": grand_parent.link_role == "editor",
|
||||
"versions_destroy": False,
|
||||
"versions_list": False,
|
||||
@@ -113,7 +125,8 @@ def test_api_documents_retrieve_anonymous_public_parent():
|
||||
"is_favorite": False,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses": 0,
|
||||
"nb_accesses_ancestors": 0,
|
||||
"nb_accesses_direct": 0,
|
||||
"numchild": 0,
|
||||
"path": document.path,
|
||||
"title": document.title,
|
||||
@@ -180,15 +193,22 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
|
||||
"children_create": document.link_role == "editor",
|
||||
"children_list": True,
|
||||
"collaboration_auth": True,
|
||||
"descendants": True,
|
||||
"destroy": False,
|
||||
"favorite": True,
|
||||
"invite_owner": False,
|
||||
"link_configuration": False,
|
||||
"link_select_options": {
|
||||
"authenticated": ["reader", "editor"],
|
||||
"public": ["reader", "editor"],
|
||||
"restricted": ["reader", "editor"],
|
||||
},
|
||||
"media_auth": True,
|
||||
"move": False,
|
||||
"partial_update": document.link_role == "editor",
|
||||
"restore": False,
|
||||
"retrieve": True,
|
||||
"tree": True,
|
||||
"update": document.link_role == "editor",
|
||||
"versions_destroy": False,
|
||||
"versions_list": False,
|
||||
@@ -202,7 +222,8 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
|
||||
"is_favorite": False,
|
||||
"link_reach": reach,
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses": 0,
|
||||
"nb_accesses_ancestors": 0,
|
||||
"nb_accesses_direct": 0,
|
||||
"numchild": 0,
|
||||
"path": document.path,
|
||||
"title": document.title,
|
||||
@@ -232,6 +253,7 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
links = document.get_ancestors().values("link_reach", "link_role")
|
||||
assert response.json() == {
|
||||
"id": str(document.id),
|
||||
"abilities": {
|
||||
@@ -243,15 +265,18 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
|
||||
"children_create": grand_parent.link_role == "editor",
|
||||
"children_list": True,
|
||||
"collaboration_auth": True,
|
||||
"descendants": True,
|
||||
"destroy": False,
|
||||
"favorite": True,
|
||||
"invite_owner": False,
|
||||
"link_configuration": False,
|
||||
"link_select_options": models.LinkReachChoices.get_select_options(links),
|
||||
"move": False,
|
||||
"media_auth": True,
|
||||
"partial_update": grand_parent.link_role == "editor",
|
||||
"restore": False,
|
||||
"retrieve": True,
|
||||
"tree": True,
|
||||
"update": grand_parent.link_role == "editor",
|
||||
"versions_destroy": False,
|
||||
"versions_list": False,
|
||||
@@ -265,7 +290,8 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
|
||||
"is_favorite": False,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses": 0,
|
||||
"nb_accesses_ancestors": 0,
|
||||
"nb_accesses_direct": 0,
|
||||
"numchild": 0,
|
||||
"path": document.path,
|
||||
"title": document.title,
|
||||
@@ -374,7 +400,8 @@ def test_api_documents_retrieve_authenticated_related_direct():
|
||||
"is_favorite": False,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses": 2,
|
||||
"nb_accesses_ancestors": 2,
|
||||
"nb_accesses_direct": 2,
|
||||
"numchild": 0,
|
||||
"path": document.path,
|
||||
"title": document.title,
|
||||
@@ -404,6 +431,7 @@ def test_api_documents_retrieve_authenticated_related_parent():
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
links = document.get_ancestors().values("link_reach", "link_role")
|
||||
assert response.json() == {
|
||||
"id": str(document.id),
|
||||
"abilities": {
|
||||
@@ -415,15 +443,18 @@ def test_api_documents_retrieve_authenticated_related_parent():
|
||||
"children_create": access.role != "reader",
|
||||
"children_list": True,
|
||||
"collaboration_auth": True,
|
||||
"descendants": True,
|
||||
"destroy": access.role == "owner",
|
||||
"favorite": True,
|
||||
"invite_owner": access.role == "owner",
|
||||
"link_configuration": access.role in ["administrator", "owner"],
|
||||
"link_select_options": models.LinkReachChoices.get_select_options(links),
|
||||
"media_auth": True,
|
||||
"move": access.role in ["administrator", "owner"],
|
||||
"partial_update": access.role != "reader",
|
||||
"restore": access.role == "owner",
|
||||
"retrieve": True,
|
||||
"tree": True,
|
||||
"update": access.role != "reader",
|
||||
"versions_destroy": access.role in ["administrator", "owner"],
|
||||
"versions_list": True,
|
||||
@@ -437,7 +468,8 @@ def test_api_documents_retrieve_authenticated_related_parent():
|
||||
"is_favorite": False,
|
||||
"link_reach": "restricted",
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses": 2,
|
||||
"nb_accesses_ancestors": 2,
|
||||
"nb_accesses_direct": 0,
|
||||
"numchild": 0,
|
||||
"path": document.path,
|
||||
"title": document.title,
|
||||
@@ -465,7 +497,8 @@ def test_api_documents_retrieve_authenticated_related_nb_accesses():
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["nb_accesses"] == 3
|
||||
assert response.json()["nb_accesses_ancestors"] == 3
|
||||
assert response.json()["nb_accesses_direct"] == 1
|
||||
|
||||
factories.UserDocumentAccessFactory(document=grand_parent)
|
||||
|
||||
@@ -473,7 +506,8 @@ def test_api_documents_retrieve_authenticated_related_nb_accesses():
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["nb_accesses"] == 4
|
||||
assert response.json()["nb_accesses_ancestors"] == 4
|
||||
assert response.json()["nb_accesses_direct"] == 1
|
||||
|
||||
|
||||
def test_api_documents_retrieve_authenticated_related_child():
|
||||
@@ -554,12 +588,10 @@ def test_api_documents_retrieve_authenticated_related_team_members(
|
||||
mock_user_teams.return_value = teams
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=document, team="readers", role="reader"
|
||||
)
|
||||
@@ -588,7 +620,8 @@ def test_api_documents_retrieve_authenticated_related_team_members(
|
||||
"is_favorite": False,
|
||||
"link_reach": "restricted",
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses": 5,
|
||||
"nb_accesses_ancestors": 5,
|
||||
"nb_accesses_direct": 5,
|
||||
"numchild": 0,
|
||||
"path": document.path,
|
||||
"title": document.title,
|
||||
@@ -649,7 +682,8 @@ def test_api_documents_retrieve_authenticated_related_team_administrators(
|
||||
"is_favorite": False,
|
||||
"link_reach": "restricted",
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses": 5,
|
||||
"nb_accesses_ancestors": 5,
|
||||
"nb_accesses_direct": 5,
|
||||
"numchild": 0,
|
||||
"path": document.path,
|
||||
"title": document.title,
|
||||
@@ -710,7 +744,8 @@ def test_api_documents_retrieve_authenticated_related_team_owners(
|
||||
"is_favorite": False,
|
||||
"link_reach": "restricted",
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses": 5,
|
||||
"nb_accesses_ancestors": 5,
|
||||
"nb_accesses_direct": 5,
|
||||
"numchild": 0,
|
||||
"path": document.path,
|
||||
"title": document.title,
|
||||
@@ -719,7 +754,7 @@ def test_api_documents_retrieve_authenticated_related_team_owners(
|
||||
}
|
||||
|
||||
|
||||
def test_api_documents_retrieve_user_roles(django_assert_num_queries):
|
||||
def test_api_documents_retrieve_user_roles(django_assert_max_num_queries):
|
||||
"""
|
||||
Roles should be annotated on querysets taking into account all documents ancestors.
|
||||
"""
|
||||
@@ -744,7 +779,7 @@ def test_api_documents_retrieve_user_roles(django_assert_num_queries):
|
||||
)
|
||||
expected_roles = {access.role for access in accesses}
|
||||
|
||||
with django_assert_num_queries(10):
|
||||
with django_assert_max_num_queries(12):
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -761,7 +796,7 @@ def test_api_documents_retrieve_numqueries_with_link_trace(django_assert_num_que
|
||||
|
||||
document = factories.DocumentFactory(users=[user], link_traces=[user])
|
||||
|
||||
with django_assert_num_queries(4):
|
||||
with django_assert_num_queries(5):
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
|
||||
|
||||
with django_assert_num_queries(3):
|
||||
|
||||
@@ -78,15 +78,22 @@ def test_api_documents_trashbin_format():
|
||||
"children_create": True,
|
||||
"children_list": True,
|
||||
"collaboration_auth": True,
|
||||
"descendants": True,
|
||||
"destroy": True,
|
||||
"favorite": True,
|
||||
"invite_owner": True,
|
||||
"link_configuration": True,
|
||||
"link_select_options": {
|
||||
"authenticated": ["reader", "editor"],
|
||||
"public": ["reader", "editor"],
|
||||
"restricted": ["reader", "editor"],
|
||||
},
|
||||
"media_auth": True,
|
||||
"move": False, # Can't move a deleted document
|
||||
"partial_update": True,
|
||||
"restore": True,
|
||||
"retrieve": True,
|
||||
"tree": True,
|
||||
"update": True,
|
||||
"versions_destroy": True,
|
||||
"versions_list": True,
|
||||
@@ -98,7 +105,8 @@ def test_api_documents_trashbin_format():
|
||||
"excerpt": document.excerpt,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses": 3,
|
||||
"nb_accesses_ancestors": 0,
|
||||
"nb_accesses_direct": 3,
|
||||
"numchild": 0,
|
||||
"path": document.path,
|
||||
"title": document.title,
|
||||
@@ -147,7 +155,7 @@ def test_api_documents_trashbin_authenticated_direct(django_assert_num_queries):
|
||||
|
||||
expected_ids = {str(document1.id), str(document2.id), str(document3.id)}
|
||||
|
||||
with django_assert_num_queries(7):
|
||||
with django_assert_num_queries(10):
|
||||
response = client.get("/api/v1.0/documents/trashbin/")
|
||||
|
||||
with django_assert_num_queries(4):
|
||||
@@ -189,7 +197,7 @@ def test_api_documents_trashbin_authenticated_via_team(
|
||||
|
||||
expected_ids = {str(deleted_document_team1.id), str(deleted_document_team2.id)}
|
||||
|
||||
with django_assert_num_queries(5):
|
||||
with django_assert_num_queries(7):
|
||||
response = client.get("/api/v1.0/documents/trashbin/")
|
||||
|
||||
with django_assert_num_queries(3):
|
||||
|
||||
1031
src/backend/core/tests/documents/test_api_documents_tree.py
Normal file
1031
src/backend/core/tests/documents/test_api_documents_tree.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -275,7 +275,8 @@ def test_api_documents_update_authenticated_editor_administrator_or_owner(
|
||||
"depth",
|
||||
"link_reach",
|
||||
"link_role",
|
||||
"nb_accesses",
|
||||
"nb_accesses_ancestors",
|
||||
"nb_accesses_direct",
|
||||
"numchild",
|
||||
"path",
|
||||
]:
|
||||
|
||||
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"
|
||||
107
src/backend/core/tests/test_api_utils_nest_tree.py
Normal file
107
src/backend/core/tests/test_api_utils_nest_tree.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""Unit tests for the nest_tree utility function."""
|
||||
|
||||
import pytest
|
||||
|
||||
from core.api.utils import nest_tree
|
||||
|
||||
|
||||
def test_api_utils_nest_tree_empty_list():
|
||||
"""Test that an empty list returns an empty nested structure."""
|
||||
# pylint: disable=use-implicit-booleaness-not-comparison
|
||||
assert nest_tree([], 4) is None
|
||||
|
||||
|
||||
def test_api_utils_nest_tree_single_document():
|
||||
"""Test that a single document is returned as the only root element."""
|
||||
documents = [{"id": "1", "path": "0001"}]
|
||||
expected = {"id": "1", "path": "0001", "children": []}
|
||||
assert nest_tree(documents, 4) == expected
|
||||
|
||||
|
||||
def test_api_utils_nest_tree_multiple_root_documents():
|
||||
"""Test that multiple root-level documents are correctly added to the root."""
|
||||
documents = [
|
||||
{"id": "1", "path": "0001"},
|
||||
{"id": "2", "path": "0002"},
|
||||
]
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="More than one root element detected.",
|
||||
):
|
||||
nest_tree(documents, 4)
|
||||
|
||||
|
||||
def test_api_utils_nest_tree_nested_structure():
|
||||
"""Test that documents are correctly nested based on path levels."""
|
||||
documents = [
|
||||
{"id": "1", "path": "0001"},
|
||||
{"id": "2", "path": "00010001"},
|
||||
{"id": "3", "path": "000100010001"},
|
||||
{"id": "4", "path": "00010002"},
|
||||
]
|
||||
expected = {
|
||||
"id": "1",
|
||||
"path": "0001",
|
||||
"children": [
|
||||
{
|
||||
"id": "2",
|
||||
"path": "00010001",
|
||||
"children": [{"id": "3", "path": "000100010001", "children": []}],
|
||||
},
|
||||
{"id": "4", "path": "00010002", "children": []},
|
||||
],
|
||||
}
|
||||
assert nest_tree(documents, 4) == expected
|
||||
|
||||
|
||||
def test_api_utils_nest_tree_siblings_at_same_path():
|
||||
"""
|
||||
Test that sibling documents with the same path are correctly grouped under the same parent.
|
||||
"""
|
||||
documents = [
|
||||
{"id": "1", "path": "0001"},
|
||||
{"id": "2", "path": "00010001"},
|
||||
{"id": "3", "path": "00010002"},
|
||||
]
|
||||
expected = {
|
||||
"id": "1",
|
||||
"path": "0001",
|
||||
"children": [
|
||||
{"id": "2", "path": "00010001", "children": []},
|
||||
{"id": "3", "path": "00010002", "children": []},
|
||||
],
|
||||
}
|
||||
assert nest_tree(documents, 4) == expected
|
||||
|
||||
|
||||
def test_api_utils_nest_tree_decreasing_path_resets_parent():
|
||||
"""Test that a document at a lower path resets the parent assignment correctly."""
|
||||
documents = [
|
||||
{"id": "1", "path": "0001"},
|
||||
{"id": "6", "path": "00010001"},
|
||||
{"id": "2", "path": "00010002"}, # unordered
|
||||
{"id": "5", "path": "000100010001"},
|
||||
{"id": "3", "path": "000100010002"},
|
||||
{"id": "4", "path": "00010003"},
|
||||
]
|
||||
expected = {
|
||||
"id": "1",
|
||||
"path": "0001",
|
||||
"children": [
|
||||
{
|
||||
"id": "6",
|
||||
"path": "00010001",
|
||||
"children": [
|
||||
{"id": "5", "path": "000100010001", "children": []},
|
||||
{"id": "3", "path": "000100010002", "children": []},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"path": "00010002",
|
||||
"children": [],
|
||||
},
|
||||
{"id": "4", "path": "00010003", "children": []},
|
||||
],
|
||||
}
|
||||
assert nest_tree(documents, 4) == expected
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Unit tests for the Document model
|
||||
"""
|
||||
# pylint: disable=too-many-lines
|
||||
|
||||
import random
|
||||
import smtplib
|
||||
@@ -157,15 +158,22 @@ def test_models_documents_get_abilities_forbidden(
|
||||
"children_create": False,
|
||||
"children_list": False,
|
||||
"collaboration_auth": False,
|
||||
"descendants": False,
|
||||
"destroy": False,
|
||||
"favorite": False,
|
||||
"invite_owner": False,
|
||||
"media_auth": False,
|
||||
"move": False,
|
||||
"link_configuration": False,
|
||||
"link_select_options": {
|
||||
"authenticated": ["reader", "editor"],
|
||||
"public": ["reader", "editor"],
|
||||
"restricted": ["reader", "editor"],
|
||||
},
|
||||
"partial_update": False,
|
||||
"restore": False,
|
||||
"retrieve": False,
|
||||
"tree": False,
|
||||
"update": False,
|
||||
"versions_destroy": False,
|
||||
"versions_list": False,
|
||||
@@ -208,15 +216,22 @@ def test_models_documents_get_abilities_reader(
|
||||
"children_create": False,
|
||||
"children_list": True,
|
||||
"collaboration_auth": True,
|
||||
"descendants": True,
|
||||
"destroy": False,
|
||||
"favorite": is_authenticated,
|
||||
"invite_owner": False,
|
||||
"link_configuration": False,
|
||||
"link_select_options": {
|
||||
"authenticated": ["reader", "editor"],
|
||||
"public": ["reader", "editor"],
|
||||
"restricted": ["reader", "editor"],
|
||||
},
|
||||
"media_auth": True,
|
||||
"move": False,
|
||||
"partial_update": False,
|
||||
"restore": False,
|
||||
"retrieve": True,
|
||||
"tree": True,
|
||||
"update": False,
|
||||
"versions_destroy": False,
|
||||
"versions_list": False,
|
||||
@@ -225,9 +240,14 @@ def test_models_documents_get_abilities_reader(
|
||||
nb_queries = 1 if is_authenticated else 0
|
||||
with django_assert_num_queries(nb_queries):
|
||||
assert document.get_abilities(user) == expected_abilities
|
||||
|
||||
document.soft_delete()
|
||||
document.refresh_from_db()
|
||||
assert all(value is False for value in document.get_abilities(user).values())
|
||||
assert all(
|
||||
value is False
|
||||
for key, value in document.get_abilities(user).items()
|
||||
if key != "link_select_options"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -256,15 +276,22 @@ def test_models_documents_get_abilities_editor(
|
||||
"children_create": is_authenticated,
|
||||
"children_list": True,
|
||||
"collaboration_auth": True,
|
||||
"descendants": True,
|
||||
"destroy": False,
|
||||
"favorite": is_authenticated,
|
||||
"invite_owner": False,
|
||||
"link_configuration": False,
|
||||
"link_select_options": {
|
||||
"authenticated": ["reader", "editor"],
|
||||
"public": ["reader", "editor"],
|
||||
"restricted": ["reader", "editor"],
|
||||
},
|
||||
"media_auth": True,
|
||||
"move": False,
|
||||
"partial_update": True,
|
||||
"restore": False,
|
||||
"retrieve": True,
|
||||
"tree": True,
|
||||
"update": True,
|
||||
"versions_destroy": False,
|
||||
"versions_list": False,
|
||||
@@ -275,7 +302,11 @@ def test_models_documents_get_abilities_editor(
|
||||
assert document.get_abilities(user) == expected_abilities
|
||||
document.soft_delete()
|
||||
document.refresh_from_db()
|
||||
assert all(value is False for value in document.get_abilities(user).values())
|
||||
assert all(
|
||||
value is False
|
||||
for key, value in document.get_abilities(user).items()
|
||||
if key != "link_select_options"
|
||||
)
|
||||
|
||||
|
||||
@override_settings(
|
||||
@@ -294,15 +325,22 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
|
||||
"children_create": True,
|
||||
"children_list": True,
|
||||
"collaboration_auth": True,
|
||||
"descendants": True,
|
||||
"destroy": True,
|
||||
"favorite": True,
|
||||
"invite_owner": True,
|
||||
"link_configuration": True,
|
||||
"link_select_options": {
|
||||
"authenticated": ["reader", "editor"],
|
||||
"public": ["reader", "editor"],
|
||||
"restricted": ["reader", "editor"],
|
||||
},
|
||||
"media_auth": True,
|
||||
"move": True,
|
||||
"partial_update": True,
|
||||
"restore": True,
|
||||
"retrieve": True,
|
||||
"tree": True,
|
||||
"update": True,
|
||||
"versions_destroy": True,
|
||||
"versions_list": True,
|
||||
@@ -333,15 +371,22 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries)
|
||||
"children_create": True,
|
||||
"children_list": True,
|
||||
"collaboration_auth": True,
|
||||
"descendants": True,
|
||||
"destroy": False,
|
||||
"favorite": True,
|
||||
"invite_owner": False,
|
||||
"link_configuration": True,
|
||||
"link_select_options": {
|
||||
"authenticated": ["reader", "editor"],
|
||||
"public": ["reader", "editor"],
|
||||
"restricted": ["reader", "editor"],
|
||||
},
|
||||
"media_auth": True,
|
||||
"move": True,
|
||||
"partial_update": True,
|
||||
"restore": False,
|
||||
"retrieve": True,
|
||||
"tree": True,
|
||||
"update": True,
|
||||
"versions_destroy": True,
|
||||
"versions_list": True,
|
||||
@@ -352,7 +397,11 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries)
|
||||
|
||||
document.soft_delete()
|
||||
document.refresh_from_db()
|
||||
assert all(value is False for value in document.get_abilities(user).values())
|
||||
assert all(
|
||||
value is False
|
||||
for key, value in document.get_abilities(user).items()
|
||||
if key != "link_select_options"
|
||||
)
|
||||
|
||||
|
||||
@override_settings(
|
||||
@@ -371,15 +420,22 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
|
||||
"children_create": True,
|
||||
"children_list": True,
|
||||
"collaboration_auth": True,
|
||||
"descendants": True,
|
||||
"destroy": False,
|
||||
"favorite": True,
|
||||
"invite_owner": False,
|
||||
"link_configuration": False,
|
||||
"link_select_options": {
|
||||
"authenticated": ["reader", "editor"],
|
||||
"public": ["reader", "editor"],
|
||||
"restricted": ["reader", "editor"],
|
||||
},
|
||||
"media_auth": True,
|
||||
"move": False,
|
||||
"partial_update": True,
|
||||
"restore": False,
|
||||
"retrieve": True,
|
||||
"tree": True,
|
||||
"update": True,
|
||||
"versions_destroy": False,
|
||||
"versions_list": True,
|
||||
@@ -390,7 +446,11 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
|
||||
|
||||
document.soft_delete()
|
||||
document.refresh_from_db()
|
||||
assert all(value is False for value in document.get_abilities(user).values())
|
||||
assert all(
|
||||
value is False
|
||||
for key, value in document.get_abilities(user).items()
|
||||
if key != "link_select_options"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ai_access_setting", ["public", "authenticated", "restricted"])
|
||||
@@ -416,15 +476,22 @@ def test_models_documents_get_abilities_reader_user(
|
||||
"children_create": access_from_link,
|
||||
"children_list": True,
|
||||
"collaboration_auth": True,
|
||||
"descendants": True,
|
||||
"destroy": False,
|
||||
"favorite": True,
|
||||
"invite_owner": False,
|
||||
"link_configuration": False,
|
||||
"link_select_options": {
|
||||
"authenticated": ["reader", "editor"],
|
||||
"public": ["reader", "editor"],
|
||||
"restricted": ["reader", "editor"],
|
||||
},
|
||||
"media_auth": True,
|
||||
"move": False,
|
||||
"partial_update": access_from_link,
|
||||
"restore": False,
|
||||
"retrieve": True,
|
||||
"tree": True,
|
||||
"update": access_from_link,
|
||||
"versions_destroy": False,
|
||||
"versions_list": True,
|
||||
@@ -437,7 +504,11 @@ def test_models_documents_get_abilities_reader_user(
|
||||
|
||||
document.soft_delete()
|
||||
document.refresh_from_db()
|
||||
assert all(value is False for value in document.get_abilities(user).values())
|
||||
assert all(
|
||||
value is False
|
||||
for key, value in document.get_abilities(user).items()
|
||||
if key != "link_select_options"
|
||||
)
|
||||
|
||||
|
||||
def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
|
||||
@@ -459,15 +530,22 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
|
||||
"children_create": False,
|
||||
"children_list": True,
|
||||
"collaboration_auth": True,
|
||||
"descendants": True,
|
||||
"destroy": False,
|
||||
"favorite": True,
|
||||
"invite_owner": False,
|
||||
"link_configuration": False,
|
||||
"link_select_options": {
|
||||
"authenticated": ["reader", "editor"],
|
||||
"public": ["reader", "editor"],
|
||||
"restricted": ["reader", "editor"],
|
||||
},
|
||||
"media_auth": True,
|
||||
"move": False,
|
||||
"partial_update": False,
|
||||
"restore": False,
|
||||
"retrieve": True,
|
||||
"tree": True,
|
||||
"update": False,
|
||||
"versions_destroy": False,
|
||||
"versions_list": True,
|
||||
@@ -711,40 +789,89 @@ def test_models_documents__email_invitation__failed(mock_logger, _mock_send_mail
|
||||
# Document number of accesses
|
||||
|
||||
|
||||
def test_models_documents_nb_accesses_cache_is_set_and_retrieved(
|
||||
def test_models_documents_nb_accesses_cache_is_set_and_retrieved_ancestors(
|
||||
django_assert_num_queries,
|
||||
):
|
||||
"""Test that nb_accesses is cached after the first computation."""
|
||||
document = factories.DocumentFactory()
|
||||
"""Test that nb_accesses is cached when calling nb_accesses_ancestors."""
|
||||
parent = factories.DocumentFactory()
|
||||
document = factories.DocumentFactory(parent=parent)
|
||||
key = f"document_{document.id!s}_nb_accesses"
|
||||
nb_accesses = random.randint(1, 4)
|
||||
factories.UserDocumentAccessFactory.create_batch(nb_accesses, document=document)
|
||||
nb_accesses_parent = random.randint(1, 4)
|
||||
factories.UserDocumentAccessFactory.create_batch(
|
||||
nb_accesses_parent, document=parent
|
||||
)
|
||||
nb_accesses_direct = random.randint(1, 4)
|
||||
factories.UserDocumentAccessFactory.create_batch(
|
||||
nb_accesses_direct, document=document
|
||||
)
|
||||
factories.UserDocumentAccessFactory() # An unrelated access should not be counted
|
||||
|
||||
# Initially, the nb_accesses should not be cached
|
||||
assert cache.get(key) is None
|
||||
|
||||
# Compute the nb_accesses for the first time (this should set the cache)
|
||||
with django_assert_num_queries(1):
|
||||
assert document.nb_accesses == nb_accesses
|
||||
nb_accesses_ancestors = nb_accesses_parent + nb_accesses_direct
|
||||
with django_assert_num_queries(2):
|
||||
assert document.nb_accesses_ancestors == nb_accesses_ancestors
|
||||
|
||||
# Ensure that the nb_accesses is now cached
|
||||
with django_assert_num_queries(0):
|
||||
assert document.nb_accesses == nb_accesses
|
||||
assert cache.get(key) == nb_accesses
|
||||
assert document.nb_accesses_ancestors == nb_accesses_ancestors
|
||||
assert cache.get(key) == (nb_accesses_direct, nb_accesses_ancestors)
|
||||
|
||||
# The cache value should be invalidated when a document access is created
|
||||
models.DocumentAccess.objects.create(
|
||||
document=document, user=factories.UserFactory(), role="reader"
|
||||
)
|
||||
assert cache.get(key) is None # Cache should be invalidated
|
||||
with django_assert_num_queries(1):
|
||||
new_nb_accesses = document.nb_accesses
|
||||
assert new_nb_accesses == nb_accesses + 1
|
||||
assert cache.get(key) == new_nb_accesses # Cache should now contain the new value
|
||||
with django_assert_num_queries(2):
|
||||
assert document.nb_accesses_ancestors == nb_accesses_ancestors + 1
|
||||
assert cache.get(key) == (nb_accesses_direct + 1, nb_accesses_ancestors + 1)
|
||||
|
||||
|
||||
def test_models_documents_nb_accesses_cache_is_set_and_retrieved_direct(
|
||||
django_assert_num_queries,
|
||||
):
|
||||
"""Test that nb_accesses is cached when calling nb_accesses_direct."""
|
||||
parent = factories.DocumentFactory()
|
||||
document = factories.DocumentFactory(parent=parent)
|
||||
key = f"document_{document.id!s}_nb_accesses"
|
||||
nb_accesses_parent = random.randint(1, 4)
|
||||
factories.UserDocumentAccessFactory.create_batch(
|
||||
nb_accesses_parent, document=parent
|
||||
)
|
||||
nb_accesses_direct = random.randint(1, 4)
|
||||
factories.UserDocumentAccessFactory.create_batch(
|
||||
nb_accesses_direct, document=document
|
||||
)
|
||||
factories.UserDocumentAccessFactory() # An unrelated access should not be counted
|
||||
|
||||
# Initially, the nb_accesses should not be cached
|
||||
assert cache.get(key) is None
|
||||
|
||||
# Compute the nb_accesses for the first time (this should set the cache)
|
||||
nb_accesses_ancestors = nb_accesses_parent + nb_accesses_direct
|
||||
with django_assert_num_queries(2):
|
||||
assert document.nb_accesses_direct == nb_accesses_direct
|
||||
|
||||
# Ensure that the nb_accesses is now cached
|
||||
with django_assert_num_queries(0):
|
||||
assert document.nb_accesses_direct == nb_accesses_direct
|
||||
assert cache.get(key) == (nb_accesses_direct, nb_accesses_ancestors)
|
||||
|
||||
# The cache value should be invalidated when a document access is created
|
||||
models.DocumentAccess.objects.create(
|
||||
document=document, user=factories.UserFactory(), role="reader"
|
||||
)
|
||||
assert cache.get(key) is None # Cache should be invalidated
|
||||
with django_assert_num_queries(2):
|
||||
assert document.nb_accesses_direct == nb_accesses_direct + 1
|
||||
assert cache.get(key) == (nb_accesses_direct + 1, nb_accesses_ancestors + 1)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", ["nb_accesses_ancestors", "nb_accesses_direct"])
|
||||
def test_models_documents_nb_accesses_cache_is_invalidated_on_access_removal(
|
||||
field,
|
||||
django_assert_num_queries,
|
||||
):
|
||||
"""Test that the cache is invalidated when a document access is deleted."""
|
||||
@@ -753,15 +880,262 @@ def test_models_documents_nb_accesses_cache_is_invalidated_on_access_removal(
|
||||
access = factories.UserDocumentAccessFactory(document=document)
|
||||
|
||||
# Initially, the nb_accesses should be cached
|
||||
assert document.nb_accesses == 1
|
||||
assert cache.get(key) == 1
|
||||
assert getattr(document, field) == 1
|
||||
assert cache.get(key) == (1, 1)
|
||||
|
||||
# Remove the access and check if cache is invalidated
|
||||
access.delete()
|
||||
assert cache.get(key) is None # Cache should be invalidated
|
||||
|
||||
# Recompute the nb_accesses (this should trigger a cache set)
|
||||
with django_assert_num_queries(1):
|
||||
new_nb_accesses = document.nb_accesses
|
||||
with django_assert_num_queries(2):
|
||||
new_nb_accesses = getattr(document, field)
|
||||
assert new_nb_accesses == 0
|
||||
assert cache.get(key) == 0 # Cache should now contain the new value
|
||||
assert cache.get(key) == (0, 0) # Cache should now contain the new value
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", ["nb_accesses_ancestors", "nb_accesses_direct"])
|
||||
def test_models_documents_nb_accesses_cache_is_invalidated_on_document_soft_delete_restore(
|
||||
field,
|
||||
django_assert_num_queries,
|
||||
):
|
||||
"""Test that the cache is invalidated when a document access is deleted."""
|
||||
document = factories.DocumentFactory()
|
||||
key = f"document_{document.id!s}_nb_accesses"
|
||||
factories.UserDocumentAccessFactory(document=document)
|
||||
|
||||
# Initially, the nb_accesses should be cached
|
||||
assert getattr(document, field) == 1
|
||||
assert cache.get(key) == (1, 1)
|
||||
|
||||
# Soft delete the document and check if cache is invalidated
|
||||
document.soft_delete()
|
||||
assert cache.get(key) is None # Cache should be invalidated
|
||||
|
||||
# Recompute the nb_accesses (this should trigger a cache set)
|
||||
with django_assert_num_queries(2):
|
||||
new_nb_accesses = getattr(document, field)
|
||||
assert new_nb_accesses == (1 if field == "nb_accesses_direct" else 0)
|
||||
assert cache.get(key) == (1, 0) # Cache should now contain the new value
|
||||
|
||||
document.restore()
|
||||
|
||||
# Recompute the nb_accesses (this should trigger a cache set)
|
||||
with django_assert_num_queries(2):
|
||||
new_nb_accesses = getattr(document, field)
|
||||
assert new_nb_accesses == 1
|
||||
assert cache.get(key) == (1, 1) # Cache should now contain the new value
|
||||
|
||||
|
||||
def test_models_documents_numchild_deleted_from_instance():
|
||||
"""the "numchild" field should not include documents deleted from the instance."""
|
||||
document = factories.DocumentFactory()
|
||||
child1, _child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||
assert document.numchild == 2
|
||||
|
||||
child1.delete()
|
||||
|
||||
document.refresh_from_db()
|
||||
assert document.numchild == 1
|
||||
|
||||
|
||||
def test_models_documents_numchild_deleted_from_queryset():
|
||||
"""the "numchild" field should not include documents deleted from a queryset."""
|
||||
document = factories.DocumentFactory()
|
||||
child1, _child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||
assert document.numchild == 2
|
||||
|
||||
models.Document.objects.filter(pk=child1.pk).delete()
|
||||
|
||||
document.refresh_from_db()
|
||||
assert document.numchild == 1
|
||||
|
||||
|
||||
def test_models_documents_numchild_soft_deleted_and_restore():
|
||||
"""the "numchild" field should not include soft deleted documents."""
|
||||
document = factories.DocumentFactory()
|
||||
child1, _child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||
|
||||
assert document.numchild == 2
|
||||
|
||||
child1.soft_delete()
|
||||
|
||||
document.refresh_from_db()
|
||||
assert document.numchild == 1
|
||||
|
||||
child1.restore()
|
||||
|
||||
document.refresh_from_db()
|
||||
assert document.numchild == 2
|
||||
|
||||
|
||||
def test_models_documents_soft_delete_tempering_with_instance():
|
||||
"""
|
||||
Soft deleting should fail if the document is already deleted in database even though the
|
||||
instance "deleted_at" attributes where tempered with.
|
||||
"""
|
||||
document = factories.DocumentFactory()
|
||||
document.soft_delete()
|
||||
|
||||
document.deleted_at = None
|
||||
document.ancestors_deleted_at = None
|
||||
with pytest.raises(
|
||||
RuntimeError, match="This document is already deleted or has deleted ancestors."
|
||||
):
|
||||
document.soft_delete()
|
||||
|
||||
|
||||
def test_models_documents_restore_tempering_with_instance():
|
||||
"""
|
||||
Soft deleting should fail if the document is already deleted in database even though the
|
||||
instance "deleted_at" attributes where tempered with.
|
||||
"""
|
||||
document = factories.DocumentFactory()
|
||||
|
||||
if random.choice([False, True]):
|
||||
document.deleted_at = timezone.now()
|
||||
else:
|
||||
document.ancestors_deleted_at = timezone.now()
|
||||
|
||||
with pytest.raises(RuntimeError, match="This document is not deleted."):
|
||||
document.restore()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"ancestors_links, select_options",
|
||||
[
|
||||
# One ancestor
|
||||
(
|
||||
[{"link_reach": "public", "link_role": "reader"}],
|
||||
{
|
||||
"restricted": ["editor"],
|
||||
"authenticated": ["editor"],
|
||||
"public": ["reader", "editor"],
|
||||
},
|
||||
),
|
||||
([{"link_reach": "public", "link_role": "editor"}], {"public": ["editor"]}),
|
||||
(
|
||||
[{"link_reach": "authenticated", "link_role": "reader"}],
|
||||
{
|
||||
"restricted": ["editor"],
|
||||
"authenticated": ["reader", "editor"],
|
||||
"public": ["reader", "editor"],
|
||||
},
|
||||
),
|
||||
(
|
||||
[{"link_reach": "authenticated", "link_role": "editor"}],
|
||||
{"authenticated": ["editor"], "public": ["reader", "editor"]},
|
||||
),
|
||||
(
|
||||
[{"link_reach": "restricted", "link_role": "reader"}],
|
||||
{
|
||||
"restricted": ["reader", "editor"],
|
||||
"authenticated": ["reader", "editor"],
|
||||
"public": ["reader", "editor"],
|
||||
},
|
||||
),
|
||||
(
|
||||
[{"link_reach": "restricted", "link_role": "editor"}],
|
||||
{
|
||||
"restricted": ["editor"],
|
||||
"authenticated": ["reader", "editor"],
|
||||
"public": ["reader", "editor"],
|
||||
},
|
||||
),
|
||||
# Multiple ancestors with different roles
|
||||
(
|
||||
[
|
||||
{"link_reach": "public", "link_role": "reader"},
|
||||
{"link_reach": "public", "link_role": "editor"},
|
||||
],
|
||||
{"public": ["editor"]},
|
||||
),
|
||||
(
|
||||
[
|
||||
{"link_reach": "authenticated", "link_role": "reader"},
|
||||
{"link_reach": "authenticated", "link_role": "editor"},
|
||||
],
|
||||
{"authenticated": ["editor"], "public": ["reader", "editor"]},
|
||||
),
|
||||
(
|
||||
[
|
||||
{"link_reach": "restricted", "link_role": "reader"},
|
||||
{"link_reach": "restricted", "link_role": "editor"},
|
||||
],
|
||||
{
|
||||
"restricted": ["editor"],
|
||||
"authenticated": ["reader", "editor"],
|
||||
"public": ["reader", "editor"],
|
||||
},
|
||||
),
|
||||
# Multiple ancestors with different reaches
|
||||
(
|
||||
[
|
||||
{"link_reach": "authenticated", "link_role": "reader"},
|
||||
{"link_reach": "public", "link_role": "reader"},
|
||||
],
|
||||
{
|
||||
"restricted": ["editor"],
|
||||
"authenticated": ["editor"],
|
||||
"public": ["reader", "editor"],
|
||||
},
|
||||
),
|
||||
(
|
||||
[
|
||||
{"link_reach": "restricted", "link_role": "reader"},
|
||||
{"link_reach": "authenticated", "link_role": "reader"},
|
||||
{"link_reach": "public", "link_role": "reader"},
|
||||
],
|
||||
{
|
||||
"restricted": ["editor"],
|
||||
"authenticated": ["editor"],
|
||||
"public": ["reader", "editor"],
|
||||
},
|
||||
),
|
||||
# Multiple ancestors with mixed reaches and roles
|
||||
(
|
||||
[
|
||||
{"link_reach": "authenticated", "link_role": "editor"},
|
||||
{"link_reach": "public", "link_role": "reader"},
|
||||
],
|
||||
{"authenticated": ["editor"], "public": ["reader", "editor"]},
|
||||
),
|
||||
(
|
||||
[
|
||||
{"link_reach": "authenticated", "link_role": "reader"},
|
||||
{"link_reach": "public", "link_role": "editor"},
|
||||
],
|
||||
{"public": ["editor"]},
|
||||
),
|
||||
(
|
||||
[
|
||||
{"link_reach": "restricted", "link_role": "editor"},
|
||||
{"link_reach": "authenticated", "link_role": "reader"},
|
||||
],
|
||||
{
|
||||
"restricted": ["editor"],
|
||||
"authenticated": ["reader", "editor"],
|
||||
"public": ["reader", "editor"],
|
||||
},
|
||||
),
|
||||
(
|
||||
[
|
||||
{"link_reach": "restricted", "link_role": "reader"},
|
||||
{"link_reach": "authenticated", "link_role": "editor"},
|
||||
],
|
||||
{"authenticated": ["editor"], "public": ["reader", "editor"]},
|
||||
),
|
||||
# No ancestors (edge case)
|
||||
(
|
||||
[],
|
||||
{
|
||||
"public": ["reader", "editor"],
|
||||
"authenticated": ["reader", "editor"],
|
||||
"restricted": ["reader", "editor"],
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_models_documents_get_select_options(ancestors_links, select_options):
|
||||
"""Validate that the "get_select_options" method operates as expected."""
|
||||
assert models.LinkReachChoices.get_select_options(ancestors_links) == select_options
|
||||
|
||||
@@ -68,6 +68,7 @@ dependencies = [
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"django-extensions==3.2.3",
|
||||
"django-test-migrations==1.4.0",
|
||||
"drf-spectacular-sidecar==2024.12.1",
|
||||
"freezegun==1.5.1",
|
||||
"ipdb==0.13.13",
|
||||
@@ -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('/');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.getByRole('menuitem', { name: role }).click();
|
||||
await page.getByRole('button', { name: 'Invite' }).click();
|
||||
|
||||
return users[index].email;
|
||||
|
||||
@@ -37,10 +37,10 @@ test.describe('Doc Editor', () => {
|
||||
|
||||
// Change language to French
|
||||
await header.click();
|
||||
await header.getByRole('combobox').getByText('English').click();
|
||||
await header.getByRole('option', { name: 'Français' }).click();
|
||||
await header.getByRole('button', { name: /Language/ }).click();
|
||||
await page.getByRole('menuitem', { name: 'Français' }).click();
|
||||
await expect(
|
||||
header.getByRole('combobox').getByText('Français'),
|
||||
header.getByRole('button').getByText('Français'),
|
||||
).toBeVisible();
|
||||
|
||||
// Trigger slash menu to show french menu
|
||||
@@ -130,7 +130,7 @@ test.describe('Doc Editor', () => {
|
||||
|
||||
await selectVisibility.click();
|
||||
await page
|
||||
.getByRole('button', {
|
||||
.getByRole('menuitem', {
|
||||
name: 'Connected',
|
||||
})
|
||||
.click();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -89,7 +89,7 @@ test.describe('Doc Header', () => {
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page
|
||||
.getByRole('button', {
|
||||
.getByRole('menuitem', {
|
||||
name: 'Delete document',
|
||||
})
|
||||
.click();
|
||||
@@ -153,7 +153,7 @@ test.describe('Doc Header', () => {
|
||||
await page.getByLabel('Open the document options').click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Delete document' }),
|
||||
page.getByRole('menuitem', { name: 'Delete document' }),
|
||||
).toBeDisabled();
|
||||
|
||||
// Click somewhere else to close the options
|
||||
@@ -177,7 +177,7 @@ test.describe('Doc Header', () => {
|
||||
await invitationCard.getByRole('button', { name: 'more_horiz' }).click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
page.getByRole('menuitem', {
|
||||
name: 'delete',
|
||||
}),
|
||||
).toBeEnabled();
|
||||
@@ -195,7 +195,7 @@ test.describe('Doc Header', () => {
|
||||
await memberCard.getByRole('button', { name: 'more_horiz' }).click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
page.getByRole('menuitem', {
|
||||
name: 'delete',
|
||||
}),
|
||||
).toBeEnabled();
|
||||
@@ -233,7 +233,7 @@ test.describe('Doc Header', () => {
|
||||
await page.getByLabel('Open the document options').click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Delete document' }),
|
||||
page.getByRole('menuitem', { name: 'Delete document' }),
|
||||
).toBeDisabled();
|
||||
|
||||
// Click somewhere else to close the options
|
||||
@@ -295,7 +295,7 @@ test.describe('Doc Header', () => {
|
||||
await page.getByLabel('Open the document options').click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Delete document' }),
|
||||
page.getByRole('menuitem', { name: 'Delete document' }),
|
||||
).toBeDisabled();
|
||||
|
||||
// Click somewhere else to close the options
|
||||
@@ -352,7 +352,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.getByRole('menuitem', { name: 'Copy as Markdown' }).click();
|
||||
await expect(page.getByText('Copied to clipboard')).toBeVisible();
|
||||
|
||||
// Test that clipboard is in Markdown format
|
||||
@@ -387,7 +387,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.getByRole('menuitem', { name: 'Copy as HTML' }).click();
|
||||
await expect(page.getByText('Copied to clipboard')).toBeVisible();
|
||||
|
||||
// Test that clipboard is in HTML format
|
||||
@@ -460,7 +460,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.getByRole('menuitem', { name: '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 +494,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.getByRole('menuitem', { name: 'Share' }).click();
|
||||
|
||||
await expect(page.getByLabel('Share modal')).toBeVisible();
|
||||
await page.getByRole('button', { name: 'close' }).click();
|
||||
|
||||
@@ -65,15 +65,15 @@ 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('menuitem', { name: 'Reader' })).toBeVisible();
|
||||
await expect(page.getByRole('menuitem', { name: 'Editor' })).toBeVisible();
|
||||
await expect(page.getByRole('menuitem', { name: 'Owner' })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Administrator' }),
|
||||
page.getByRole('menuitem', { name: 'Administrator' }),
|
||||
).toBeVisible();
|
||||
|
||||
// Validate
|
||||
await page.getByRole('button', { name: 'Administrator' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Administrator' }).click();
|
||||
await page.getByRole('button', { name: 'Invite' }).click();
|
||||
|
||||
// Check invitation added
|
||||
@@ -121,7 +121,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.getByRole('menuitem', { name: 'Owner' }).click();
|
||||
|
||||
const responsePromiseCreateInvitation = page.waitForResponse(
|
||||
(response) =>
|
||||
@@ -139,7 +139,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.getByRole('menuitem', { name: 'Owner' }).click();
|
||||
|
||||
const responsePromiseCreateInvitationFail = page.waitForResponse(
|
||||
(response) =>
|
||||
@@ -162,8 +162,8 @@ test.describe('Document create member', () => {
|
||||
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 header.getByRole('button', { name: /Language/ }).click();
|
||||
await page.getByRole('menuitem', { name: 'Français' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Partager' }).click();
|
||||
|
||||
@@ -178,7 +178,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: 'Administrateur' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Administrateur' }).click();
|
||||
|
||||
const responsePromiseCreateInvitation = page.waitForResponse(
|
||||
(response) =>
|
||||
@@ -212,7 +212,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.getByRole('menuitem', { name: 'Administrator' }).click();
|
||||
|
||||
const responsePromiseCreateInvitation = page.waitForResponse(
|
||||
(response) =>
|
||||
@@ -232,14 +232,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.getByRole('menuitem', { name: 'Reader' }).click();
|
||||
|
||||
const moreActions = userInvitation.getByRole('button', {
|
||||
name: 'more_horiz',
|
||||
});
|
||||
await moreActions.click();
|
||||
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
await page.getByRole('menuitem', { name: '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.getByRole('menuitem', { name: 'Administrator' }).click();
|
||||
await list.click();
|
||||
await expect(currentUserRole).toBeVisible();
|
||||
|
||||
await currentUserRole.click();
|
||||
await page.getByRole('button', { name: 'Reader' }).click();
|
||||
await page.getByRole('menuitem', { name: '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.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await expect(userReader).toBeHidden();
|
||||
|
||||
await mySelfMoreActions.click();
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
await page.getByRole('menuitem', { name: '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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,7 +19,7 @@ test.describe('Doc Version', () => {
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page
|
||||
.getByRole('button', {
|
||||
.getByRole('menuitem', {
|
||||
name: 'Version history',
|
||||
})
|
||||
.click();
|
||||
@@ -59,7 +59,7 @@ test.describe('Doc Version', () => {
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page
|
||||
.getByRole('button', {
|
||||
.getByRole('menuitem', {
|
||||
name: 'Version history',
|
||||
})
|
||||
.click();
|
||||
@@ -91,7 +91,7 @@ test.describe('Doc Version', () => {
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Version history' }),
|
||||
page.getByRole('menuitem', { name: 'Version history' }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
@@ -120,7 +120,7 @@ test.describe('Doc Version', () => {
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page
|
||||
.getByRole('button', {
|
||||
.getByRole('menuitem', {
|
||||
name: 'Version history',
|
||||
})
|
||||
.click();
|
||||
|
||||
@@ -50,7 +50,7 @@ test.describe('Doc Visibility', () => {
|
||||
|
||||
await selectVisibility.click();
|
||||
await page
|
||||
.getByRole('button', {
|
||||
.getByRole('menuitem', {
|
||||
name: 'Connected',
|
||||
})
|
||||
.click();
|
||||
@@ -60,7 +60,7 @@ test.describe('Doc Visibility', () => {
|
||||
await selectVisibility.click();
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
.getByRole('menuitem', {
|
||||
name: 'Public',
|
||||
})
|
||||
.click();
|
||||
@@ -100,7 +100,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 +135,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 +162,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.getByRole('menuitem', { name: 'Administrator' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Invite' }).click();
|
||||
|
||||
@@ -213,7 +215,7 @@ test.describe('Doc Visibility: Public', () => {
|
||||
await selectVisibility.click();
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
.getByRole('menuitem', {
|
||||
name: 'Public',
|
||||
})
|
||||
.click();
|
||||
@@ -225,7 +227,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 +289,7 @@ test.describe('Doc Visibility: Public', () => {
|
||||
await selectVisibility.click();
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
.getByRole('menuitem', {
|
||||
name: 'Public',
|
||||
})
|
||||
.click();
|
||||
@@ -355,7 +357,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 +381,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 +407,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 +416,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 +469,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é' }),
|
||||
|
||||
@@ -9,19 +9,20 @@ test.describe('Language', () => {
|
||||
await expect(page.getByLabel('Logout')).toBeVisible();
|
||||
|
||||
const header = page.locator('header').first();
|
||||
await header.getByRole('combobox').getByText('English').click();
|
||||
await header.getByRole('option', { name: 'Français' }).click();
|
||||
await header
|
||||
.getByRole('button', { name: /Language/ })
|
||||
.getByText('English')
|
||||
.click();
|
||||
await page.getByRole('menuitem', { name: 'Français' }).click();
|
||||
await expect(
|
||||
header.getByRole('combobox').getByText('Français'),
|
||||
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.getByRole('menuitem', { name: 'Deutsch' }).click();
|
||||
await expect(header.getByRole('button').getByText('Deutsch')).toBeVisible();
|
||||
|
||||
await expect(page.getByLabel('Abmelden')).toBeVisible();
|
||||
});
|
||||
@@ -53,8 +54,11 @@ test.describe('Language', () => {
|
||||
|
||||
// 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 header
|
||||
.getByRole('button', { name: /Language/ })
|
||||
.getByText('English')
|
||||
.click();
|
||||
await page.getByRole('menuitem', { name: 'Français' }).click();
|
||||
|
||||
// Check for French 404 response
|
||||
await check404Response('Pas trouvé.');
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
|
||||
const config = {
|
||||
themes: {
|
||||
default: {
|
||||
@@ -381,6 +382,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: {
|
||||
|
||||
@@ -15,17 +15,20 @@
|
||||
"test:watch": "jest --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ag-media/react-pdf-table": "2.0.1",
|
||||
"@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",
|
||||
"@fontsource/material-icons": "^5.1.1",
|
||||
"@gouvfr-lasuite/integration": "1.0.2",
|
||||
"@hocuspocus/provider": "2.15.2",
|
||||
"@openfun/cunningham-react": "2.9.4",
|
||||
"@react-pdf/renderer": "4.1.6",
|
||||
"@sentry/nextjs": "8.54.0",
|
||||
"@tanstack/react-query": "5.66.0",
|
||||
"clsx": "2.1.1",
|
||||
"cmdk": "1.0.4",
|
||||
"crisp-sdk-web": "1.0.25",
|
||||
"docx": "9.1.1",
|
||||
@@ -37,11 +40,14 @@
|
||||
"next": "15.1.6",
|
||||
"posthog-js": "1.215.6",
|
||||
"react": "*",
|
||||
"react-arborist": "3.4.0",
|
||||
"react-aria-components": "1.6.0",
|
||||
"react-dom": "*",
|
||||
"react-i18next": "15.4.0",
|
||||
"react-intersection-observer": "9.15.1",
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"react-select": "5.10.0",
|
||||
"sass": "1.83.4",
|
||||
"styled-components": "6.1.15",
|
||||
"use-debounce": "10.0.4",
|
||||
"y-protocols": "1.0.6",
|
||||
@@ -73,6 +79,7 @@
|
||||
"stylelint-config-standard": "37.0.0",
|
||||
"stylelint-prettier": "5.0.3",
|
||||
"typescript": "*",
|
||||
"use-resize-observer": "9.1.0",
|
||||
"webpack": "5.97.1",
|
||||
"workbox-webpack-plugin": "7.1.0"
|
||||
}
|
||||
|
||||
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 |
@@ -1,4 +1,3 @@
|
||||
import { FocusScope } from '@react-aria/focus';
|
||||
import {
|
||||
PropsWithChildren,
|
||||
ReactNode,
|
||||
@@ -7,19 +6,22 @@ import {
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Button, Popover } from 'react-aria-components';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
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;
|
||||
padding: 1rem;
|
||||
`;
|
||||
|
||||
const StyledButton = styled(Button)`
|
||||
interface StyledButtonProps {
|
||||
$css?: BoxProps['$css'];
|
||||
}
|
||||
const StyledButton = styled(Button)<StyledButtonProps>`
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: none;
|
||||
@@ -30,14 +32,12 @@ const StyledButton = styled(Button)`
|
||||
font-size: 0.938rem;
|
||||
padding: 0;
|
||||
text-wrap: nowrap;
|
||||
|
||||
&:focus-within {
|
||||
outline: 2px solid #007bff;
|
||||
}
|
||||
${({ $css }) => $css};
|
||||
`;
|
||||
|
||||
export interface DropButtonProps {
|
||||
button: ReactNode;
|
||||
buttonCss?: BoxProps['$css'];
|
||||
isOpen?: boolean;
|
||||
onOpenChange?: (isOpen: boolean) => void;
|
||||
label?: string;
|
||||
@@ -45,55 +45,54 @@ export interface DropButtonProps {
|
||||
|
||||
export const DropButton = ({
|
||||
button,
|
||||
buttonCss,
|
||||
isOpen = false,
|
||||
onOpenChange,
|
||||
children,
|
||||
label,
|
||||
}: PropsWithChildren<DropButtonProps>) => {
|
||||
const { t } = useTranslation();
|
||||
const [isLocalOpen, setIsLocalOpen] = useState(isOpen);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const firstFocusableRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const triggerRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLocalOpen && firstFocusableRef.current) {
|
||||
firstFocusableRef.current.focus();
|
||||
}
|
||||
}, [isLocalOpen]);
|
||||
setIsLocalOpen(isOpen);
|
||||
}, [isOpen]);
|
||||
|
||||
const onOpenChangeHandler = (isOpen: boolean) => {
|
||||
setIsLocalOpen(isOpen);
|
||||
onOpenChange?.(isOpen);
|
||||
};
|
||||
|
||||
const props = {
|
||||
onClick: (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onOpenChangeHandler(!isLocalOpen);
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledButton
|
||||
// {...props}
|
||||
ref={triggerRef}
|
||||
onPress={() => onOpenChangeHandler(true)}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={isLocalOpen}
|
||||
aria-label={t('Open the document options')}
|
||||
onPress={(e) => {
|
||||
onOpenChangeHandler(!isLocalOpen);
|
||||
}}
|
||||
aria-label={label}
|
||||
$css={buttonCss}
|
||||
>
|
||||
<span aria-hidden="true">{button}</span>
|
||||
{button}
|
||||
</StyledButton>
|
||||
|
||||
{isLocalOpen && (
|
||||
<StyledPopover
|
||||
triggerRef={triggerRef}
|
||||
isOpen={isLocalOpen}
|
||||
onOpenChange={onOpenChangeHandler}
|
||||
>
|
||||
<FocusScope contain restoreFocus>
|
||||
{children}
|
||||
<button
|
||||
ref={firstFocusableRef}
|
||||
onClick={() => setIsLocalOpen(false)}
|
||||
>
|
||||
{t('Close the modal')}
|
||||
</button>
|
||||
</FocusScope>
|
||||
</StyledPopover>
|
||||
)}
|
||||
<StyledPopover
|
||||
triggerRef={triggerRef}
|
||||
isOpen={isLocalOpen}
|
||||
onOpenChange={onOpenChangeHandler}
|
||||
>
|
||||
{children}
|
||||
</StyledPopover>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
//import { t } from 'i18next';
|
||||
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';
|
||||
@@ -9,6 +8,7 @@ export type DropdownMenuOption = {
|
||||
icon?: string;
|
||||
label: string;
|
||||
testId?: string;
|
||||
value?: string;
|
||||
callback?: () => void | Promise<unknown>;
|
||||
danger?: boolean;
|
||||
isSelected?: boolean;
|
||||
@@ -21,8 +21,11 @@ export type DropdownMenuProps = {
|
||||
showArrow?: boolean;
|
||||
label?: string;
|
||||
arrowCss?: BoxProps['$css'];
|
||||
buttonCss?: BoxProps['$css'];
|
||||
disabled?: boolean;
|
||||
topMessage?: string;
|
||||
selectedValues?: string[];
|
||||
afterOpenChange?: (isOpen: boolean) => void;
|
||||
};
|
||||
|
||||
export const DropdownMenu = ({
|
||||
@@ -31,16 +34,21 @@ export const DropdownMenu = ({
|
||||
disabled = false,
|
||||
showArrow = false,
|
||||
arrowCss,
|
||||
buttonCss,
|
||||
label,
|
||||
topMessage,
|
||||
afterOpenChange,
|
||||
selectedValues,
|
||||
}: PropsWithChildren<DropdownMenuProps>) => {
|
||||
const theme = useCunninghamTheme();
|
||||
const spacings = theme.spacingsTokens();
|
||||
const colors = theme.colorsTokens();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const blockButtonRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const onOpenChange = (isOpen: boolean) => {
|
||||
setIsOpen(isOpen);
|
||||
afterOpenChange?.(isOpen);
|
||||
};
|
||||
|
||||
if (disabled) {
|
||||
@@ -52,10 +60,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={
|
||||
@@ -68,11 +83,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"
|
||||
@@ -91,6 +112,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"
|
||||
@@ -145,7 +167,8 @@ export const DropdownMenu = ({
|
||||
{option.label}
|
||||
</Text>
|
||||
</Box>
|
||||
{option.isSelected && (
|
||||
{(option.isSelected ||
|
||||
selectedValues?.includes(option.value ?? '')) && (
|
||||
<Icon iconName="check" $size="20px" $theme="greyscale" />
|
||||
)}
|
||||
</BoxButton>
|
||||
|
||||
@@ -5,10 +5,19 @@ import { useCunninghamTheme } from '@/cunningham';
|
||||
|
||||
type IconProps = TextType & {
|
||||
iconName: string;
|
||||
isFilled?: boolean;
|
||||
};
|
||||
export const Icon = ({ iconName, ...textProps }: IconProps) => {
|
||||
export const Icon = ({ iconName, isFilled, ...textProps }: IconProps) => {
|
||||
return (
|
||||
<Text $isMaterialIcon {...textProps}>
|
||||
<Text
|
||||
$isMaterialIcon={!isFilled}
|
||||
{...textProps}
|
||||
className={
|
||||
isFilled
|
||||
? `material-icons-filled ${textProps.className}`
|
||||
: textProps.className
|
||||
}
|
||||
>
|
||||
{iconName}
|
||||
</Text>
|
||||
);
|
||||
|
||||
@@ -25,7 +25,9 @@ export interface TextProps extends BoxProps {
|
||||
$size?: TextSizes | (string & {});
|
||||
$theme?:
|
||||
| 'primary'
|
||||
| 'primary-text'
|
||||
| 'secondary'
|
||||
| 'secondary-text'
|
||||
| 'info'
|
||||
| 'success'
|
||||
| 'warning'
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import styles from './loader.module.scss';
|
||||
|
||||
interface LoaderProps {
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
}
|
||||
|
||||
export const Loader = ({ size = 'sm' }: LoaderProps) => {
|
||||
return <div className={[styles.loader, styles[size]].join(' ')} />;
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
.loader {
|
||||
border-radius: 50%;
|
||||
background:
|
||||
radial-gradient(farthest-side, #cecece 94%, #0000) top/3.8px 3.8px no-repeat,
|
||||
conic-gradient(#0000 30%, #cecece);
|
||||
-webkit-mask: radial-gradient(
|
||||
farthest-side,
|
||||
#0000 calc(100% - 3.8px),
|
||||
#000 0
|
||||
);
|
||||
animation: spinner-c7wet2 1s infinite linear;
|
||||
&.sm {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
&.md {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
&.lg {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
&.xl {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.spinner {
|
||||
}
|
||||
|
||||
@keyframes spinner-c7wet2 {
|
||||
100% {
|
||||
transform: rotate(1turn);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
/* eslint-disable jsx-a11y/no-static-element-interactions */
|
||||
import { clsx } from 'clsx';
|
||||
import { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
CursorProps,
|
||||
MoveHandler,
|
||||
NodeApi,
|
||||
NodeRendererProps,
|
||||
Tree,
|
||||
} from 'react-arborist';
|
||||
import { OpenMap } from 'react-arborist/dist/module/state/open-slice';
|
||||
|
||||
import {
|
||||
BaseType,
|
||||
TreeViewDataType,
|
||||
TreeViewMoveModeEnum,
|
||||
TreeViewMoveResult,
|
||||
} from '@/features/docs/doc-tree/types/tree';
|
||||
|
||||
import { Box } from '../../Box';
|
||||
import { Icon } from '../../Icon';
|
||||
import { Loader } from '../loader/Loader';
|
||||
|
||||
import styles from './treeview.module.scss';
|
||||
|
||||
export type TreeViewProps<T> = {
|
||||
treeData: TreeViewDataType<T>[];
|
||||
width?: number | string;
|
||||
selectedNodeId?: string;
|
||||
rootNodeId: string;
|
||||
initialOpenState?: OpenMap;
|
||||
renderNode: (
|
||||
props: NodeRendererProps<TreeViewDataType<T>>,
|
||||
) => React.ReactNode;
|
||||
afterMove?: (
|
||||
result: TreeViewMoveResult,
|
||||
newTreeData: TreeViewDataType<T>[],
|
||||
) => void;
|
||||
};
|
||||
|
||||
export const TreeView = <T,>({
|
||||
treeData,
|
||||
width,
|
||||
rootNodeId,
|
||||
renderNode,
|
||||
afterMove,
|
||||
selectedNodeId,
|
||||
initialOpenState,
|
||||
}: TreeViewProps<T>) => {
|
||||
const onMove3 = (args: {
|
||||
dragIds: string[];
|
||||
dragNodes: NodeApi<BaseType<T>>[];
|
||||
parentId: string | null;
|
||||
parentNode: NodeApi<BaseType<T>> | null;
|
||||
index: number;
|
||||
}): TreeViewMoveResult | null => {
|
||||
const newData = treeData.map((rootItem) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { children, ...rest } = rootItem;
|
||||
return rest;
|
||||
});
|
||||
|
||||
const sourceNodeId = args.dragNodes[0].data.id;
|
||||
const sourceNode = args.dragNodes[0].data;
|
||||
const oldParentId = sourceNode.parentId ?? rootNodeId;
|
||||
const newIndex = args.index;
|
||||
const targetNodeId = args.parentId ?? rootNodeId;
|
||||
|
||||
const children = args.parentId
|
||||
? (args.parentNode?.children ?? [])
|
||||
: newData;
|
||||
|
||||
if (newIndex === 0) {
|
||||
return {
|
||||
targetNodeId: targetNodeId ?? rootNodeId,
|
||||
mode: TreeViewMoveModeEnum.FIRST_CHILD,
|
||||
sourceNodeId,
|
||||
oldParentId,
|
||||
};
|
||||
}
|
||||
if (newIndex === children.length) {
|
||||
return {
|
||||
targetNodeId: targetNodeId ?? rootNodeId,
|
||||
mode: TreeViewMoveModeEnum.LAST_CHILD,
|
||||
sourceNodeId,
|
||||
oldParentId,
|
||||
};
|
||||
}
|
||||
|
||||
const siblingIndex = newIndex - 1;
|
||||
const sibling = children[siblingIndex];
|
||||
|
||||
if (sibling) {
|
||||
return {
|
||||
targetNodeId: sibling.id,
|
||||
mode: TreeViewMoveModeEnum.RIGHT,
|
||||
sourceNodeId,
|
||||
oldParentId,
|
||||
};
|
||||
}
|
||||
|
||||
const nextSiblingIndex = newIndex + 1;
|
||||
const nextSibling = children[nextSiblingIndex];
|
||||
if (nextSibling) {
|
||||
return {
|
||||
targetNodeId: nextSibling.id,
|
||||
mode: TreeViewMoveModeEnum.LEFT,
|
||||
sourceNodeId,
|
||||
oldParentId,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const onMove = (args: {
|
||||
dragIds: string[];
|
||||
dragNodes: NodeApi<BaseType<T>>[];
|
||||
parentId: string | null;
|
||||
parentNode: NodeApi<BaseType<T>> | null;
|
||||
index: number;
|
||||
}) => {
|
||||
// Création d'une copie profonde pour éviter les mutations directes
|
||||
const newData = JSON.parse(
|
||||
JSON.stringify(treeData),
|
||||
) as TreeViewDataType<T>[];
|
||||
const draggedId = args.dragIds[0];
|
||||
|
||||
// Fonction helper pour trouver et supprimer un nœud dans l'arbre
|
||||
const findAndRemoveNode = (
|
||||
items: TreeViewDataType<T>[],
|
||||
parentId?: string,
|
||||
): {
|
||||
currentIndex: number;
|
||||
newIndex: number;
|
||||
parentId?: string;
|
||||
draggedNode: TreeViewDataType<T>;
|
||||
} | null => {
|
||||
items.forEach((item, index) => {
|
||||
if (item.id === draggedId) {
|
||||
return {
|
||||
currentIndex: index,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i].id === draggedId) {
|
||||
const currentIndex = i;
|
||||
let newIndex = args.index;
|
||||
if (currentIndex < newIndex) {
|
||||
newIndex -= 1;
|
||||
}
|
||||
return {
|
||||
currentIndex: i,
|
||||
parentId,
|
||||
newIndex,
|
||||
draggedNode: items.splice(i, 1)[0],
|
||||
};
|
||||
}
|
||||
if (items[i].children?.length) {
|
||||
const found = findAndRemoveNode(
|
||||
items[i]?.children ?? [],
|
||||
items[i].id,
|
||||
);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Trouver et supprimer le nœud déplacé
|
||||
const r = findAndRemoveNode(newData);
|
||||
const draggedNode = r?.draggedNode;
|
||||
const currentIndex = r?.currentIndex ?? -1;
|
||||
const newIndex = r?.newIndex ?? -1;
|
||||
if (!draggedNode || currentIndex < 0 || newIndex < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cas 1: Déplacement à la racine
|
||||
if (!args.parentNode) {
|
||||
draggedNode.parentId = rootNodeId;
|
||||
newData.splice(newIndex, 0, draggedNode);
|
||||
}
|
||||
// Cas 2: Déplacement dans un dossier
|
||||
else {
|
||||
const targetParent = args.parentNode.data;
|
||||
draggedNode.parentId = targetParent.id;
|
||||
const findParentAndInsert = (items: TreeViewDataType<T>[]) => {
|
||||
for (const item of items) {
|
||||
if (item.id === targetParent.id) {
|
||||
item.children = item.children || [];
|
||||
item.children.splice(
|
||||
r.parentId === targetParent.id ? r.newIndex : args.index,
|
||||
0,
|
||||
draggedNode,
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
if (item.children?.length) {
|
||||
if (findParentAndInsert(item.children)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
findParentAndInsert(newData);
|
||||
}
|
||||
|
||||
const moveResult = onMove3(args);
|
||||
if (moveResult) {
|
||||
afterMove?.(moveResult, newData);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Tree
|
||||
data={treeData}
|
||||
openByDefault={false}
|
||||
height={1000}
|
||||
indent={20}
|
||||
width={width}
|
||||
initialOpenState={initialOpenState}
|
||||
selection={selectedNodeId}
|
||||
disableEdit={true}
|
||||
selectionFollowsFocus={true}
|
||||
disableMultiSelection={true}
|
||||
rowHeight={32}
|
||||
overscanCount={20}
|
||||
padding={25}
|
||||
renderCursor={Cursor}
|
||||
onMove={onMove as MoveHandler<TreeViewDataType<T>>}
|
||||
>
|
||||
{(props) => renderNode(props)}
|
||||
</Tree>
|
||||
);
|
||||
};
|
||||
|
||||
function Cursor({ top, left }: CursorProps) {
|
||||
return (
|
||||
<div
|
||||
className={styles.cursor}
|
||||
style={{
|
||||
top,
|
||||
left: left + 10,
|
||||
}}
|
||||
></div>
|
||||
);
|
||||
}
|
||||
|
||||
export type TreeViewNodeProps<T> = NodeRendererProps<TreeViewDataType<T>> & {
|
||||
children: ReactNode;
|
||||
onClick?: () => void;
|
||||
loadChildren?: (node?: TreeViewDataType<T>) => Promise<TreeViewDataType<T>[]>;
|
||||
};
|
||||
|
||||
export const TreeViewNode = <T,>({
|
||||
children,
|
||||
onClick,
|
||||
node,
|
||||
dragHandle,
|
||||
style,
|
||||
loadChildren,
|
||||
}: TreeViewNodeProps<T>) => {
|
||||
/* This node instance can do many things. See the API reference. */
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const hasChildren =
|
||||
(node.data.childrenCount !== undefined && node.data.childrenCount > 0) ||
|
||||
(node.data.children?.length ?? 0) > 0;
|
||||
|
||||
const isLeaf = node.isLeaf || !hasChildren;
|
||||
|
||||
const hasLoadedChildren = node.children?.length ?? 0 > 0;
|
||||
|
||||
const handleClick = useCallback(async () => {
|
||||
if (isLeaf) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasLoadedChildren) {
|
||||
node.toggle();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
await loadChildren?.(node.data);
|
||||
setIsLoading(false);
|
||||
node.open();
|
||||
}, [hasLoadedChildren, loadChildren, node, isLeaf]);
|
||||
|
||||
useEffect(() => {
|
||||
if (node.willReceiveDrop && !node.isOpen) {
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
void handleClick();
|
||||
}, 200);
|
||||
}
|
||||
|
||||
if (timeoutRef.current && !node.willReceiveDrop) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
}, [node, handleClick]);
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
onKeyDown={onClick}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={clsx(styles.node, {
|
||||
[styles.willReceiveDrop]: node.willReceiveDrop,
|
||||
[styles.selected]: node.isSelected,
|
||||
toto: true,
|
||||
})}
|
||||
style={{
|
||||
...style,
|
||||
}}
|
||||
ref={dragHandle}
|
||||
>
|
||||
{isLeaf ? (
|
||||
<Box $padding={{ left: '16px' }} />
|
||||
) : (
|
||||
<>
|
||||
{isLoading ? (
|
||||
<Box>
|
||||
<Loader />
|
||||
</Box>
|
||||
) : (
|
||||
<Icon
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
void handleClick();
|
||||
}}
|
||||
$variation="500"
|
||||
$size="16px"
|
||||
iconName={
|
||||
node.isOpen ? 'keyboard_arrow_down' : 'keyboard_arrow_right'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,186 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { Doc, getDoc } from '@/features/docs';
|
||||
import { TreeViewDataType } from '@/features/docs/doc-tree/types/tree';
|
||||
|
||||
interface TreeStore<T> {
|
||||
treeData: TreeViewDataType<T>[];
|
||||
selectedNode: TreeViewDataType<T> | null;
|
||||
rootId: string | undefined;
|
||||
initialNode: TreeViewDataType<T> | undefined;
|
||||
setInitialNode: (node: TreeViewDataType<T> | undefined) => void;
|
||||
setSelectedNode: (node: TreeViewDataType<T> | null) => void;
|
||||
setTreeData: (data: TreeViewDataType<T>[]) => void;
|
||||
updateNode: (nodeId: string, newData: Partial<TreeViewDataType<T>>) => void;
|
||||
addRootNode: (node: TreeViewDataType<T>) => void;
|
||||
removeNode: (nodeId: string) => void;
|
||||
addChildNode: (parentId: string, newNode: TreeViewDataType<T>) => void;
|
||||
refreshNode: (nodeId: string) => void;
|
||||
findNode: (nodeId: string) => TreeViewDataType<T> | null;
|
||||
setRootId: (id?: string) => void;
|
||||
reset: (
|
||||
rootId?: string,
|
||||
treeData?: TreeViewDataType<T>[],
|
||||
selectedNode?: TreeViewDataType<T> | null,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export const createTreeStore = <T>(
|
||||
refreshCallback?: (id: string) => Promise<Partial<TreeViewDataType<T>>>,
|
||||
) =>
|
||||
create<TreeStore<T>>((set, get) => ({
|
||||
treeData: [],
|
||||
selectedNode: null,
|
||||
rootId: undefined,
|
||||
initialNode: undefined,
|
||||
setSelectedNode: (node) => {
|
||||
set({ selectedNode: node });
|
||||
},
|
||||
setInitialNode: (node) => {
|
||||
set({ initialNode: node });
|
||||
},
|
||||
setTreeData: (data) => {
|
||||
set({ treeData: data });
|
||||
},
|
||||
setRootId: (id) => {
|
||||
set({ rootId: id });
|
||||
},
|
||||
refreshNode: (nodeId) => {
|
||||
set((state) => {
|
||||
const node = state.findNode(nodeId);
|
||||
if (!node) {
|
||||
return state;
|
||||
}
|
||||
refreshCallback?.(nodeId)
|
||||
.then((data) => {
|
||||
console.log('data', data);
|
||||
state.updateNode(nodeId, { ...node, ...data });
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
return state;
|
||||
});
|
||||
},
|
||||
updateNode: (nodeId, newData) => {
|
||||
set((state) => {
|
||||
const updateNodeInTree = (
|
||||
nodes: TreeViewDataType<T>[],
|
||||
): TreeViewDataType<T>[] => {
|
||||
return nodes.map((node) => {
|
||||
if (node.id === nodeId) {
|
||||
return { ...node, ...newData };
|
||||
}
|
||||
if (node.children) {
|
||||
return {
|
||||
...node,
|
||||
children: updateNodeInTree(node.children),
|
||||
};
|
||||
}
|
||||
return node;
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
treeData: updateNodeInTree(state.treeData),
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
addRootNode: (node) => {
|
||||
set((state) => ({
|
||||
treeData: [...state.treeData, node],
|
||||
}));
|
||||
},
|
||||
|
||||
removeNode: (nodeId) => {
|
||||
set((state) => {
|
||||
const removeNodeFromTree = (
|
||||
nodes: TreeViewDataType<T>[],
|
||||
): TreeViewDataType<T>[] => {
|
||||
const filteredNodes = nodes.filter((node) => node.id !== nodeId);
|
||||
|
||||
return filteredNodes.map((node) => {
|
||||
if (node.children) {
|
||||
const children = removeNodeFromTree(node.children);
|
||||
return {
|
||||
...node,
|
||||
children: children,
|
||||
childrenCount: children.length,
|
||||
};
|
||||
}
|
||||
return node;
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
treeData: removeNodeFromTree(state.treeData),
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
addChildNode: (parentId, newNode) => {
|
||||
set((state) => {
|
||||
const addChildToNode = (
|
||||
nodes: TreeViewDataType<T>[],
|
||||
): TreeViewDataType<T>[] => {
|
||||
return nodes.map((node) => {
|
||||
if (node.id === parentId) {
|
||||
return {
|
||||
...node,
|
||||
children: [...(node.children || []), newNode],
|
||||
};
|
||||
}
|
||||
if (node.children) {
|
||||
return {
|
||||
...node,
|
||||
children: addChildToNode(node.children),
|
||||
};
|
||||
}
|
||||
return node;
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
treeData: addChildToNode(state.treeData),
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
findNode: (nodeId) => {
|
||||
const findNodeInTree = (
|
||||
nodes: TreeViewDataType<T>[],
|
||||
): TreeViewDataType<T> | null => {
|
||||
for (const node of nodes) {
|
||||
if (node.id === nodeId) {
|
||||
return node;
|
||||
}
|
||||
if (node.children) {
|
||||
const found = findNodeInTree(node.children);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return findNodeInTree(get().treeData);
|
||||
},
|
||||
reset: (rootId, treeData, selectedNode) => {
|
||||
console.log('reset', rootId, treeData, selectedNode);
|
||||
set({
|
||||
treeData: treeData ?? [],
|
||||
selectedNode: selectedNode ?? null,
|
||||
rootId: rootId,
|
||||
initialNode: selectedNode ?? undefined,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
// Créer une instance du store
|
||||
// export const useTreeStore = createTreeStore();
|
||||
export const useTreeStore = createTreeStore<Doc>(async (docId) => {
|
||||
const doc = await getDoc({ id: docId });
|
||||
return { ...doc, childrenCount: doc.numchild };
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
.container {
|
||||
[role='treeitem'] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.node {
|
||||
width: 100%;
|
||||
// padding-left: 0px !important;
|
||||
// margin-left: 180px;
|
||||
// min-width: 300px;
|
||||
height: calc(100% - 2px);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
border-radius: 4px;
|
||||
border: 1.5px solid rgba(0, 0, 0, 0);
|
||||
cursor: pointer;
|
||||
|
||||
padding: var(--c--theme--spacings--4xs) 0;
|
||||
gap: var(--c--theme--spacings--3xs);
|
||||
|
||||
&:not(.willReceiveDrop, .selected):hover {
|
||||
background-color: var(--c--theme--colors--greyscale-100);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background-color: var(--c--theme--colors--greyscale-100);
|
||||
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&.willReceiveDrop {
|
||||
background-color: var(--c--theme--colors--primary-100);
|
||||
border: 1.5px solid var(--c--theme--colors--primary-500);
|
||||
}
|
||||
}
|
||||
|
||||
.cursor {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 0;
|
||||
border-top: 2px solid var(--c--theme--colors--primary-500);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { Box } from '../Box';
|
||||
import { DropdownMenu, DropdownMenuOption } from '../DropdownMenu';
|
||||
import { Icon } from '../Icon';
|
||||
import { Text } from '../Text';
|
||||
|
||||
export type FilterDropdownProps = {
|
||||
options: DropdownMenuOption[];
|
||||
selectedValue?: string;
|
||||
};
|
||||
|
||||
export const FilterDropdown = ({
|
||||
options,
|
||||
selectedValue,
|
||||
}: FilterDropdownProps) => {
|
||||
const selectedOption = options.find(
|
||||
(option) => option.value === selectedValue,
|
||||
);
|
||||
|
||||
if (options.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
selectedValues={selectedValue ? [selectedValue] : undefined}
|
||||
options={options}
|
||||
>
|
||||
<Box
|
||||
$css={css`
|
||||
border: 1px solid
|
||||
${selectedOption
|
||||
? 'var(--c--theme--colors--primary-500)'
|
||||
: 'var(--c--theme--colors--greyscale-250)'};
|
||||
border-radius: 4px;
|
||||
background-color: ${selectedOption
|
||||
? 'var(--c--theme--colors--primary-100)'
|
||||
: 'var(--c--theme--colors--greyscale-000)'};
|
||||
gap: var(--c--theme--spacings--2xs);
|
||||
padding: var(--c--theme--spacings--2xs) var(--c--theme--spacings--xs);
|
||||
`}
|
||||
color="secondary"
|
||||
$direction="row"
|
||||
$align="center"
|
||||
>
|
||||
<Text
|
||||
$weight={400}
|
||||
$variation={selectedOption ? '800' : '600'}
|
||||
$theme={selectedOption ? 'primary' : 'greyscale'}
|
||||
>
|
||||
{selectedOption?.label ?? options[0].label}
|
||||
</Text>
|
||||
<Icon
|
||||
$size="16px"
|
||||
iconName="keyboard_arrow_down"
|
||||
$variation={selectedOption ? '800' : '600'}
|
||||
$theme={selectedOption ? 'primary' : 'greyscale'}
|
||||
/>
|
||||
</Box>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable jsx-a11y/no-static-element-interactions */
|
||||
import { Command } from 'cmdk';
|
||||
import { ReactNode, useRef } from 'react';
|
||||
|
||||
@@ -48,7 +49,12 @@ export const QuickSearch = ({
|
||||
return (
|
||||
<>
|
||||
<QuickSearchStyle />
|
||||
<div className="quick-search-container">
|
||||
<div
|
||||
className="quick-search-container"
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<Command label={label} shouldFilter={false} ref={ref}>
|
||||
{showInput && (
|
||||
<QuickSearchInput
|
||||
|
||||
@@ -47,11 +47,7 @@ export const QuickSearchInput = ({
|
||||
$gap={spacing['2xs']}
|
||||
$padding={{ all: 'base' }}
|
||||
>
|
||||
{!loading && (
|
||||
<span aria-hidden="true">
|
||||
<Icon iconName="search" $variation="600" />
|
||||
</span>
|
||||
)}
|
||||
{!loading && <Icon iconName="search" $variation="600" />}
|
||||
{loading && (
|
||||
<div>
|
||||
<Loader size="small" />
|
||||
@@ -61,6 +57,9 @@ export const QuickSearchInput = ({
|
||||
/* eslint-disable-next-line jsx-a11y/no-autofocus */
|
||||
autoFocus={true}
|
||||
aria-label={t('Quick search input')}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
value={inputValue}
|
||||
role="combobox"
|
||||
placeholder={placeholder ?? t('Search')}
|
||||
|
||||
@@ -41,7 +41,10 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CunninghamProvider theme={theme}>
|
||||
<ConfigProvider>
|
||||
<Auth>{children}</Auth>
|
||||
<Auth>
|
||||
{children}
|
||||
<div id="modals" />
|
||||
</Auth>
|
||||
</ConfigProvider>
|
||||
</CunninghamProvider>
|
||||
</QueryClientProvider>
|
||||
|
||||
@@ -203,7 +203,6 @@ input:-webkit-autofill:focus {
|
||||
|
||||
.c__select__wrapper .c__select__inner__actions__open:focus {
|
||||
outline: none;
|
||||
|
||||
}
|
||||
|
||||
.c__select__wrapper .labelled-box__label.c__offscreen {
|
||||
@@ -407,6 +406,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(
|
||||
@@ -606,31 +609,3 @@ input:-webkit-autofill:focus {
|
||||
.c__tooltip {
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
/**
|
||||
* lecture ou non des icons
|
||||
*/
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
[data-icon]:before {
|
||||
font-family: 'Material Icons';
|
||||
content: attr(data-icon);
|
||||
}
|
||||
|
||||
button:focus {
|
||||
background-color: var(
|
||||
--c--components--button--primary-text--background--color-hover
|
||||
);
|
||||
border-radius: var(--c--components--button--border-radius--focus);
|
||||
box-shadow: 0 0 0 2px var(--c--theme--colors--primary-400)
|
||||
}
|
||||
@@ -505,6 +505,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(
|
||||
|
||||
@@ -505,6 +505,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' },
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -16,8 +16,11 @@ export const setAuthUrl = () => {
|
||||
}
|
||||
};
|
||||
|
||||
export const gotoLogin = () => {
|
||||
setAuthUrl();
|
||||
export const gotoLogin = (withRedirect = true) => {
|
||||
if (withRedirect) {
|
||||
setAuthUrl();
|
||||
}
|
||||
|
||||
window.location.replace(LOGIN_URL);
|
||||
};
|
||||
|
||||
|
||||
@@ -25,6 +25,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 +76,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 +92,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;
|
||||
|
||||
@@ -27,6 +27,7 @@ export const DocHeader = ({ doc }: DocHeaderProps) => {
|
||||
|
||||
const { t } = useTranslation();
|
||||
const docIsPublic = doc.link_reach === LinkReach.PUBLIC;
|
||||
const docIsAuth = doc.link_reach === LinkReach.AUTHENTICATED;
|
||||
|
||||
const { transRole } = useTrans();
|
||||
|
||||
@@ -38,7 +39,7 @@ export const DocHeader = ({ doc }: DocHeaderProps) => {
|
||||
$gap={spacings['base']}
|
||||
aria-label={t('It is the card information about the document.')}
|
||||
>
|
||||
{docIsPublic && (
|
||||
{(docIsPublic || docIsAuth) && (
|
||||
<Box
|
||||
aria-label={t('Public document')}
|
||||
$color={colors['primary-800']}
|
||||
@@ -57,10 +58,12 @@ export const DocHeader = ({ doc }: DocHeaderProps) => {
|
||||
$theme="primary"
|
||||
$variation="800"
|
||||
data-testid="public-icon"
|
||||
iconName="public"
|
||||
iconName={docIsPublic ? 'public' : 'vpn_lock'}
|
||||
/>
|
||||
<Text $theme="primary" $variation="800">
|
||||
{t('Public document')}
|
||||
{docIsPublic
|
||||
? t('Public document')
|
||||
: t('Document accessible to any connected person')}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
@@ -76,8 +79,9 @@ export const DocHeader = ({ doc }: DocHeaderProps) => {
|
||||
$css="flex:1;"
|
||||
$gap="0.5rem 1rem"
|
||||
$align="center"
|
||||
$maxWidth="100%"
|
||||
>
|
||||
<Box $gap={spacings['3xs']}>
|
||||
<Box $gap={spacings['3xs']} $overflow="auto">
|
||||
<DocTitle doc={doc} />
|
||||
|
||||
<Box $direction="row">
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/* eslint-disable jsx-a11y/click-events-have-key-events */
|
||||
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
|
||||
import {
|
||||
Tooltip,
|
||||
VariantType,
|
||||
@@ -8,6 +10,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { Box, Text } from '@/components';
|
||||
import { useTreeStore } from '@/components/common/tree/treeStore';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import {
|
||||
Doc,
|
||||
@@ -50,22 +53,21 @@ export const DocTitleText = ({ title }: DocTitleTextProps) => {
|
||||
|
||||
const DocTitleInput = ({ doc }: DocTitleProps) => {
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
const { updateNode, setSelectedNode } = useTreeStore();
|
||||
|
||||
const { t } = useTranslation();
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
const [titleDisplay, setTitleDisplay] = useState(doc.title);
|
||||
const { toast } = useToastProvider();
|
||||
const { untitledDocument } = useTrans();
|
||||
const isUntitled = titleDisplay === untitledDocument;
|
||||
|
||||
const { broadcast } = useBroadcastStore();
|
||||
|
||||
const { mutate: updateDoc } = useUpdateDoc({
|
||||
listInvalideQueries: [KEY_DOC, KEY_LIST_DOC],
|
||||
onSuccess(data) {
|
||||
if (data.title !== untitledDocument) {
|
||||
toast(t('Document title updated successfully'), VariantType.SUCCESS);
|
||||
}
|
||||
|
||||
toast(t('Document title updated successfully'), VariantType.SUCCESS);
|
||||
updateNode(doc.id, { title: data.title });
|
||||
// Broadcast to every user connected to the document
|
||||
broadcast(`${KEY_DOC}-${data.id}`);
|
||||
},
|
||||
@@ -78,8 +80,7 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
|
||||
|
||||
// When blank we set to untitled
|
||||
if (!sanitizedTitle) {
|
||||
sanitizedTitle = untitledDocument;
|
||||
setTitleDisplay(sanitizedTitle);
|
||||
setTitleDisplay('');
|
||||
}
|
||||
|
||||
// If mutation we update
|
||||
@@ -88,7 +89,7 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
|
||||
updateDoc({ id: doc.id, title: sanitizedTitle });
|
||||
}
|
||||
},
|
||||
[doc.id, doc.title, untitledDocument, updateDoc],
|
||||
[doc.id, doc.title, updateDoc],
|
||||
);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
@@ -103,38 +104,35 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
|
||||
}, [doc]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip content={t('Rename')} placement="top">
|
||||
<Box
|
||||
as="span"
|
||||
role="textbox"
|
||||
contentEditable
|
||||
defaultValue={isUntitled ? undefined : titleDisplay}
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
suppressContentEditableWarning={true}
|
||||
onBlurCapture={(event) =>
|
||||
handleTitleSubmit(event.target.textContent || '')
|
||||
<Tooltip content={t('Rename')} placement="top">
|
||||
<Box
|
||||
as="span"
|
||||
role="textbox"
|
||||
contentEditable
|
||||
defaultValue={titleDisplay || undefined}
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
suppressContentEditableWarning={true}
|
||||
aria-label="doc title input"
|
||||
onBlurCapture={(event) =>
|
||||
handleTitleSubmit(event.target.textContent || '')
|
||||
}
|
||||
$color={colorsTokens()['greyscale-1000']}
|
||||
$css={css`
|
||||
&[contenteditable='true']:empty:not(:focus):before {
|
||||
content: '${untitledDocument}';
|
||||
color: grey;
|
||||
pointer-events: none;
|
||||
font-style: italic;
|
||||
}
|
||||
$color={colorsTokens()['greyscale-1000']}
|
||||
$margin={{ left: '-2px', right: '10px' }}
|
||||
$css={css`
|
||||
&[contenteditable='true']:empty:not(:focus):before {
|
||||
content: '${untitledDocument}';
|
||||
color: grey;
|
||||
pointer-events: none;
|
||||
font-style: italic;
|
||||
}
|
||||
font-size: ${isDesktop
|
||||
? css`var(--c--theme--font--sizes--h2)`
|
||||
: css`var(--c--theme--font--sizes--sm)`};
|
||||
font-weight: 700;
|
||||
|
||||
outline: none;
|
||||
`}
|
||||
>
|
||||
{isUntitled ? '' : titleDisplay}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</>
|
||||
font-size: ${isDesktop
|
||||
? css`var(--c--theme--font--sizes--h2)`
|
||||
: css`var(--c--theme--font--sizes--sm)`};
|
||||
font-weight: 700;
|
||||
outline: none;
|
||||
`}
|
||||
>
|
||||
{titleDisplay}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -18,7 +18,11 @@ import {
|
||||
} from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { useEditorStore } from '@/features/docs/doc-editor/';
|
||||
import { Doc, ModalRemoveDoc } from '@/features/docs/doc-management';
|
||||
import {
|
||||
Doc,
|
||||
ModalRemoveDoc,
|
||||
useCopyDocLink,
|
||||
} from '@/features/docs/doc-management';
|
||||
import { DocShareModal } from '@/features/docs/doc-share';
|
||||
import {
|
||||
KEY_LIST_DOC_VERSIONS,
|
||||
@@ -34,7 +38,7 @@ interface DocToolBoxProps {
|
||||
|
||||
export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
||||
const { t } = useTranslation();
|
||||
const hasAccesses = doc.nb_accesses > 1;
|
||||
const hasAccesses = doc.nb_accesses_direct > 1 && doc.abilities.accesses_view;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
|
||||
@@ -50,6 +54,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
||||
const { isSmallMobile, isDesktop } = useResponsiveStore();
|
||||
const { editor } = useEditorStore();
|
||||
const { toast } = useToastProvider();
|
||||
const copyDocLink = useCopyDocLink(doc.id);
|
||||
|
||||
const options: DropdownMenuOption[] = [
|
||||
...(isSmallMobile
|
||||
@@ -66,6 +71,11 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
||||
setIsModalExportOpen(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t('Copy link'),
|
||||
icon: 'add_link',
|
||||
callback: copyDocLink,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
|
||||
@@ -184,7 +194,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
||||
}}
|
||||
size={isSmallMobile ? 'small' : 'medium'}
|
||||
>
|
||||
{doc.nb_accesses}
|
||||
{doc.nb_accesses_direct}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
VariantType,
|
||||
useToastProvider,
|
||||
} from '@openfun/cunningham-react';
|
||||
import { pdf } from '@react-pdf/renderer';
|
||||
import { Text as PDFText, pdf } from '@react-pdf/renderer';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
@@ -27,6 +27,8 @@ import { Doc } from '@/features/docs/doc-management';
|
||||
import { TemplatesOrdering, useTemplates } from '../api/useTemplates';
|
||||
import { downloadFile, exportResolveFileUrl } from '../utils';
|
||||
|
||||
import { Table } from './blocks/Table';
|
||||
|
||||
enum DocDownloadFormat {
|
||||
PDF = 'pdf',
|
||||
DOCX = 'docx',
|
||||
@@ -113,7 +115,8 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
|
||||
? 1.5
|
||||
: 1.17;
|
||||
return (
|
||||
<Text
|
||||
<PDFText
|
||||
key={block.id}
|
||||
style={{
|
||||
fontSize: fontSizeEM * FONT_SIZE * PIXELS_PER_POINT,
|
||||
fontWeight: 700,
|
||||
@@ -122,7 +125,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
|
||||
}}
|
||||
>
|
||||
{exporter.transformInlineContent(block.content)}
|
||||
</Text>
|
||||
</PDFText>
|
||||
);
|
||||
},
|
||||
paragraph: (block, exporter) => {
|
||||
@@ -146,11 +149,14 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Text key={block.id}>
|
||||
<PDFText key={block.id}>
|
||||
{exporter.transformInlineContent(block.content)}
|
||||
</Text>
|
||||
</PDFText>
|
||||
);
|
||||
},
|
||||
table: (block, transformer) => {
|
||||
return <Table data={block.content} transformer={transformer} />;
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { TD, TH, TR, Table as TablePDF } from '@ag-media/react-pdf-table';
|
||||
import {
|
||||
DefaultBlockSchema,
|
||||
Exporter,
|
||||
InlineContentSchema,
|
||||
StyleSchema,
|
||||
TableContent,
|
||||
} from '@blocknote/core';
|
||||
import { View } from '@react-pdf/renderer';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
export const Table = (props: {
|
||||
data: TableContent<InlineContentSchema>;
|
||||
transformer: Exporter<
|
||||
DefaultBlockSchema,
|
||||
InlineContentSchema,
|
||||
StyleSchema,
|
||||
unknown,
|
||||
unknown,
|
||||
unknown,
|
||||
unknown
|
||||
>;
|
||||
}) => {
|
||||
return (
|
||||
<TablePDF>
|
||||
{props.data.rows.map((row, index) => {
|
||||
if (index === 0) {
|
||||
return (
|
||||
<TH key={index}>
|
||||
{row.cells.map((cell, index) => {
|
||||
// Make empty cells are rendered.
|
||||
if (cell.length === 0) {
|
||||
cell.push({
|
||||
styles: {},
|
||||
text: ' ',
|
||||
type: 'text',
|
||||
});
|
||||
}
|
||||
return (
|
||||
<TD key={index}>
|
||||
{props.transformer.transformInlineContent(cell)}
|
||||
</TD>
|
||||
);
|
||||
})}
|
||||
</TH>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<TR key={index}>
|
||||
{row.cells.map((cell, index) => {
|
||||
// Make empty cells are rendered.
|
||||
if (cell.length === 0) {
|
||||
cell.push({
|
||||
styles: {},
|
||||
text: ' ',
|
||||
type: 'text',
|
||||
});
|
||||
}
|
||||
return (
|
||||
<TD key={index}>
|
||||
<View>
|
||||
{
|
||||
props.transformer.transformInlineContent(
|
||||
cell,
|
||||
) as ReactNode
|
||||
}
|
||||
</View>
|
||||
</TD>
|
||||
);
|
||||
})}
|
||||
</TR>
|
||||
);
|
||||
})}
|
||||
</TablePDF>
|
||||
);
|
||||
};
|
||||
@@ -6,14 +6,9 @@ import { Doc } from '../types';
|
||||
|
||||
import { KEY_LIST_DOC } from './useDocs';
|
||||
|
||||
export type CreateDocParam = Pick<Doc, 'title'>;
|
||||
|
||||
export const createDoc = async ({ title }: CreateDocParam): Promise<Doc> => {
|
||||
export const createDoc = async (): Promise<Doc> => {
|
||||
const response = await fetchAPI(`documents/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -29,7 +24,7 @@ interface CreateDocProps {
|
||||
|
||||
export function useCreateDoc({ onSuccess }: CreateDocProps) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<Doc, APIError, CreateDocParam>({
|
||||
return useMutation<Doc, APIError>({
|
||||
mutationFn: createDoc,
|
||||
onSuccess: (data) => {
|
||||
void queryClient.resetQueries({
|
||||
|
||||
@@ -28,6 +28,7 @@ export function useDoc(
|
||||
return useQuery<Doc, APIError, Doc>({
|
||||
queryKey: [KEY_DOC, param],
|
||||
queryFn: () => getDoc(param),
|
||||
|
||||
...queryConfig,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
useAPIInfiniteQuery,
|
||||
} from '@/api';
|
||||
|
||||
import { DocSearchTarget } from '../../doc-search/components/DocSearchFilters';
|
||||
import { Doc } from '../types';
|
||||
|
||||
export const isDocsOrdering = (data: string): data is DocsOrdering => {
|
||||
@@ -31,6 +32,8 @@ export type DocsParams = {
|
||||
is_creator_me?: boolean;
|
||||
title?: string;
|
||||
is_favorite?: boolean;
|
||||
target?: DocSearchTarget;
|
||||
parent_id?: string;
|
||||
};
|
||||
|
||||
export type DocsResponse = APIList<Doc>;
|
||||
@@ -53,8 +56,14 @@ export const getDocs = async (params: DocsParams): Promise<DocsResponse> => {
|
||||
if (params.is_favorite !== undefined) {
|
||||
searchParams.set('is_favorite', params.is_favorite.toString());
|
||||
}
|
||||
|
||||
const response = await fetchAPI(`documents/?${searchParams.toString()}`);
|
||||
let response: Response;
|
||||
if (params.parent_id && params.target === DocSearchTarget.CURRENT) {
|
||||
response = await fetchAPI(
|
||||
`documents/${params.parent_id}/descendants/?${searchParams.toString()}`,
|
||||
);
|
||||
} else {
|
||||
response = await fetchAPI(`documents/?${searchParams.toString()}`);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError('Failed to get the docs', await errorCauses(response));
|
||||
|
||||
@@ -17,16 +17,20 @@ import { Doc } from '../types';
|
||||
interface ModalRemoveDocProps {
|
||||
onClose: () => void;
|
||||
doc: Doc;
|
||||
afterDelete?: (doc: Doc) => void;
|
||||
}
|
||||
|
||||
export const ModalRemoveDoc = ({ onClose, doc }: ModalRemoveDocProps) => {
|
||||
export const ModalRemoveDoc = ({
|
||||
onClose,
|
||||
doc,
|
||||
afterDelete,
|
||||
}: ModalRemoveDocProps) => {
|
||||
const { toast } = useToastProvider();
|
||||
const { push } = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
const {
|
||||
mutate: removeDoc,
|
||||
|
||||
isError,
|
||||
error,
|
||||
} = useRemoveDoc({
|
||||
@@ -34,6 +38,11 @@ export const ModalRemoveDoc = ({ onClose, doc }: ModalRemoveDocProps) => {
|
||||
toast(t('The document has been deleted.'), VariantType.SUCCESS, {
|
||||
duration: 4000,
|
||||
});
|
||||
if (afterDelete) {
|
||||
afterDelete(doc);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === '/') {
|
||||
onClose();
|
||||
} else {
|
||||
@@ -71,9 +80,15 @@ export const ModalRemoveDoc = ({ onClose, doc }: ModalRemoveDocProps) => {
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
size={ModalSize.MEDIUM}
|
||||
size={ModalSize.SMALL}
|
||||
title={
|
||||
<Text $size="h6" as="h6" $margin={{ all: '0' }} $align="flex-start">
|
||||
<Text
|
||||
$size="h6"
|
||||
as="h6"
|
||||
$margin={{ all: '0' }}
|
||||
$align="flex-start"
|
||||
$variation="1000"
|
||||
>
|
||||
{t('Delete a doc')}
|
||||
</Text>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Button, Input, Modal, ModalSize } from '@openfun/cunningham-react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box, Text } from '@/components';
|
||||
import { useTreeStore } from '@/components/common/tree/treeStore';
|
||||
|
||||
import { useUpdateDoc } from '../api';
|
||||
import { Doc } from '../types';
|
||||
|
||||
interface ModalRenameDocProps {
|
||||
onClose: () => void;
|
||||
doc: Doc;
|
||||
}
|
||||
|
||||
export const ModalRenameDoc = ({ onClose, doc }: ModalRenameDocProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { updateNode } = useTreeStore();
|
||||
const { mutate: updateDoc } = useUpdateDoc();
|
||||
const [title, setTitle] = useState(doc.title);
|
||||
|
||||
const onRename = () => {
|
||||
updateDoc(
|
||||
{
|
||||
id: doc.id,
|
||||
title,
|
||||
},
|
||||
{
|
||||
onSuccess: (doc) => {
|
||||
updateNode(doc.id, doc);
|
||||
onClose();
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
closeOnClickOutside
|
||||
title={
|
||||
<Text $size="h6" $margin={{ all: '0' }} $align="flex-start">
|
||||
{t('Rename')}
|
||||
</Text>
|
||||
}
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
size={ModalSize.SMALL}
|
||||
rightActions={
|
||||
<>
|
||||
<Button
|
||||
aria-label={t('Close the modal')}
|
||||
color="secondary"
|
||||
fullWidth
|
||||
onClick={() => onClose()}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button aria-label={t('Confirm rename')} fullWidth onClick={onRename}>
|
||||
{t('Rename')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Box $padding={{ top: 'base' }}>
|
||||
<Input
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === 'Enter') {
|
||||
onRename();
|
||||
}
|
||||
}}
|
||||
value={title}
|
||||
label={t('Document name')}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
setTitle(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -42,9 +42,12 @@ export interface Doc {
|
||||
is_favorite: boolean;
|
||||
link_reach: LinkReach;
|
||||
link_role: LinkRole;
|
||||
nb_accesses: number;
|
||||
nb_accesses_direct: number;
|
||||
depth: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
numchild: number;
|
||||
children?: Doc[];
|
||||
abilities: {
|
||||
accesses_manage: boolean;
|
||||
accesses_view: boolean;
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Button } from '@openfun/cunningham-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box } from '@/components';
|
||||
import { FilterDropdown } from '@/components/filter/FilterDropdown';
|
||||
|
||||
export enum DocSearchTarget {
|
||||
ALL = 'all',
|
||||
CURRENT = 'current',
|
||||
}
|
||||
|
||||
export type DocSearchFiltersValues = {
|
||||
target?: DocSearchTarget;
|
||||
};
|
||||
|
||||
export type DocSearchFiltersProps = {
|
||||
values?: DocSearchFiltersValues;
|
||||
onValuesChange?: (values: DocSearchFiltersValues) => void;
|
||||
onReset?: () => void;
|
||||
};
|
||||
|
||||
export const DocSearchFilters = ({
|
||||
values,
|
||||
onValuesChange,
|
||||
onReset,
|
||||
}: DocSearchFiltersProps) => {
|
||||
const { t } = useTranslation();
|
||||
const hasFilters = Object.keys(values ?? {}).length > 0;
|
||||
const handleTargetChange = (target: DocSearchTarget) => {
|
||||
onValuesChange?.({ ...values, target });
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
$direction="row"
|
||||
$align="center"
|
||||
$height="35px"
|
||||
$justify="space-between"
|
||||
$gap="10px"
|
||||
$margin={{ horizontal: 'sm', vertical: 'base' }}
|
||||
>
|
||||
<Box $direction="row" $align="center" $gap="10px">
|
||||
<FilterDropdown
|
||||
selectedValue={values?.target}
|
||||
options={[
|
||||
{
|
||||
label: t('All docs'),
|
||||
value: DocSearchTarget.ALL,
|
||||
callback: () => handleTargetChange(DocSearchTarget.ALL),
|
||||
},
|
||||
{
|
||||
label: t('Current doc'),
|
||||
value: DocSearchTarget.CURRENT,
|
||||
callback: () => handleTargetChange(DocSearchTarget.CURRENT),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Box>
|
||||
{hasFilters && (
|
||||
<Button color="primary-text" size="small" onClick={onReset}>
|
||||
{t('Reset')}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -4,21 +4,34 @@ import { Doc } from '@/features/docs/doc-management';
|
||||
import { SimpleDocItem } from '@/features/docs/docs-grid/';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
import { LightDocItem } from '../../docs-grid/components/LightDocItem';
|
||||
|
||||
type DocSearchItemProps = {
|
||||
doc: Doc;
|
||||
isSubPage?: boolean;
|
||||
};
|
||||
|
||||
export const DocSearchItem = ({ doc }: DocSearchItemProps) => {
|
||||
export const DocSearchItem = ({
|
||||
doc,
|
||||
isSubPage = false,
|
||||
}: DocSearchItemProps) => {
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
|
||||
return (
|
||||
<Box data-testid={`doc-search-item-${doc.id}`} $width="100%">
|
||||
<QuickSearchItemContent
|
||||
left={
|
||||
<Box $direction="row" $align="center" $gap="10px" $width="100%">
|
||||
<Box $flex={isDesktop ? 9 : 1}>
|
||||
<SimpleDocItem doc={doc} showAccesses />
|
||||
<>
|
||||
<Box $direction="row" $align="center" $gap="10px" $width="100%">
|
||||
<Box $flex={isDesktop ? 9 : 1}>
|
||||
{isSubPage ? (
|
||||
<LightDocItem doc={doc} showActions={false} />
|
||||
) : (
|
||||
<SimpleDocItem doc={doc} showAccesses />
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
}
|
||||
right={
|
||||
<Icon iconName="keyboard_return" $theme="primary" $variation="800" />
|
||||
|
||||
@@ -7,6 +7,7 @@ import { InView } from 'react-intersection-observer';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
|
||||
import { Box } from '@/components';
|
||||
import { useTreeStore } from '@/components/common/tree/treeStore';
|
||||
import {
|
||||
QuickSearch,
|
||||
QuickSearchData,
|
||||
@@ -17,15 +18,29 @@ import { useResponsiveStore } from '@/stores';
|
||||
|
||||
import EmptySearchIcon from '../assets/illustration-docs-empty.png';
|
||||
|
||||
import { DocSearchFilters, DocSearchFiltersValues } from './DocSearchFilters';
|
||||
import { DocSearchItem } from './DocSearchItem';
|
||||
|
||||
type DocSearchModalProps = ModalProps & {};
|
||||
type DocSearchModalProps = ModalProps & {
|
||||
showFilters?: boolean;
|
||||
defaultFilters?: DocSearchFiltersValues;
|
||||
};
|
||||
|
||||
export const DocSearchModal = ({ ...modalProps }: DocSearchModalProps) => {
|
||||
export const DocSearchModal = ({
|
||||
showFilters = false,
|
||||
defaultFilters,
|
||||
...modalProps
|
||||
}: DocSearchModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { rootId, initialNode, reset } = useTreeStore();
|
||||
const router = useRouter();
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [filters, setFilters] = useState<DocSearchFiltersValues>(
|
||||
defaultFilters ?? {},
|
||||
);
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
|
||||
const {
|
||||
data,
|
||||
isFetching,
|
||||
@@ -36,27 +51,37 @@ export const DocSearchModal = ({ ...modalProps }: DocSearchModalProps) => {
|
||||
} = useInfiniteDocs({
|
||||
page: 1,
|
||||
title: search,
|
||||
...filters,
|
||||
parent_id: rootId,
|
||||
});
|
||||
const loading = isFetching || isRefetching || isLoading;
|
||||
const handleInputSearch = useDebouncedCallback(setSearch, 300);
|
||||
|
||||
const handleSelect = (doc: Doc) => {
|
||||
if (initialNode?.id !== doc.id) {
|
||||
reset(doc.id, [], doc);
|
||||
}
|
||||
router.push(`/docs/${doc.id}`);
|
||||
modalProps.onClose?.();
|
||||
};
|
||||
|
||||
const handleResetFilters = () => {
|
||||
setFilters({});
|
||||
};
|
||||
|
||||
const docsData: QuickSearchData<Doc> = useMemo(() => {
|
||||
const docs = data?.pages.flatMap((page) => page.results) || [];
|
||||
|
||||
const groupName =
|
||||
filters.target != null ? t('Select a sub-page') : t('Select a page');
|
||||
return {
|
||||
groupName: docs.length > 0 ? t('Select a document') : '',
|
||||
groupName: docs.length > 0 ? groupName : '',
|
||||
elements: search ? docs : [],
|
||||
//emptyString: t('No document found'),
|
||||
emptyString: t('No document found'),
|
||||
endActions: hasNextPage
|
||||
? [{ content: <InView onChange={() => void fetchNextPage()} /> }]
|
||||
: [],
|
||||
};
|
||||
}, [data, hasNextPage, fetchNextPage, t, search]);
|
||||
}, [data, hasNextPage, fetchNextPage, t, search, filters.target]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -75,6 +100,13 @@ export const DocSearchModal = ({ ...modalProps }: DocSearchModalProps) => {
|
||||
onFilter={handleInputSearch}
|
||||
>
|
||||
<Box $height={isDesktop ? '500px' : 'calc(100vh - 68px - 1rem)'}>
|
||||
{showFilters && (
|
||||
<DocSearchFilters
|
||||
values={filters}
|
||||
onValuesChange={setFilters}
|
||||
onReset={handleResetFilters}
|
||||
/>
|
||||
)}
|
||||
{search.length === 0 && (
|
||||
<Box
|
||||
$direction="column"
|
||||
@@ -93,23 +125,11 @@ export const DocSearchModal = ({ ...modalProps }: DocSearchModalProps) => {
|
||||
<QuickSearchGroup
|
||||
onSelect={handleSelect}
|
||||
group={docsData}
|
||||
renderElement={(doc) => <DocSearchItem doc={doc} />}
|
||||
renderElement={(doc) => (
|
||||
<DocSearchItem isSubPage={filters.target != null} doc={doc} />
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{/* Message accessible pour les résultats vides */}
|
||||
{search && docsData.elements.length === 0 && (
|
||||
<p
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
marginTop: '1rem',
|
||||
color: '#666',
|
||||
}}
|
||||
>
|
||||
{t('No document found')}
|
||||
</p>
|
||||
)}
|
||||
</Box>
|
||||
</QuickSearch>
|
||||
</Box>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { css } from 'styled-components';
|
||||
|
||||
import { APIError } from '@/api';
|
||||
import { Box } from '@/components';
|
||||
import { useTreeStore } from '@/components/common/tree/treeStore';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { User } from '@/features/auth';
|
||||
import { Doc, Role } from '@/features/docs';
|
||||
@@ -39,6 +40,7 @@ export const DocShareAddMemberList = ({
|
||||
afterInvite,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { refreshNode } = useTreeStore();
|
||||
const { toast } = useToastProvider();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
|
||||
@@ -94,14 +96,24 @@ export const DocShareAddMemberList = ({
|
||||
};
|
||||
|
||||
return isInvitationMode
|
||||
? createInvitation({
|
||||
...payload,
|
||||
email: user.email,
|
||||
})
|
||||
: createDocAccess({
|
||||
...payload,
|
||||
memberId: user.id,
|
||||
});
|
||||
? createInvitation(
|
||||
{
|
||||
...payload,
|
||||
email: user.email,
|
||||
},
|
||||
{
|
||||
onSuccess: () => refreshNode(doc.id),
|
||||
},
|
||||
)
|
||||
: createDocAccess(
|
||||
{
|
||||
...payload,
|
||||
memberId: user.id,
|
||||
},
|
||||
{
|
||||
onSuccess: () => refreshNode(doc.id),
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const settledPromises = await Promise.allSettled(promises);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user