Compare commits

..

2 Commits

Author SHA1 Message Date
Anthony LC 0815af4977 🚚(frontend) move Blocknote styles
Move Blocknote styles to separate file.
2025-02-04 15:51:28 +01:00
Arnaud Robin bf0b1591b3 (frontend) add Alert, Quote, and Divider blocks to the editor
- Introduced new Alert, Quote, and Divider components for enhanced document editing.
- Updated BlockNoteEditor to include these new block types in the schema.
- Implemented corresponding slash menu items for easy insertion of these blocks.
- Added translations for new block types and their descriptions in the i18n files.
2025-02-04 15:49:49 +01:00
250 changed files with 2508 additions and 9702 deletions
+2 -3
View File
@@ -7,11 +7,10 @@ on:
- 'release/**'
jobs:
install-dependencies:
uses: ./.github/workflows/dependencies.yml
install-front:
uses: ./.github/workflows/front-dependencies-installation.yml
with:
node_version: '20.x'
with-front-dependencies-installation: true
synchronize-with-crowdin:
runs-on: ubuntu-latest
+3 -12
View File
@@ -7,15 +7,13 @@ on:
- main
jobs:
install-dependencies:
uses: ./.github/workflows/dependencies.yml
install-front:
uses: ./.github/workflows/front-dependencies-installation.yml
with:
node_version: '20.x'
with-front-dependencies-installation: true
with-build_mails: true
synchronize-with-crowdin:
needs: install-dependencies
needs: install-front
runs-on: ubuntu-latest
steps:
@@ -31,13 +29,6 @@ jobs:
- name: Install development dependencies
run: pip install --user .
working-directory: src/backend
- name: Restore the mail templates
uses: actions/cache@v4
id: mail-templates
with:
path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
fail-on-cache-miss: true
- name: Install gettext
run: |
sudo apt-get update
-85
View File
@@ -1,85 +0,0 @@
name: Dependency reusable workflow
on:
workflow_call:
inputs:
node_version:
required: false
default: '20.x'
type: string
with-front-dependencies-installation:
type: boolean
default: false
with-build_mails:
type: boolean
default: false
jobs:
front-dependencies-installation:
if: ${{ inputs.with-front-dependencies-installation == true }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Restore the frontend cache
uses: actions/cache@v4
id: front-node_modules
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
- name: Setup Node.js
if: steps.front-node_modules.outputs.cache-hit != 'true'
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node_version }}
- name: Install dependencies
if: steps.front-node_modules.outputs.cache-hit != 'true'
run: cd src/frontend/ && yarn install --frozen-lockfile
- name: Cache install frontend
if: steps.front-node_modules.outputs.cache-hit != 'true'
uses: actions/cache@v4
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
build-mails:
if: ${{ inputs.with-build_mails == true }}
runs-on: ubuntu-latest
defaults:
run:
working-directory: src/mail
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Restore the mail templates
uses: actions/cache@v4
id: mail-templates
with:
path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
- name: Setup Node.js
if: steps.mail-templates.outputs.cache-hit != 'true'
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node_version }}
- name: Install yarn
if: steps.mail-templates.outputs.cache-hit != 'true'
run: npm install -g yarn
- name: Install node dependencies
if: steps.mail-templates.outputs.cache-hit != 'true'
run: yarn install --frozen-lockfile
- name: Build mails
if: steps.mail-templates.outputs.cache-hit != 'true'
run: yarn build
- name: Cache mail templates
if: steps.mail-templates.outputs.cache-hit != 'true'
uses: actions/cache@v4
with:
path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
@@ -0,0 +1,36 @@
name: Install frontend installation reusable workflow
on:
workflow_call:
inputs:
node_version:
required: false
default: '20.x'
type: string
jobs:
front-dependencies-installation:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Restore the frontend cache
uses: actions/cache@v4
id: front-node_modules
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
- name: Setup Node.js
if: steps.front-node_modules.outputs.cache-hit != 'true'
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node_version }}
- name: Install dependencies
if: steps.front-node_modules.outputs.cache-hit != 'true'
run: cd src/frontend/ && yarn install --frozen-lockfile
- name: Cache install frontend
if: steps.front-node_modules.outputs.cache-hit != 'true'
uses: actions/cache@v4
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
+27 -6
View File
@@ -10,14 +10,13 @@ on:
jobs:
install-dependencies:
uses: ./.github/workflows/dependencies.yml
install-front:
uses: ./.github/workflows/front-dependencies-installation.yml
with:
node_version: '20.x'
with-front-dependencies-installation: true
test-front:
needs: install-dependencies
needs: install-front
runs-on: ubuntu-latest
steps:
- name: Checkout repository
@@ -40,7 +39,7 @@ jobs:
lint-front:
runs-on: ubuntu-latest
needs: install-dependencies
needs: install-front
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -61,7 +60,7 @@ jobs:
test-e2e-chromium:
runs-on: ubuntu-latest
needs: install-dependencies
needs: install-front
timeout-minutes: 20
steps:
- name: Checkout repository
@@ -88,6 +87,28 @@ 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'
+41 -7
View File
@@ -9,11 +9,6 @@ on:
- "*"
jobs:
install-dependencies:
uses: ./.github/workflows/dependencies.yml
with:
with-build_mails: true
lint-git:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' # Makes sense only for pull requests
@@ -61,6 +56,46 @@ jobs:
exit 1
fi
build-mails:
runs-on: ubuntu-latest
defaults:
run:
working-directory: src/mail
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: "18"
- name: Restore the mail templates
uses: actions/cache@v4
id: mail-templates
with:
path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
- name: Install yarn
if: steps.mail-templates.outputs.cache-hit != 'true'
run: npm install -g yarn
- name: Install node dependencies
if: steps.mail-templates.outputs.cache-hit != 'true'
run: yarn install --frozen-lockfile
- name: Build mails
if: steps.mail-templates.outputs.cache-hit != 'true'
run: yarn build
- name: Cache mail templates
if: steps.mail-templates.outputs.cache-hit != 'true'
uses: actions/cache@v4
with:
path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
lint-back:
runs-on: ubuntu-latest
defaults:
@@ -86,7 +121,7 @@ jobs:
test-back:
runs-on: ubuntu-latest
needs: install-dependencies
needs: build-mails
defaults:
run:
@@ -134,7 +169,6 @@ jobs:
with:
path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
fail-on-cache-miss: true
- name: Start MinIO
run: |
+23 -42
View File
@@ -6,46 +6,13 @@ 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
- 📝(doc) Add security.md and codeofconduct.md #604
- ✨(frontend) add home page #608
- ✨(frontend) cursor display on activity #609
- ✨(frontend) Add export page break #623
- ✨(frontend) add Alert, Quote, and Divider blocks to the editor #566
## Changed
- 🔧(backend) make AI feature reach configurable #628
## Fixed
- 🌐(CI) Fix email partially translated #616
- 🐛(frontend) fix cursor breakline #609
- 🐛(frontend) fix style pdf export #609
## [2.1.0] - 2025-01-29
@@ -62,7 +29,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
@@ -74,6 +41,7 @@ and this project adheres to
- 🔥(backend) remove "content" field from list serializer # 516
## [2.0.1] - 2025-01-17
## Fixed
@@ -128,11 +96,12 @@ and this project adheres to
- ⚡️(e2e) reduce flakiness on e2e tests #511
## Fixed
## Fixed
- 🐛(frontend) update doc editor height #481
- 💄(frontend) add doc search #485
## [1.9.0] - 2024-12-11
## Added
@@ -154,18 +123,21 @@ 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
@@ -194,6 +166,7 @@ and this project adheres to
- 🐛(frontend) users have view access when revoked #387
- 🐛(frontend) fix placeholder editable when double clicks #454
## [1.7.0] - 2024-10-24
## Added
@@ -221,6 +194,7 @@ and this project adheres to
- 🔥(helm) remove infra related codes #366
## [1.6.0] - 2024-10-17
## Added
@@ -243,6 +217,7 @@ and this project adheres to
- 🐛(backend) fix nginx docker container #340
- 🐛(frontend) fix copy paste firefox #353
## [1.5.1] - 2024-10-10
## Fixed
@@ -277,6 +252,7 @@ and this project adheres to
- 🔧(backend) fix configuration to avoid different ssl warning #297
- 🐛(frontend) fix editor break line not working #302
## [1.4.0] - 2024-09-17
## Added
@@ -297,6 +273,7 @@ and this project adheres to
- 🐛(backend) Fix forcing ID when creating a document via API endpoint #234
- 🐛 Rebuild frontend dev container from makefile #248
## [1.3.0] - 2024-09-05
## Added
@@ -321,6 +298,7 @@ and this project adheres to
- 🔥(frontend) remove saving modal #213
## [1.2.1] - 2024-08-23
## Changed
@@ -328,6 +306,7 @@ and this project adheres to
- ♻️ Change ordering docs datagrid #195
- 🔥(helm) use scaleway email #194
## [1.2.0] - 2024-08-22
## Added
@@ -351,14 +330,14 @@ and this project adheres to
- ⚡️(CI) only e2e chrome mandatory #177
## Removed
- 🔥(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
@@ -369,11 +348,12 @@ 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)
@@ -407,6 +387,7 @@ 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
@@ -414,8 +395,8 @@ and this project adheres to
- ✨(frontend) Coming Soon page (#67)
- 🚀 Impress, project to manage your documents easily and collaboratively.
[unreleased]: https://github.com/numerique-gouv/impress/compare/v2.2.0...main
[v2.2.0]: https://github.com/numerique-gouv/impress/releases/v2.2.0
[unreleased]: https://github.com/numerique-gouv/impress/compare/v2.1.0...main
[v2.1.0]: https://github.com/numerique-gouv/impress/releases/v2.1.0
[v2.0.1]: https://github.com/numerique-gouv/impress/releases/v2.0.1
[v2.0.0]: https://github.com/numerique-gouv/impress/releases/v2.0.0
+17 -13
View File
@@ -44,6 +44,7 @@ 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
@@ -80,12 +81,12 @@ bootstrap: \
data/static \
create-env-files \
build \
run-with-frontend \
migrate \
demo \
back-i18n-compile \
mails-install \
mails-build \
run
mails-build
.PHONY: bootstrap
# -- Docker/compose
@@ -108,7 +109,7 @@ build-yjs-provider: ## build the y-provider container
build-frontend: cache ?=
build-frontend: ## build the frontend container
@$(COMPOSE) build frontend $(cache)
@$(COMPOSE) build frontend-dev $(cache)
.PHONY: build-frontend
down: ## stop and remove containers, networks, images, and volumes
@@ -119,18 +120,19 @@ logs: ## display app-dev logs (follow mode)
@$(COMPOSE) logs -f app-dev
.PHONY: logs
run-backend: ## Start only the backend application and all needed services
run: ## start the wsgi (production) and development server
@$(COMPOSE) up --force-recreate -d celery-dev
@$(COMPOSE) up --force-recreate -d y-provider
@$(COMPOSE) up --force-recreate -d nginx
.PHONY: run-backend
run: ## start the wsgi (production) and development server
run:
@$(MAKE) run-backend
@$(COMPOSE) up --force-recreate -d frontend
@echo "Wait for postgresql to be up..."
@$(WAIT_DB)
.PHONY: run
run-with-frontend: ## Start all the containers needed (backend to frontend)
@$(MAKE) run
@$(COMPOSE) up --force-recreate -d frontend-dev
.PHONY: run-with-frontend
status: ## an alias for "docker compose ps"
@$(COMPOSE) ps
.PHONY: status
@@ -186,12 +188,14 @@ 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
@@ -306,16 +310,16 @@ help:
.PHONY: help
# Front
frontend-development-install: ## install the frontend locally
frontend-install: ## install the frontend locally
cd $(PATH_FRONT_IMPRESS) && yarn
.PHONY: frontend-development-install
.PHONY: frontend-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
@$(COMPOSE) stop frontend-dev
cd $(PATH_FRONT_IMPRESS) && yarn dev
.PHONY: run-frontend-development
+12 -28
View File
@@ -23,7 +23,6 @@ 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
@@ -34,31 +33,23 @@ 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 with your team in real time
* 🔒 Granular access control to ensure your information is secure and only shared with the right people
* 🤝 Collaborate in realtime with your team mates
* 🔒 Granular access control to keep your information secure and shared with the right people
* 📑 Professional document exports in multiple formats (.odt, .doc, .pdf) with customizable templates
* 📚 Built-in wiki functionality to turn your team's collaborative work into organized knowledge `ETA 02/2025`
* 📚 Built-in wiki functionality to transform 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
@@ -66,22 +57,23 @@ $ docker -v
Docker version 20.10.2, build 2291f61
$ docker compose version
$ docker compose -v
Docker Compose version v2.32.4
docker compose version 1.27.4, build 40524192
```
> ⚠️ 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 🎉
@@ -97,7 +89,7 @@ password: impress
📝 Note that if you need to run them afterwards, you can use the eponym Make rule:
```shellscript
$ make run
$ make run-with-frontend
```
⚠️ For the frontend developer, it is often better to run the frontend in development mode locally.
@@ -105,7 +97,7 @@ $ make run
To do so, install the frontend dependencies with the following command:
```shellscript
$ make frontend-development-install
$ make frontend-install
```
And run the frontend locally in development mode with the following command:
@@ -117,7 +109,7 @@ $ make run-frontend-development
To start all the services, except the frontend container, you can use the following command:
```shellscript
$ make run-backend
$ make run
```
**Adding content**
@@ -134,7 +126,6 @@ $ make help
```
**Django admin**
You can access the Django admin site at
<http://localhost:8071/admin>.
@@ -146,21 +137,17 @@ $ 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).
@@ -182,13 +169,10 @@ docs
```
## Credits ❤️
### Stack
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/).
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/)
### 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/).
-5
View File
@@ -15,8 +15,3 @@ the following command inside your docker container:
(Note : in your development environment, you can `make migrate`.)
## [Unreleased]
- AI features are now limited to users who are authenticated. Before this release, even anonymous
users who gained editor access on a document with link reach used to get AI feature.
IF you want anonymous users to keep access on AI features, you must now define the
`AI_ALLOW_REACH_FROM` setting to "public".
+3 -1
View File
@@ -7,6 +7,7 @@ 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
@@ -39,8 +40,9 @@ function _set_user() {
# ARGS : docker compose command arguments
function _docker_compose() {
echo "🐳(compose) file: '${COMPOSE_FILE}'"
echo "🐳(compose) project: '${COMPOSE_PROJECT}' file: '${COMPOSE_FILE}'"
docker compose \
-p "${COMPOSE_PROJECT}" \
-f "${COMPOSE_FILE}" \
--project-directory "${REPO_DIR}" \
"$@"
+16 -54
View File
@@ -1,13 +1,6 @@
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:
@@ -30,11 +23,6 @@ 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:
@@ -43,9 +31,7 @@ services:
createbuckets:
image: minio/mc
depends_on:
minio:
condition: service_healthy
restart: true
- minio
entrypoint: >
sh -c "
/usr/bin/mc alias set impress http://minio:9000 impress password && \
@@ -73,15 +59,10 @@ services:
- ./src/backend:/app
- ./data/static:/data/static
depends_on:
postgresql:
condition: service_healthy
restart: true
mailcatcher:
condition: service_started
redis:
condition: service_started
createbuckets:
condition: service_started
- postgresql
- mailcatcher
- redis
- createbuckets
celery-dev:
user: ${DOCKER_USER:-1000}
@@ -112,13 +93,9 @@ services:
- env.d/development/common
- env.d/development/postgresql
depends_on:
postgresql:
condition: service_healthy
restart: true
redis:
condition: service_started
minio:
condition: service_started
- postgresql
- redis
- minio
celery:
user: ${DOCKER_USER:-1000}
@@ -139,15 +116,11 @@ services:
volumes:
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
depends_on:
app-dev:
condition: service_started
y-provider:
condition: service_started
keycloak:
condition: service_healthy
restart: true
- keycloak
- app-dev
- y-provider
frontend:
frontend-dev:
user: "${DOCKER_USER:-1000}"
build:
context: .
@@ -162,6 +135,9 @@ services:
ports:
- "3000:3000"
dockerize:
image: jwilder/dockerize
crowdin:
image: crowdin/cli:3.16.0
volumes:
@@ -193,11 +169,6 @@ services:
kc_postgresql:
image: postgres:14.3
healthcheck:
test: ["CMD-SHELL", "pg_isready"]
interval: 1s
timeout: 2s
retries: 300
ports:
- "5433:5432"
env_file:
@@ -216,13 +187,6 @@ 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
@@ -236,6 +200,4 @@ services:
ports:
- "8080:8080"
depends_on:
kc_postgresql:
condition: service_healthy
restart: true
- kc_postgresql
@@ -88,11 +88,5 @@ 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;
}
}
+3 -14
View File
@@ -12,26 +12,15 @@ 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
+22 -40
View File
@@ -128,14 +128,26 @@ class TemplateAccessSerializer(BaseAccessSerializer):
read_only_fields = ["id", "abilities"]
class ListDocumentSerializer(serializers.ModelSerializer):
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):
"""Serialize documents with limited fields for display in lists."""
is_favorite = serializers.BooleanField(read_only=True)
nb_accesses_ancestors = serializers.IntegerField(read_only=True)
nb_accesses_direct = serializers.IntegerField(read_only=True)
nb_accesses = serializers.IntegerField(read_only=True)
user_roles = serializers.SerializerMethodField(read_only=True)
abilities = serializers.SerializerMethodField(read_only=True)
class Meta:
model = models.Document
@@ -149,8 +161,7 @@ class ListDocumentSerializer(serializers.ModelSerializer):
"is_favorite",
"link_role",
"link_reach",
"nb_accesses_ancestors",
"nb_accesses_direct",
"nb_accesses",
"numchild",
"path",
"title",
@@ -167,30 +178,13 @@ class ListDocumentSerializer(serializers.ModelSerializer):
"is_favorite",
"link_role",
"link_reach",
"nb_accesses_ancestors",
"nb_accesses_direct",
"nb_accesses",
"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,
@@ -220,8 +214,7 @@ class DocumentSerializer(ListDocumentSerializer):
"is_favorite",
"link_role",
"link_reach",
"nb_accesses_ancestors",
"nb_accesses_direct",
"nb_accesses",
"numchild",
"path",
"title",
@@ -237,8 +230,7 @@ class DocumentSerializer(ListDocumentSerializer):
"is_favorite",
"link_role",
"link_reach",
"nb_accesses_ancestors",
"nb_accesses_direct",
"nb_accesses",
"numchild",
"path",
"updated_at",
@@ -367,7 +359,7 @@ class ServerCreateDocumentSerializer(serializers.Serializer):
raise NotImplementedError("Update is not supported for this serializer.")
class LinkDocumentSerializer(serializers.ModelSerializer):
class LinkDocumentSerializer(BaseResourceSerializer):
"""
Serialize link configuration for documents.
We expose it separately from document in order to simplify and secure access control.
@@ -437,12 +429,9 @@ class FileUploadSerializer(serializers.Serializer):
return attrs
class TemplateSerializer(serializers.ModelSerializer):
class TemplateSerializer(BaseResourceSerializer):
"""Serialize templates."""
abilities = serializers.SerializerMethodField(read_only=True)
accesses = TemplateAccessSerializer(many=True, read_only=True)
class Meta:
model = models.Template
fields = [
@@ -456,13 +445,6 @@ class TemplateSerializer(serializers.ModelSerializer):
]
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):
-29
View File
@@ -11,35 +11,6 @@ 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.
+40 -148
View File
@@ -20,6 +20,7 @@ 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
@@ -29,7 +30,7 @@ from core.services.ai_services import AIService
from core.services.collaboration_services import CollaborationService
from . import permissions, serializers, utils
from .filters import DocumentFilter, ListDocumentFilter
from .filters import DocumentFilter
logger = logging.getLogger(__name__)
@@ -314,6 +315,7 @@ class DocumentViewSet(
SerializerPerActionMixin,
drf.mixins.CreateModelMixin,
drf.mixins.DestroyModelMixin,
drf.mixins.ListModelMixin,
drf.mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
@@ -411,21 +413,20 @@ 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
tree_serializer_class = serializers.ListDocumentSerializer
children_serializer_class = serializers.ListDocumentSerializer
ai_translate_serializer_class = serializers.AITranslateSerializer
def annotate_is_favorite(self, queryset):
"""
@@ -498,38 +499,8 @@ class DocumentViewSet(
)
def filter_queryset(self, queryset):
"""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(
"""Apply annotations and filters sequentially."""
filterset = DocumentFilter(
self.request.GET, queryset=queryset, request=self.request
)
filterset.is_valid()
@@ -541,19 +512,22 @@ class DocumentViewSet(
queryset = self.annotate_user_roles(queryset)
# Among the results, we may have documents that are ancestors/descendants
# of each other. In this case we want to keep only the highest ancestors.
root_paths = utils.filter_root_paths(
queryset.order_by("path").values_list("path", flat=True),
skip_sorting=True,
)
queryset = queryset.filter(path__in=root_paths)
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)
# 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 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 favorite status and filter if applicable as late as possible
queryset = self.annotate_is_favorite(queryset)
@@ -562,11 +536,18 @@ class DocumentViewSet(
)
# Apply ordering only now that everyting is filtered and annotated
queryset = filters.OrderingFilter().filter_queryset(
self.request, queryset, self
)
return filters.OrderingFilter().filter_queryset(self.request, queryset, self)
return self.get_response_for_queryset(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)
result = self.get_paginated_response(serializer.data)
return result
serializer = self.get_serializer(queryset, many=True)
return drf.response.Response(serializer.data)
def retrieve(self, request, *args, **kwargs):
"""
@@ -619,7 +600,7 @@ class DocumentViewSet(
user=user
).values_list("document_id", flat=True)
queryset = self.filter_queryset(self.get_queryset())
queryset = self.get_queryset()
queryset = queryset.filter(id__in=favorite_documents_ids)
return self.get_response_for_queryset(queryset)
@@ -746,6 +727,7 @@ 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"""
@@ -777,101 +759,11 @@ class DocumentViewSet(
)
# GET: List children
queryset = document.get_children().filter(ancestors_deleted_at__isnull=True)
queryset = document.get_children().filter(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 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)
)
queryset = self.annotate_user_roles(queryset)
return self.get_response_for_queryset(queryset)
@drf.decorators.action(detail=True, methods=["get"], url_path="versions")
def versions_list(self, request, *args, **kwargs):
+97 -483
View File
@@ -1,552 +1,166 @@
# 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):
dependencies = [
("core", "0001_initial"),
('core', '0001_initial'),
]
operations = [
@@ -1,114 +1,52 @@
# Generated by Django 5.1 on 2024-09-08 16:55
import uuid
import django.db.models.deletion
import uuid
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,14 +1,13 @@
# 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):
@@ -17,20 +16,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,16 +4,15 @@ 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,34 +4,25 @@ 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):
dependencies = [
("core", "0006_add_user_full_name_and_short_name"),
('core', '0006_add_user_full_name_and_short_name'),
]
operations = [
@@ -4,22 +4,15 @@ 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,87 +1,37 @@
# Generated by Django 5.1.2 on 2024-11-08 07:59
import uuid
import django.db.models.deletion
import uuid
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,48 +7,25 @@ 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, OuterRef, Q, Subquery
from django.db.models import F, ForeignKey, Subquery, OuterRef, Q
def set_creator_from_document_access(apps, schema_editor):
@@ -25,37 +25,28 @@ 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,42 +6,25 @@ 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):
dependencies = [
("core", "0012_make_document_creator_and_invitation_issuer_optional"),
('core', '0012_make_document_creator_and_invitation_issuer_optional'),
]
operations = [
@@ -4,29 +4,28 @@ 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,10 +7,9 @@ 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
@@ -27,25 +26,27 @@ 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,27 +4,20 @@ 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,49 +4,33 @@ 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'),
),
]
@@ -1,24 +0,0 @@
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
),
]
+43 -190
View File
@@ -6,7 +6,6 @@ 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
@@ -30,7 +29,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, MP_NodeManager, MP_NodeQuerySet
from treebeard.mp_tree import MP_Node
logger = getLogger(__name__)
@@ -81,55 +80,6 @@ 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."""
@@ -417,51 +367,6 @@ 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."""
@@ -494,8 +399,6 @@ 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",)
@@ -652,47 +555,24 @@ class Document(MP_Node, BaseModel):
"""Generate a unique cache key for each document."""
return f"document_{self.id!s}_nb_accesses"
def get_nb_accesses(self):
"""
Calculate the number of accesses:
- directly attached to the document
- attached to any of the document's ancestors
"""
@property
def nb_accesses(self):
"""Calculate the number of accesses."""
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=self).count(),
DocumentAccess.objects.filter(
document__path=Left(
models.Value(self.path), Length("document__path")
),
document__ancestors_deleted_at__isnull=True,
).count(),
)
nb_accesses = DocumentAccess.objects.filter(
document__path=Left(models.Value(self.path), Length("document__path")),
).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)
@@ -716,27 +596,25 @@ class Document(MP_Node, BaseModel):
roles = []
return roles
def get_links_definitions(self, ancestors_links):
@cached_property
def links_definitions(self):
"""Get links reach/role definitions for the current document and its ancestors."""
links_definitions = {self.link_reach: {self.link_role}}
links_definitions = defaultdict(set)
links_definitions[self.link_reach].add(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"]
)
# Merge ancestor link definitions
for ancestor in ancestors_links:
links_definitions[ancestor["link_reach"]].add(ancestor["link_role"])
return links_definitions
return dict(links_definitions) # Convert defaultdict back to a normal dict
def get_abilities(self, user, ancestors_links=None):
def get_abilities(self, user):
"""
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
@@ -751,12 +629,11 @@ class Document(MP_Node, BaseModel):
# which date to allow them anyway)
# Anonymous users should also not see document accesses
has_access_role = bool(roles) and not is_deleted
can_update_from_access = (
is_owner_or_admin or RoleChoices.EDITOR in roles
) and not is_deleted
# Add roles provided by the document link, taking into account its ancestors
links_definitions = self.get_links_definitions(ancestors_links)
# Add roles provided by the document link
links_definitions = self.links_definitions
public_roles = links_definitions.get(LinkReachChoices.PUBLIC, set())
authenticated_roles = (
links_definitions.get(LinkReachChoices.AUTHENTICATED, set())
@@ -770,28 +647,15 @@ class Document(MP_Node, BaseModel):
is_owner_or_admin or RoleChoices.EDITOR in roles
) and not is_deleted
ai_allow_reach_from = settings.AI_ALLOW_REACH_FROM
ai_access = any(
[
ai_allow_reach_from == LinkReachChoices.PUBLIC and can_update,
ai_allow_reach_from == LinkReachChoices.AUTHENTICATED
and user.is_authenticated
and can_update,
ai_allow_reach_from == LinkReachChoices.RESTRICTED
and can_update_from_access,
]
)
return {
"accesses_manage": is_owner_or_admin,
"accesses_view": has_access_role,
"ai_transform": ai_access,
"ai_translate": ai_access,
"ai_transform": can_update,
"ai_translate": can_update,
"attachment_upload": can_update,
"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,
@@ -801,8 +665,6 @@ 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,
@@ -873,26 +735,19 @@ 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._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()
):
if self.deleted_at or self.ancestors_deleted_at:
raise RuntimeError(
_("This document is already deleted or has deleted ancestors.")
"This document is already deleted or has deleted ancestors."
)
# Check if any ancestors are deleted
if self.get_ancestors().filter(deleted_at__isnull=False).exists():
raise RuntimeError(
"Cannot delete this document because one or more ancestors are already deleted."
)
self.ancestors_deleted_at = self.deleted_at = timezone.now()
self.save()
self.invalidate_nb_accesses_cache()
if self.depth > 1:
self._meta.model.objects.filter(pk=self.get_parent().pk).update(
numchild=models.F("numchild") - 1
)
# Mark all descendants as soft deleted
self.get_descendants().filter(ancestors_deleted_at__isnull=True).update(
@@ -903,14 +758,18 @@ class Document(MP_Node, BaseModel):
def restore(self):
"""Cancelling a soft delete with checks."""
# This should not happen
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 is None:
raise ValidationError({"deleted_at": [_("This document is not deleted.")]})
if self.deleted_at < get_trashbin_cutoff():
raise RuntimeError(
_("This document was permanently deleted and cannot be restored.")
raise ValidationError(
{
"deleted_at": [
_(
"This document was permanently deleted and cannot be restored."
)
]
}
)
# Restore the current document
@@ -924,15 +783,9 @@ 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 be good)
# current document (the ancestor_deleted_at date for those should already by good)
# The number of deleted descendants should not be too big so we can handcraft a union
# clause for them:
deleted_descendants_paths = (
@@ -458,10 +458,6 @@ def test_api_document_invitations_create_email_from_content_language():
email_content = " ".join(email.body.split())
assert f"{user.full_name} a partagé un document avec vous!" in email_content
assert (
"Docs, votre nouvel outil incontournable pour organiser, partager et collaborer "
"sur vos documents en équipe." in email_content
)
def test_api_document_invitations_create_email_from_content_language_not_supported():
@@ -2,7 +2,6 @@
Test AI transform API endpoint for users in impress's core app.
"""
import random
from unittest.mock import MagicMock, patch
from django.core.cache import cache
@@ -32,9 +31,6 @@ def ai_settings():
yield
@override_settings(
AI_ALLOW_REACH_FROM=random.choice(["public", "authenticated", "restricted"])
)
@pytest.mark.parametrize(
"reach, role",
[
@@ -61,7 +57,6 @@ def test_api_documents_ai_transform_anonymous_forbidden(reach, role):
}
@override_settings(AI_ALLOW_REACH_FROM="public")
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_transform_anonymous_success(mock_create):
@@ -98,27 +93,6 @@ def test_api_documents_ai_transform_anonymous_success(mock_create):
)
@override_settings(AI_ALLOW_REACH_FROM=random.choice(["authenticated", "restricted"]))
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_transform_anonymous_limited_by_setting(mock_create):
"""
Anonymous users should be able to request AI transform to a document
if the link reach and role permit it.
"""
document = factories.DocumentFactory(link_reach="public", link_role="editor")
answer = '{"answer": "Salut"}'
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=answer))]
)
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
response = APIClient().post(url, {"text": "Hello", "action": "summarize"})
assert response.status_code == 401
@pytest.mark.parametrize(
"reach, role",
[
@@ -2,7 +2,6 @@
Test AI translate API endpoint for users in impress's core app.
"""
import random
from unittest.mock import MagicMock, patch
from django.core.cache import cache
@@ -52,9 +51,6 @@ def test_api_documents_ai_translate_viewset_options_metadata():
}
@override_settings(
AI_ALLOW_REACH_FROM=random.choice(["public", "authenticated", "restricted"])
)
@pytest.mark.parametrize(
"reach, role",
[
@@ -81,7 +77,6 @@ def test_api_documents_ai_translate_anonymous_forbidden(reach, role):
}
@override_settings(AI_ALLOW_REACH_FROM="public")
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_translate_anonymous_success(mock_create):
@@ -118,27 +113,6 @@ def test_api_documents_ai_translate_anonymous_success(mock_create):
)
@override_settings(AI_ALLOW_REACH_FROM=random.choice(["authenticated", "restricted"]))
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_translate_anonymous_limited_by_setting(mock_create):
"""
Anonymous users should be able to request AI translate to a document
if the link reach and role permit it.
"""
document = factories.DocumentFactory(link_reach="public", link_role="editor")
answer = '{"answer": "Salut"}'
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=answer))]
)
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
response = APIClient().post(url, {"text": "Hello", "language": "es"})
assert response.status_code == 401
@pytest.mark.parametrize(
"reach, role",
[
@@ -1,5 +1,5 @@
"""
Tests for Documents API endpoint in impress's core app: children create
Tests for Documents API endpoint in impress's core app: create
"""
from uuid import uuid4
@@ -1,5 +1,5 @@
"""
Tests for Documents API endpoint in impress's core app: children list
Tests for Documents API endpoint in impress's core app: retrieve
"""
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 document."""
"""Anonymous users should be allowed to retrieve the children of a public documents."""
document = factories.DocumentFactory(link_reach="public")
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
factories.UserDocumentAccessFactory(document=child1)
@@ -39,8 +39,7 @@ def test_api_documents_children_list_anonymous_public_standalone():
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 0,
"nb_accesses_ancestors": 1,
"nb_accesses_direct": 1,
"nb_accesses": 1,
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
@@ -57,8 +56,7 @@ def test_api_documents_children_list_anonymous_public_standalone():
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
"nb_accesses_ancestors": 0,
"nb_accesses_direct": 0,
"nb_accesses": 0,
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
@@ -102,8 +100,7 @@ def test_api_documents_children_list_anonymous_public_parent():
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 0,
"nb_accesses_ancestors": 1,
"nb_accesses_direct": 1,
"nb_accesses": 1,
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
@@ -120,8 +117,7 @@ def test_api_documents_children_list_anonymous_public_parent():
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
"nb_accesses_ancestors": 0,
"nb_accesses_direct": 0,
"nb_accesses": 0,
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
@@ -183,8 +179,7 @@ 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_ancestors": 1,
"nb_accesses_direct": 1,
"nb_accesses": 1,
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
@@ -201,8 +196,7 @@ 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_ancestors": 0,
"nb_accesses_direct": 0,
"nb_accesses": 0,
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
@@ -250,8 +244,7 @@ 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_ancestors": 1,
"nb_accesses_direct": 1,
"nb_accesses": 1,
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
@@ -268,8 +261,7 @@ 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_ancestors": 0,
"nb_accesses_direct": 0,
"nb_accesses": 0,
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
@@ -339,8 +331,7 @@ def test_api_documents_children_list_authenticated_related_direct():
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 0,
"nb_accesses_ancestors": 3,
"nb_accesses_direct": 1,
"nb_accesses": 3,
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
@@ -357,8 +348,7 @@ def test_api_documents_children_list_authenticated_related_direct():
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
"nb_accesses_ancestors": 2,
"nb_accesses_direct": 0,
"nb_accesses": 2,
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
@@ -409,8 +399,7 @@ def test_api_documents_children_list_authenticated_related_parent():
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 0,
"nb_accesses_ancestors": 2,
"nb_accesses_direct": 1,
"nb_accesses": 2,
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
@@ -427,8 +416,7 @@ def test_api_documents_children_list_authenticated_related_parent():
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
"nb_accesses_ancestors": 1,
"nb_accesses_direct": 0,
"nb_accesses": 1,
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
@@ -526,8 +514,7 @@ def test_api_documents_children_list_authenticated_related_team_members(
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 0,
"nb_accesses_ancestors": 1,
"nb_accesses_direct": 0,
"nb_accesses": 1,
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
@@ -544,8 +531,7 @@ def test_api_documents_children_list_authenticated_related_team_members(
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
"nb_accesses_ancestors": 1,
"nb_accesses_direct": 0,
"nb_accesses": 1,
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
@@ -1,696 +0,0 @@
"""
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],
},
],
}
@@ -1,88 +0,0 @@
"""
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,8 +70,7 @@ def test_api_documents_list_format():
"is_favorite": True,
"link_reach": document.link_reach,
"link_role": document.link_role,
"nb_accesses_ancestors": 3,
"nb_accesses_direct": 3,
"nb_accesses": 3,
"numchild": 0,
"path": document.path,
"title": document.title,
@@ -148,7 +147,7 @@ def test_api_documents_list_authenticated_direct(django_assert_num_queries):
str(child4_with_access.id),
}
with django_assert_num_queries(12):
with django_assert_num_queries(8):
response = client.get("/api/v1.0/documents/")
# nb_accesses should now be cached
@@ -186,7 +185,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(14):
with django_assert_num_queries(9):
response = client.get("/api/v1.0/documents/")
# nb_accesses should now be cached
@@ -219,7 +218,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(6):
with django_assert_num_queries(5):
response = client.get("/api/v1.0/documents/")
# nb_accesses should now be cached
@@ -268,7 +267,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(10):
with django_assert_num_queries(7):
response = client.get("/api/v1.0/documents/")
# nb_accesses should now be cached
@@ -329,35 +328,6 @@ 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()
@@ -392,7 +362,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(14):
with django_assert_num_queries(9):
response = client.get(url)
# nb_accesses should now be cached
@@ -28,29 +28,22 @@ def test_api_documents_retrieve_anonymous_public_standalone():
"abilities": {
"accesses_manage": False,
"accesses_view": False,
"ai_transform": False,
"ai_translate": False,
"ai_transform": document.link_role == "editor",
"ai_translate": document.link_role == "editor",
"attachment_upload": document.link_role == "editor",
"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,
@@ -64,8 +57,7 @@ def test_api_documents_retrieve_anonymous_public_standalone():
"is_favorite": False,
"link_reach": "public",
"link_role": document.link_role,
"nb_accesses_ancestors": 0,
"nb_accesses_direct": 0,
"nb_accesses": 0,
"numchild": 0,
"path": document.path,
"title": document.title,
@@ -87,31 +79,27 @@ 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": {
"accesses_manage": False,
"accesses_view": False,
"ai_transform": False,
"ai_translate": False,
"ai_transform": grand_parent.link_role == "editor",
"ai_translate": grand_parent.link_role == "editor",
"attachment_upload": grand_parent.link_role == "editor",
"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,
@@ -125,8 +113,7 @@ def test_api_documents_retrieve_anonymous_public_parent():
"is_favorite": False,
"link_reach": document.link_reach,
"link_role": document.link_role,
"nb_accesses_ancestors": 0,
"nb_accesses_direct": 0,
"nb_accesses": 0,
"numchild": 0,
"path": document.path,
"title": document.title,
@@ -193,22 +180,15 @@ 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,
@@ -222,8 +202,7 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
"is_favorite": False,
"link_reach": reach,
"link_role": document.link_role,
"nb_accesses_ancestors": 0,
"nb_accesses_direct": 0,
"nb_accesses": 0,
"numchild": 0,
"path": document.path,
"title": document.title,
@@ -253,7 +232,6 @@ 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": {
@@ -265,18 +243,15 @@ 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,
@@ -290,8 +265,7 @@ 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_ancestors": 0,
"nb_accesses_direct": 0,
"nb_accesses": 0,
"numchild": 0,
"path": document.path,
"title": document.title,
@@ -400,8 +374,7 @@ def test_api_documents_retrieve_authenticated_related_direct():
"is_favorite": False,
"link_reach": document.link_reach,
"link_role": document.link_role,
"nb_accesses_ancestors": 2,
"nb_accesses_direct": 2,
"nb_accesses": 2,
"numchild": 0,
"path": document.path,
"title": document.title,
@@ -431,7 +404,6 @@ 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": {
@@ -443,18 +415,15 @@ 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,
@@ -468,8 +437,7 @@ def test_api_documents_retrieve_authenticated_related_parent():
"is_favorite": False,
"link_reach": "restricted",
"link_role": document.link_role,
"nb_accesses_ancestors": 2,
"nb_accesses_direct": 0,
"nb_accesses": 2,
"numchild": 0,
"path": document.path,
"title": document.title,
@@ -497,8 +465,7 @@ 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_ancestors"] == 3
assert response.json()["nb_accesses_direct"] == 1
assert response.json()["nb_accesses"] == 3
factories.UserDocumentAccessFactory(document=grand_parent)
@@ -506,8 +473,7 @@ 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_ancestors"] == 4
assert response.json()["nb_accesses_direct"] == 1
assert response.json()["nb_accesses"] == 4
def test_api_documents_retrieve_authenticated_related_child():
@@ -588,10 +554,12 @@ 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"
)
@@ -620,8 +588,7 @@ def test_api_documents_retrieve_authenticated_related_team_members(
"is_favorite": False,
"link_reach": "restricted",
"link_role": document.link_role,
"nb_accesses_ancestors": 5,
"nb_accesses_direct": 5,
"nb_accesses": 5,
"numchild": 0,
"path": document.path,
"title": document.title,
@@ -682,8 +649,7 @@ def test_api_documents_retrieve_authenticated_related_team_administrators(
"is_favorite": False,
"link_reach": "restricted",
"link_role": document.link_role,
"nb_accesses_ancestors": 5,
"nb_accesses_direct": 5,
"nb_accesses": 5,
"numchild": 0,
"path": document.path,
"title": document.title,
@@ -744,8 +710,7 @@ def test_api_documents_retrieve_authenticated_related_team_owners(
"is_favorite": False,
"link_reach": "restricted",
"link_role": document.link_role,
"nb_accesses_ancestors": 5,
"nb_accesses_direct": 5,
"nb_accesses": 5,
"numchild": 0,
"path": document.path,
"title": document.title,
@@ -754,7 +719,7 @@ def test_api_documents_retrieve_authenticated_related_team_owners(
}
def test_api_documents_retrieve_user_roles(django_assert_max_num_queries):
def test_api_documents_retrieve_user_roles(django_assert_num_queries):
"""
Roles should be annotated on querysets taking into account all documents ancestors.
"""
@@ -779,7 +744,7 @@ def test_api_documents_retrieve_user_roles(django_assert_max_num_queries):
)
expected_roles = {access.role for access in accesses}
with django_assert_max_num_queries(12):
with django_assert_num_queries(10):
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
assert response.status_code == 200
@@ -796,7 +761,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(5):
with django_assert_num_queries(4):
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
with django_assert_num_queries(3):
@@ -78,22 +78,15 @@ 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,
@@ -105,8 +98,7 @@ def test_api_documents_trashbin_format():
"excerpt": document.excerpt,
"link_reach": document.link_reach,
"link_role": document.link_role,
"nb_accesses_ancestors": 0,
"nb_accesses_direct": 3,
"nb_accesses": 3,
"numchild": 0,
"path": document.path,
"title": document.title,
@@ -155,7 +147,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(10):
with django_assert_num_queries(7):
response = client.get("/api/v1.0/documents/trashbin/")
with django_assert_num_queries(4):
@@ -197,7 +189,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(7):
with django_assert_num_queries(5):
response = client.get("/api/v1.0/documents/trashbin/")
with django_assert_num_queries(3):
File diff suppressed because it is too large Load Diff
@@ -275,8 +275,7 @@ def test_api_documents_update_authenticated_editor_administrator_or_owner(
"depth",
"link_reach",
"link_role",
"nb_accesses_ancestors",
"nb_accesses_direct",
"nb_accesses",
"numchild",
"path",
]:
@@ -1,35 +0,0 @@
import pytest
from core import factories
@pytest.mark.django_db
def test_update_blank_title_migration(migrator):
"""
Test that the migration fixes the titles of documents that are
"Untitled document", "Unbenanntes Dokument" or "Document sans titre"
"""
migrator.apply_initial_migration(("core", "0017_add_fields_for_soft_delete"))
english_doc = factories.DocumentFactory(title="Untitled document")
german_doc = factories.DocumentFactory(title="Unbenanntes Dokument")
french_doc = factories.DocumentFactory(title="Document sans titre")
other_doc = factories.DocumentFactory(title="My document")
assert english_doc.title == "Untitled document"
assert german_doc.title == "Unbenanntes Dokument"
assert french_doc.title == "Document sans titre"
assert other_doc.title == "My document"
# Apply the migration
migrator.apply_tested_migration(("core", "0018_update_blank_title"))
english_doc.refresh_from_db()
german_doc.refresh_from_db()
french_doc.refresh_from_db()
other_doc.refresh_from_db()
assert english_doc.title == None
assert german_doc.title == None
assert french_doc.title == None
assert other_doc.title == "My document"
@@ -1,107 +0,0 @@
"""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
+32 -473
View File
@@ -1,7 +1,6 @@
"""
Unit tests for the Document model
"""
# pylint: disable=too-many-lines
import random
import smtplib
@@ -13,7 +12,6 @@ from django.core import mail
from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.core.files.storage import default_storage
from django.test.utils import override_settings
from django.utils import timezone
import pytest
@@ -126,9 +124,6 @@ def test_models_documents_soft_delete(depth):
# get_abilities
@override_settings(
AI_ALLOW_REACH_FROM=random.choice(["public", "authenticated", "restricted"])
)
@pytest.mark.parametrize(
"is_authenticated,reach,role",
[
@@ -158,22 +153,15 @@ 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,
@@ -187,9 +175,6 @@ def test_models_documents_get_abilities_forbidden(
assert document.get_abilities(user) == expected_abilities
@override_settings(
AI_ALLOW_REACH_FROM=random.choice(["public", "authenticated", "restricted"])
)
@pytest.mark.parametrize(
"is_authenticated,reach",
[
@@ -216,22 +201,15 @@ 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,
@@ -240,14 +218,9 @@ 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 key, value in document.get_abilities(user).items()
if key != "link_select_options"
)
assert all(value is False for value in document.get_abilities(user).values())
@pytest.mark.parametrize(
@@ -270,28 +243,21 @@ def test_models_documents_get_abilities_editor(
expected_abilities = {
"accesses_manage": False,
"accesses_view": False,
"ai_transform": is_authenticated,
"ai_translate": is_authenticated,
"ai_transform": True,
"ai_translate": True,
"attachment_upload": True,
"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,
@@ -302,16 +268,9 @@ 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 key, value in document.get_abilities(user).items()
if key != "link_select_options"
)
assert all(value is False for value in document.get_abilities(user).values())
@override_settings(
AI_ALLOW_REACH_FROM=random.choice(["public", "authenticated", "restricted"])
)
def test_models_documents_get_abilities_owner(django_assert_num_queries):
"""Check abilities returned for the owner of a document."""
user = factories.UserFactory()
@@ -325,22 +284,15 @@ 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,
@@ -348,16 +300,12 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
}
with django_assert_num_queries(1):
assert document.get_abilities(user) == expected_abilities
document.soft_delete()
document.refresh_from_db()
expected_abilities["move"] = False
assert document.get_abilities(user) == expected_abilities
@override_settings(
AI_ALLOW_REACH_FROM=random.choice(["public", "authenticated", "restricted"])
)
def test_models_documents_get_abilities_administrator(django_assert_num_queries):
"""Check abilities returned for the administrator of a document."""
user = factories.UserFactory()
@@ -371,22 +319,15 @@ 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,
@@ -394,19 +335,11 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries)
}
with django_assert_num_queries(1):
assert document.get_abilities(user) == expected_abilities
document.soft_delete()
document.refresh_from_db()
assert all(
value is False
for key, value in document.get_abilities(user).items()
if key != "link_select_options"
)
assert all(value is False for value in document.get_abilities(user).values())
@override_settings(
AI_ALLOW_REACH_FROM=random.choice(["public", "authenticated", "restricted"])
)
def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
"""Check abilities returned for the editor of a document."""
user = factories.UserFactory()
@@ -420,22 +353,15 @@ 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,
@@ -443,72 +369,46 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
}
with django_assert_num_queries(1):
assert document.get_abilities(user) == expected_abilities
document.soft_delete()
document.refresh_from_db()
assert all(
value is False
for key, value in document.get_abilities(user).items()
if key != "link_select_options"
)
assert all(value is False for value in document.get_abilities(user).values())
@pytest.mark.parametrize("ai_access_setting", ["public", "authenticated", "restricted"])
def test_models_documents_get_abilities_reader_user(
ai_access_setting, django_assert_num_queries
):
def test_models_documents_get_abilities_reader_user(django_assert_num_queries):
"""Check abilities returned for the reader of a document."""
user = factories.UserFactory()
document = factories.DocumentFactory(users=[(user, "reader")])
access_from_link = (
document.link_reach != "restricted" and document.link_role == "editor"
)
expected_abilities = {
"accesses_manage": False,
"accesses_view": True,
# If you get your editor rights from the link role and not your access role
# You should not access AI if it's restricted to users with specific access
"ai_transform": access_from_link and ai_access_setting != "restricted",
"ai_translate": access_from_link and ai_access_setting != "restricted",
"ai_transform": access_from_link,
"ai_translate": access_from_link,
"attachment_upload": access_from_link,
"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,
"versions_retrieve": True,
}
with override_settings(AI_ALLOW_REACH_FROM=ai_access_setting):
with django_assert_num_queries(1):
assert document.get_abilities(user) == expected_abilities
document.soft_delete()
document.refresh_from_db()
assert all(
value is False
for key, value in document.get_abilities(user).items()
if key != "link_select_options"
)
with django_assert_num_queries(1):
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())
def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
@@ -530,22 +430,15 @@ 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,
@@ -553,44 +446,6 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
}
@override_settings(AI_ALLOW_REACH_FROM="public")
@pytest.mark.parametrize(
"is_authenticated,reach",
[
(True, "public"),
(False, "public"),
(True, "authenticated"),
],
)
def test_models_document_get_abilities_ai_access_authenticated(is_authenticated, reach):
"""Validate AI abilities when AI is available to any anonymous user with editor rights."""
user = factories.UserFactory() if is_authenticated else AnonymousUser()
document = factories.DocumentFactory(link_reach=reach, link_role="editor")
abilities = document.get_abilities(user)
assert abilities["ai_transform"] is True
assert abilities["ai_translate"] is True
@override_settings(AI_ALLOW_REACH_FROM="authenticated")
@pytest.mark.parametrize(
"is_authenticated,reach",
[
(True, "public"),
(False, "public"),
(True, "authenticated"),
],
)
def test_models_document_get_abilities_ai_access_public(is_authenticated, reach):
"""Validate AI abilities when AI is available only to authenticated users with editor rights."""
user = factories.UserFactory() if is_authenticated else AnonymousUser()
document = factories.DocumentFactory(link_reach=reach, link_role="editor")
abilities = document.get_abilities(user)
assert abilities["ai_transform"] == is_authenticated
assert abilities["ai_translate"] == is_authenticated
def test_models_documents_get_versions_slice_pagination(settings):
"""
The "get_versions_slice" method should allow navigating all versions of
@@ -789,89 +644,40 @@ 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_ancestors(
def test_models_documents_nb_accesses_cache_is_set_and_retrieved(
django_assert_num_queries,
):
"""Test that nb_accesses is cached when calling nb_accesses_ancestors."""
parent = factories.DocumentFactory()
document = factories.DocumentFactory(parent=parent)
"""Test that nb_accesses is cached after the first computation."""
document = factories.DocumentFactory()
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
)
nb_accesses = random.randint(1, 4)
factories.UserDocumentAccessFactory.create_batch(nb_accesses, 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_ancestors == nb_accesses_ancestors
with django_assert_num_queries(1):
assert document.nb_accesses == nb_accesses
# Ensure that the nb_accesses is now cached
with django_assert_num_queries(0):
assert document.nb_accesses_ancestors == nb_accesses_ancestors
assert cache.get(key) == (nb_accesses_direct, nb_accesses_ancestors)
assert document.nb_accesses == nb_accesses
assert cache.get(key) == nb_accesses
# 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_ancestors == nb_accesses_ancestors + 1
assert cache.get(key) == (nb_accesses_direct + 1, nb_accesses_ancestors + 1)
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
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."""
@@ -880,262 +686,15 @@ 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 getattr(document, field) == 1
assert cache.get(key) == (1, 1)
assert document.nb_accesses == 1
assert cache.get(key) == 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(2):
new_nb_accesses = getattr(document, field)
with django_assert_num_queries(1):
new_nb_accesses = document.nb_accesses
assert new_nb_accesses == 0
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
assert cache.get(key) == 0 # Cache should now contain the new value
+1 -6
View File
@@ -516,12 +516,7 @@ class Base(Configuration):
AI_API_KEY = values.Value(None, environ_name="AI_API_KEY", environ_prefix=None)
AI_BASE_URL = values.Value(None, environ_name="AI_BASE_URL", environ_prefix=None)
AI_MODEL = values.Value(None, environ_name="AI_MODEL", environ_prefix=None)
AI_ALLOW_REACH_FROM = values.Value(
choices=("public", "authenticated", "restricted"),
default="authenticated",
environ_name="AI_ALLOW_REACH_FROM",
environ_prefix=None,
)
AI_DOCUMENT_RATE_THROTTLE_RATES = {
"minute": 5,
"hour": 100,
+2 -23
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-02-06 15:30+0000\n"
"PO-Revision-Date: 2025-02-10 14:14\n"
"POT-Creation-Date: 2025-01-29 13:43+0000\n"
"PO-Revision-Date: 2025-01-30 10:24\n"
"Last-Translator: \n"
"Language-Team: German\n"
"Language: de_DE\n"
@@ -391,24 +391,3 @@ msgstr "Französisch"
msgid "German"
msgstr "Deutsch"
#: core/templates/mail/html/invitation.html:162
#: core/templates/mail/text/invitation.txt:3
msgid "Logo email"
msgstr "Logo-E-Mail"
#: core/templates/mail/html/invitation.html:209
#: core/templates/mail/text/invitation.txt:10
msgid "Open"
msgstr "Öffnen"
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr " Docs, Ihr neues unentbehrliches Werkzeug für die Organisation, den Austausch und die Zusammenarbeit in Ihren Dokumenten als Team. "
#: core/templates/mail/html/invitation.html:233
#: core/templates/mail/text/invitation.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
msgstr " Erstellt von %(brandname)s "
+2 -23
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-02-06 15:30+0000\n"
"PO-Revision-Date: 2025-02-10 14:14\n"
"POT-Creation-Date: 2025-01-29 13:43+0000\n"
"PO-Revision-Date: 2025-01-30 10:24\n"
"Last-Translator: \n"
"Language-Team: English\n"
"Language: en_US\n"
@@ -391,24 +391,3 @@ msgstr ""
msgid "German"
msgstr ""
#: core/templates/mail/html/invitation.html:162
#: core/templates/mail/text/invitation.txt:3
msgid "Logo email"
msgstr ""
#: core/templates/mail/html/invitation.html:209
#: core/templates/mail/text/invitation.txt:10
msgid "Open"
msgstr ""
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr ""
#: core/templates/mail/html/invitation.html:233
#: core/templates/mail/text/invitation.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
msgstr ""
+2 -23
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-02-06 15:30+0000\n"
"PO-Revision-Date: 2025-02-10 14:14\n"
"POT-Creation-Date: 2025-01-29 13:43+0000\n"
"PO-Revision-Date: 2025-01-30 10:24\n"
"Last-Translator: \n"
"Language-Team: French\n"
"Language: fr_FR\n"
@@ -391,24 +391,3 @@ msgstr ""
msgid "German"
msgstr ""
#: core/templates/mail/html/invitation.html:162
#: core/templates/mail/text/invitation.txt:3
msgid "Logo email"
msgstr "Logo de l'e-mail"
#: core/templates/mail/html/invitation.html:209
#: core/templates/mail/text/invitation.txt:10
msgid "Open"
msgstr "Ouvrir"
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr " Docs, votre nouvel outil incontournable pour organiser, partager et collaborer sur vos documents en équipe. "
#: core/templates/mail/html/invitation.html:233
#: core/templates/mail/text/invitation.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
msgstr " Proposé par %(brandname)s "
+2 -23
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-02-06 15:30+0000\n"
"PO-Revision-Date: 2025-02-10 14:14\n"
"POT-Creation-Date: 2025-01-29 13:43+0000\n"
"PO-Revision-Date: 2025-01-30 10:24\n"
"Last-Translator: \n"
"Language-Team: Dutch\n"
"Language: nl_NL\n"
@@ -391,24 +391,3 @@ msgstr ""
msgid "German"
msgstr ""
#: core/templates/mail/html/invitation.html:162
#: core/templates/mail/text/invitation.txt:3
msgid "Logo email"
msgstr ""
#: core/templates/mail/html/invitation.html:209
#: core/templates/mail/text/invitation.txt:10
msgid "Open"
msgstr ""
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr ""
#: core/templates/mail/html/invitation.html:233
#: core/templates/mail/text/invitation.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
msgstr ""
+2 -2
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "impress"
version = "2.2.0"
version = "2.1.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -68,7 +68,6 @@ 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",
@@ -100,6 +99,7 @@ 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('Home')).toBeVisible();
await expect(page.getByText('Back to home page')).toBeVisible();
});
test('checks go back to home page redirects to home page', async ({
page,
}) => {
await page.getByText('Home').click();
await page.getByText('Back to home page').click();
await expect(page).toHaveURL('/');
});
});
@@ -1,46 +1,27 @@
import { FullConfig, FullProject, chromium, expect } from '@playwright/test';
import { test as setup } from '@playwright/test';
import { keyCloakSignIn } from './common';
const saveStorageState = async (
browserConfig: FullProject<unknown, unknown>,
) => {
const browserName = browserConfig?.name || 'chromium';
setup('authenticate-chromium', async ({ page }) => {
await page.goto('/');
await keyCloakSignIn(page, 'chromium');
await page
.context()
.storageState({ path: `playwright/.auth/user-chromium.json` });
});
const { storageState, ...useConfig } = browserConfig?.use;
const browser = await chromium.launch();
const context = await browser.newContext(useConfig);
const page = await context.newPage();
setup('authenticate-webkit', async ({ page }) => {
await page.goto('/');
await keyCloakSignIn(page, 'webkit');
await page
.context()
.storageState({ path: `playwright/.auth/user-webkit.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;
setup('authenticate-firefox', async ({ page }) => {
await page.goto('/');
await keyCloakSignIn(page, 'firefox');
await page
.context()
.storageState({ path: `playwright/.auth/user-firefox.json` });
});
@@ -1,17 +1,6 @@
import { Page, expect } from '@playwright/test';
export const keyCloakSignIn = async (
page: Page,
browserName: string,
fromHome: boolean = true,
) => {
if (fromHome) {
await page
.getByRole('button', { name: 'Proconnect Login' })
.first()
.click();
}
export const keyCloakSignIn = async (page: Page, browserName: string) => {
const login = `user-e2e-${browserName}`;
const password = `password-e2e-${browserName}`;
@@ -97,7 +86,7 @@ export const addNewMember = async (
// Choose a role
await page.getByLabel('doc-role-dropdown').click();
await page.getByRole('menuitem', { name: role }).click();
await page.getByRole('button', { name: role }).click();
await page.getByRole('button', { name: 'Invite' }).click();
return users[index].email;
@@ -269,8 +258,3 @@ export const mockedAccesses = async (page: Page, json?: object) => {
}
});
};
export const expectLoginPage = async (page: Page) =>
await expect(
page.getByRole('heading', { name: 'Collaborative writing' }),
).toBeVisible();
@@ -63,6 +63,27 @@ test.describe('Config', () => {
expect((await consoleMessage).text()).toContain(invalidMsg);
});
test('it checks that theme is configured from config endpoint', async ({
page,
}) => {
const responsePromise = page.waitForResponse(
(response) =>
response.url().includes('/config/') && response.status() === 200,
);
await page.goto('/');
const response = await responsePromise;
expect(response.ok()).toBeTruthy();
const jsonResponse = await response.json();
expect(jsonResponse.FRONTEND_THEME).toStrictEqual('dsfr');
const footer = page.locator('footer').first();
// alt 'Gouvernement Logo' comes from the theme
await expect(footer.getByAltText('Gouvernement Logo')).toBeVisible();
});
test('it checks that media server is configured from config endpoint', async ({
page,
browserName,
@@ -140,28 +161,3 @@ test.describe('Config', () => {
).toBeVisible();
});
});
test.describe('Config: Not loggued', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('it checks that theme is configured from config endpoint', async ({
page,
}) => {
const responsePromise = page.waitForResponse(
(response) =>
response.url().includes('/config/') && response.status() === 200,
);
await page.goto('/');
const response = await responsePromise;
expect(response.ok()).toBeTruthy();
const jsonResponse = await response.json();
expect(jsonResponse.FRONTEND_THEME).toStrictEqual('dsfr');
const footer = page.locator('footer').first();
// alt 'Gouvernement Logo' comes from the theme
await expect(footer.getByAltText('Gouvernement Logo')).toBeVisible();
});
});
@@ -24,6 +24,8 @@ test.describe('Doc Create', () => {
const header = page.locator('header').first();
await header.locator('h2').getByText('Docs').click();
await expect(page.getByTestId('grid-loader')).toBeVisible();
const docsGrid = page.getByTestId('docs-grid');
await expect(docsGrid).toBeVisible();
await expect(page.getByTestId('grid-loader')).toBeHidden();
@@ -1,5 +1,3 @@
/* eslint-disable playwright/no-conditional-expect */
/* eslint-disable playwright/no-conditional-in-test */
import path from 'path';
import { expect, test } from '@playwright/test';
@@ -37,10 +35,10 @@ test.describe('Doc Editor', () => {
// Change language to French
await header.click();
await header.getByRole('button', { name: /Language/ }).click();
await page.getByRole('menuitem', { name: 'Français' }).click();
await header.getByRole('combobox').getByText('English').click();
await header.getByRole('option', { name: 'Français' }).click();
await expect(
header.getByRole('button').getByText('Français'),
header.getByRole('combobox').getByText('Français'),
).toBeVisible();
// Trigger slash menu to show french menu
@@ -130,7 +128,7 @@ test.describe('Doc Editor', () => {
await selectVisibility.click();
await page
.getByRole('menuitem', {
.getByRole('button', {
name: 'Connected',
})
.click();
@@ -371,76 +369,83 @@ test.describe('Doc Editor', () => {
await expect(editor.getByText('Bonjour le monde')).toBeVisible();
});
[
{ ai_transform: false, ai_translate: false },
{ ai_transform: true, ai_translate: false },
{ ai_transform: false, ai_translate: true },
].forEach(({ ai_transform, ai_translate }) => {
test(`it checks AI buttons when can transform is at "${ai_transform}" and can translate is at "${ai_translate}"`, async ({
page,
}) => {
await mockedDocument(page, {
accesses: [
{
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
role: 'owner',
user: {
email: 'super@owner.com',
full_name: 'Super Owner',
},
},
],
abilities: {
destroy: true, // Means owner
link_configuration: true,
ai_transform,
ai_translate,
accesses_manage: true,
accesses_view: true,
update: true,
partial_update: true,
retrieve: true,
},
link_reach: 'public',
link_role: 'editor',
created_at: '2021-09-01T09:00:00Z',
});
test('it checks the divider block', async ({ page, browserName }) => {
await createDoc(page, 'divider-block', browserName, 1);
await goToGridDoc(page);
const editor = page.locator('.ProseMirror');
// Trigger slash menu to show menu
await editor.click();
await editor.fill('/');
await page.getByText('Divider', { exact: true }).click();
await verifyDocName(page, 'Mocked document');
await expect(
editor.locator('.bn-block-content[data-content-type="divider"]'),
).toBeVisible();
});
await page.locator('.bn-block-outer').last().fill('Hello World');
test('it checks the quote block', async ({ page, browserName }) => {
await createDoc(page, 'divider-block', browserName, 1);
const editor = page.locator('.ProseMirror');
await editor.getByText('Hello').dblclick();
const editor = page.locator('.ProseMirror');
// Trigger slash menu to show menu
await editor.click();
await editor.fill('/');
await page.getByText('Quote', { exact: true }).click();
if (!ai_transform && !ai_translate) {
await expect(page.getByRole('button', { name: 'AI' })).toBeHidden();
return;
}
await expect(
editor.locator('.bn-block-content[data-content-type="quote"]'),
).toBeVisible();
await page.getByRole('button', { name: 'AI' }).click();
await editor.fill('Hello World');
if (ai_transform) {
await expect(
page.getByRole('menuitem', { name: 'Use as prompt' }),
).toBeVisible();
} else {
await expect(
page.getByRole('menuitem', { name: 'Use as prompt' }),
).toBeHidden();
}
await expect(editor.getByText('Hello World')).toHaveCSS(
'font-style',
'italic',
);
});
if (ai_translate) {
await expect(
page.getByRole('menuitem', { name: 'Language' }),
).toBeVisible();
} else {
await expect(
page.getByRole('menuitem', { name: 'Language' }),
).toBeHidden();
}
});
test('it checks the alert block', async ({ page, browserName }) => {
await createDoc(page, 'divider-block', browserName, 1);
const editor = page.locator('.ProseMirror');
// Trigger slash menu to show menu
await editor.click();
await editor.fill('/');
await page.getByText('Alert', { exact: true }).click();
const alertBlock = editor.locator(
'.bn-block-content[data-content-type="alert"]',
);
await expect(
alertBlock.locator('div[data-alert-type="warning"]'),
).toBeVisible();
await editor.fill('My alert');
await expect(alertBlock.getByText('My alert')).toBeVisible();
await alertBlock.getByText('warning').click();
await expect(
alertBlock.getByRole('menuitem', { name: 'warning Warning' }),
).toBeVisible();
await expect(
alertBlock.getByRole('menuitem', { name: 'error Error' }),
).toBeVisible();
await expect(
alertBlock.getByRole('menuitem', { name: 'info Info' }),
).toBeVisible();
await expect(
alertBlock.getByRole('menuitem', { name: 'check_circle Success' }),
).toBeVisible();
await alertBlock
.getByRole('menuitem', { name: 'check_circle Success' })
.click();
await expect(
alertBlock.locator('div[data-alert-type="success"]'),
).toBeVisible();
});
});
@@ -41,16 +41,8 @@ test.describe('Doc Export', () => {
await expect(page.getByRole('button', { name: 'Download' })).toBeVisible();
});
test('it exports the doc with pdf line break', async ({
page,
browserName,
}) => {
const [randomDoc] = await createDoc(
page,
'doc-editor-line-break',
browserName,
1,
);
test('it exports the doc to pdf', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-editor', browserName, 1);
const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`${randomDoc}.pdf`);
@@ -58,20 +50,8 @@ test.describe('Doc Export', () => {
await verifyDocName(page, randomDoc);
const editor = page.locator('.ProseMirror.bn-editor');
await editor.click();
await editor.locator('.bn-block-outer').last().fill('Hello');
await page.keyboard.press('Enter');
await editor.locator('.bn-block-outer').last().fill('/');
await page.getByText('Page Break').click();
await expect(editor.locator('.bn-page-break')).toBeVisible();
await page.keyboard.press('Enter');
await editor.locator('.bn-block-outer').last().fill('World');
await page.locator('.ProseMirror.bn-editor').click();
await page.locator('.ProseMirror.bn-editor').fill('Hello World');
await page
.getByRole('button', {
@@ -89,10 +69,9 @@ test.describe('Doc Export', () => {
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
const pdfData = await pdf(pdfBuffer);
const pdfText = (await pdf(pdfBuffer)).text;
expect(pdfData.numpages).toBe(2);
expect(pdfData.text).toContain('\n\nHello\n\nWorld'); // This is the doc text
expect(pdfText).toContain('Hello World'); // This is the doc text
});
test('it exports the doc to docx', async ({ page, browserName }) => {
@@ -7,9 +7,17 @@ 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();
@@ -86,10 +94,6 @@ 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);
@@ -208,9 +212,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
await expect(page.getByTestId('grid-loader')).toBeVisible();
const response = await page.waitForResponse(
(response) =>
response.url().endsWith('documents/?page=1') &&
@@ -251,6 +254,7 @@ test.describe('Documents filters', () => {
url = new URL(page.url());
target = url.searchParams.get('target');
expect(target).toBe('my_docs');
await expect(page.getByTestId('grid-loader')).toBeVisible();
const responseMyDocs = await page.waitForResponse(
(response) =>
response.url().endsWith('documents/?page=1&is_creator_me=true') &&
@@ -266,6 +270,7 @@ test.describe('Documents filters', () => {
url = new URL(page.url());
target = url.searchParams.get('target');
expect(target).toBe('shared_with_me');
await expect(page.getByTestId('grid-loader')).toBeVisible();
const responseSharedWithMe = await page.waitForResponse(
(response) =>
response.url().includes('documents/?page=1&is_creator_me=false') &&
@@ -280,10 +285,14 @@ test.describe('Documents filters', () => {
});
test.describe('Documents Grid', () => {
test('checks all the elements are visible', async ({ page }) => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('checks all the elements are visible', async ({ page }) => {
let docs: SmallDoc[] = [];
await expect(page.getByTestId('grid-loader')).toBeVisible();
const response = await page.waitForResponse(
(response) =>
response.url().endsWith('documents/?page=1') &&
@@ -310,12 +319,11 @@ test.describe('Documents Grid', () => {
test('checks the infinite scroll', async ({ page }) => {
let docs: SmallDoc[] = [];
const responsePromisePage1 = page.waitForResponse((response) => {
return (
const responsePromisePage1 = page.waitForResponse(
(response) =>
response.url().endsWith(`/documents/?page=1`) &&
response.status() === 200
);
});
response.status() === 200,
);
const responsePromisePage2 = page.waitForResponse(
(response) =>
@@ -323,8 +331,6 @@ 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('menuitem', {
.getByRole('button', {
name: 'Delete document',
})
.click();
@@ -153,7 +153,7 @@ test.describe('Doc Header', () => {
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('menuitem', { name: 'Delete document' }),
page.getByRole('button', { 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('menuitem', {
page.getByRole('button', {
name: 'delete',
}),
).toBeEnabled();
@@ -195,7 +195,7 @@ test.describe('Doc Header', () => {
await memberCard.getByRole('button', { name: 'more_horiz' }).click();
await expect(
page.getByRole('menuitem', {
page.getByRole('button', {
name: 'delete',
}),
).toBeEnabled();
@@ -233,7 +233,7 @@ test.describe('Doc Header', () => {
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('menuitem', { name: 'Delete document' }),
page.getByRole('button', { 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('menuitem', { name: 'Delete document' }),
page.getByRole('button', { 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('menuitem', { name: 'Copy as Markdown' }).click();
await page.getByRole('button', { 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('menuitem', { name: 'Copy as HTML' }).click();
await page.getByRole('button', { name: 'Copy as HTML' }).click();
await expect(page.getByText('Copied to clipboard')).toBeVisible();
// Test that clipboard is in HTML format
@@ -395,7 +395,9 @@ test.describe('Doc Header', () => {
navigator.clipboard.readText(),
);
const clipboardContent = await handle.jsonValue();
expect(clipboardContent.trim()).toBe(`<h1>Hello World</h1><p></p>`);
expect(clipboardContent.trim()).toBe(
`<h1 data-level=\"1\">Hello World</h1><p></p>`,
);
});
test('it checks the copy link button', async ({ page }) => {
@@ -460,7 +462,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('menuitem', { name: 'Share' }).click();
await page.getByRole('button', { 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 +496,7 @@ test.describe('Documents Header mobile', () => {
await goToGridDoc(page);
await page.getByLabel('Open the document options').click();
await page.getByRole('menuitem', { name: 'Share' }).click();
await page.getByRole('button', { 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('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: 'Reader' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Editor' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Owner' })).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'Administrator' }),
page.getByRole('button', { name: 'Administrator' }),
).toBeVisible();
// Validate
await page.getByRole('menuitem', { name: 'Administrator' }).click();
await page.getByRole('button', { 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('menuitem', { name: 'Owner' }).click();
await page.getByRole('button', { 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('menuitem', { name: 'Owner' }).click();
await page.getByRole('button', { 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('button', { name: /Language/ }).click();
await page.getByRole('menuitem', { name: 'Français' }).click();
await header.getByRole('combobox').getByText('EN').click();
await header.getByRole('option', { name: 'translate 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('menuitem', { name: 'Administrateur' }).click();
await page.getByRole('button', { 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('menuitem', { name: 'Administrator' }).click();
await page.getByRole('button', { 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('menuitem', { name: 'Reader' }).click();
await page.getByRole('button', { name: 'Reader' }).click();
const moreActions = userInvitation.getByRole('button', {
name: 'more_horiz',
});
await moreActions.click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByRole('button', { 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('menuitem', { name: 'Administrator' }).click();
await page.getByRole('button', { name: 'Administrator' }).click();
await list.click();
await expect(currentUserRole).toBeVisible();
await currentUserRole.click();
await page.getByRole('menuitem', { name: 'Reader' }).click();
await page.getByRole('button', { 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('menuitem', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Delete' }).click();
await expect(userReader).toBeHidden();
await mySelfMoreActions.click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Delete' }).click();
await expect(
page.getByText('You do not have permission to view this document.'),
page.getByText('You do not have permission to perform this action.'),
).toBeVisible();
});
});
@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
import { expectLoginPage, keyCloakSignIn, mockedDocument } from './common';
import { keyCloakSignIn, mockedDocument } from './common';
test.describe('Doc Routing', () => {
test.beforeEach(async ({ page }) => {
@@ -63,13 +63,16 @@ test.describe('Doc Routing: Not loggued', () => {
await page.goto('/docs/mocked-document-id/');
await expect(page.locator('h2').getByText('Mocked document')).toBeVisible();
await page.getByRole('button', { name: 'Login' }).click();
await keyCloakSignIn(page, browserName, false);
await keyCloakSignIn(page, browserName);
await expect(page.locator('h2').getByText('Mocked document')).toBeVisible();
});
// eslint-disable-next-line playwright/expect-expect
test('The homepage redirects to login.', async ({ page }) => {
await page.goto('/');
await expectLoginPage(page);
await expect(
page.getByRole('button', {
name: 'Sign In',
}),
).toBeVisible();
});
});
@@ -19,7 +19,7 @@ test.describe('Doc Version', () => {
await page.getByLabel('Open the document options').click();
await page
.getByRole('menuitem', {
.getByRole('button', {
name: 'Version history',
})
.click();
@@ -59,7 +59,7 @@ test.describe('Doc Version', () => {
await page.getByLabel('Open the document options').click();
await page
.getByRole('menuitem', {
.getByRole('button', {
name: 'Version history',
})
.click();
@@ -91,7 +91,7 @@ test.describe('Doc Version', () => {
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('menuitem', { name: 'Version history' }),
page.getByRole('button', { name: 'Version history' }),
).toBeDisabled();
});
@@ -120,7 +120,7 @@ test.describe('Doc Version', () => {
await page.getByLabel('Open the document options').click();
await page
.getByRole('menuitem', {
.getByRole('button', {
name: 'Version history',
})
.click();
@@ -1,11 +1,6 @@
import { expect, test } from '@playwright/test';
import {
createDoc,
expectLoginPage,
keyCloakSignIn,
verifyDocName,
} from './common';
import { createDoc, keyCloakSignIn, verifyDocName } from './common';
const browsersName = ['chromium', 'webkit', 'firefox'];
@@ -50,7 +45,7 @@ test.describe('Doc Visibility', () => {
await selectVisibility.click();
await page
.getByRole('menuitem', {
.getByRole('button', {
name: 'Connected',
})
.click();
@@ -60,7 +55,7 @@ test.describe('Doc Visibility', () => {
await selectVisibility.click();
await page
.getByRole('menuitem', {
.getByRole('button', {
name: 'Public',
})
.click();
@@ -96,13 +91,11 @@ test.describe('Doc Visibility: Restricted', () => {
})
.click();
await expectLoginPage(page);
await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible();
await page.goto(urlDoc);
await expect(
page.getByText('Log in to access the document.'),
).toBeVisible();
await expect(page.getByRole('textbox', { name: 'password' })).toBeVisible();
});
test('A doc is not accessible when authentified but not member.', async ({
@@ -128,14 +121,10 @@ test.describe('Doc Visibility: Restricted', () => {
await keyCloakSignIn(page, otherBrowser!);
await expect(
page.getByRole('link', { name: 'Docs Logo Docs BETA' }),
).toBeVisible();
await page.goto(urlDoc);
await expect(
page.getByText('You do not have permission to view this document.'),
page.getByText('You do not have permission to perform this action.'),
).toBeVisible();
});
@@ -162,7 +151,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('menuitem', { name: 'Administrator' }).click();
await page.getByRole('button', { name: 'Administrator' }).click();
await page.getByRole('button', { name: 'Invite' }).click();
@@ -180,12 +169,11 @@ test.describe('Doc Visibility: Restricted', () => {
await keyCloakSignIn(page, otherBrowser!);
await expect(
page.getByRole('link', { name: 'Docs Logo Docs BETA' }),
).toBeVisible();
await page.goto(urlDoc);
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(1000);
await verifyDocName(page, docTitle);
await expect(page.getByLabel('Share button')).toBeVisible();
});
@@ -215,7 +203,7 @@ test.describe('Doc Visibility: Public', () => {
await selectVisibility.click();
await page
.getByRole('menuitem', {
.getByRole('button', {
name: 'Public',
})
.click();
@@ -227,7 +215,7 @@ test.describe('Doc Visibility: Public', () => {
await expect(page.getByLabel('Visibility mode')).toBeVisible();
await page.getByLabel('Visibility mode').click();
await page
.getByRole('menuitem', {
.getByRole('button', {
name: 'Reading',
})
.click();
@@ -259,7 +247,7 @@ test.describe('Doc Visibility: Public', () => {
})
.click();
await expectLoginPage(page);
await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible();
await page.goto(urlDoc);
@@ -289,7 +277,7 @@ test.describe('Doc Visibility: Public', () => {
await selectVisibility.click();
await page
.getByRole('menuitem', {
.getByRole('button', {
name: 'Public',
})
.click();
@@ -325,7 +313,7 @@ test.describe('Doc Visibility: Public', () => {
})
.click();
await expectLoginPage(page);
await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible();
await page.goto(urlDoc);
@@ -357,7 +345,7 @@ test.describe('Doc Visibility: Authenticated', () => {
const selectVisibility = page.getByLabel('Visibility', { exact: true });
await selectVisibility.click();
await page
.getByRole('menuitem', {
.getByRole('button', {
name: 'Connected',
})
.click();
@@ -376,15 +364,12 @@ test.describe('Doc Visibility: Authenticated', () => {
})
.click();
await expectLoginPage(page);
await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible();
await page.goto(urlDoc);
await expect(page.locator('h2').getByText(docTitle)).toBeHidden();
await expect(
page.getByText('Log in to access the document.'),
).toBeVisible();
await expect(page.getByRole('textbox', { name: 'password' })).toBeVisible();
});
test('It checks a authenticated doc in read only mode', async ({
@@ -407,7 +392,7 @@ test.describe('Doc Visibility: Authenticated', () => {
const selectVisibility = page.getByLabel('Visibility', { exact: true });
await selectVisibility.click();
await page
.getByRole('menuitem', {
.getByRole('button', {
name: 'Connected',
})
.click();
@@ -416,14 +401,6 @@ 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();
@@ -437,10 +414,6 @@ test.describe('Doc Visibility: Authenticated', () => {
const otherBrowser = browsersName.find((b) => b !== browserName);
await keyCloakSignIn(page, otherBrowser!);
await expect(
page.getByRole('link', { name: 'Docs Logo Docs BETA' }),
).toBeVisible();
await page.goto(urlDoc);
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
@@ -469,7 +442,7 @@ test.describe('Doc Visibility: Authenticated', () => {
const selectVisibility = page.getByLabel('Visibility', { exact: true });
await selectVisibility.click();
await page
.getByRole('menuitem', {
.getByRole('button', {
name: 'Connected',
})
.click();
@@ -497,10 +470,6 @@ test.describe('Doc Visibility: Authenticated', () => {
const otherBrowser = browsersName.find((b) => b !== browserName);
await keyCloakSignIn(page, otherBrowser!);
await expect(
page.getByRole('link', { name: 'Docs Logo Docs BETA' }),
).toBeVisible();
await page.goto(urlDoc);
await verifyDocName(page, docTitle);
@@ -1,10 +1,13 @@
import { expect, test } from '@playwright/test';
test.describe('Footer', () => {
test.use({ storageState: { cookies: [], origins: [] } });
import { goToGridDoc } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Footer', () => {
test('checks all the elements are visible', async ({ page }) => {
await page.goto('/');
const footer = page.locator('footer').first();
await expect(footer.getByAltText('Gouvernement Logo')).toBeVisible();
@@ -44,6 +47,12 @@ test.describe('Footer', () => {
).toBeVisible();
});
test('checks footer is not visible on doc editor', async ({ page }) => {
await expect(page.locator('footer')).toBeVisible();
await goToGridDoc(page);
await expect(page.locator('footer')).toBeHidden();
});
const legalPages = [
{ name: 'Legal Notice', url: '/legal-notice/' },
{ name: 'Personal data and cookies', url: '/personal-data-cookies/' },
@@ -51,8 +60,6 @@ test.describe('Footer', () => {
];
for (const { name, url } of legalPages) {
test(`checks ${name} page`, async ({ page }) => {
await page.goto('/');
const footer = page.locator('footer').first();
await footer.getByRole('link', { name }).click();
@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
import { expectLoginPage, keyCloakSignIn } from './common';
import { keyCloakSignIn } from './common';
test.describe('Header', () => {
test.beforeEach(async ({ page }) => {
@@ -10,7 +10,7 @@ test.describe('Header', () => {
test('checks all the elements are visible', async ({ page }) => {
const header = page.locator('header').first();
await expect(header.getByLabel('Docs Logo')).toBeVisible();
await expect(header.getByAltText('Docs Logo')).toBeVisible();
await expect(header.locator('h2').getByText('Docs')).toHaveCSS(
'color',
'rgb(0, 0, 145)',
@@ -88,7 +88,6 @@ 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);
@@ -99,6 +98,6 @@ test.describe('Header: Log out', () => {
})
.click();
await expectLoginPage(page);
await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible();
});
});
@@ -1,52 +0,0 @@
import { expect, test } from '@playwright/test';
test.beforeEach(async ({ page }) => {
await page.goto('/docs/');
});
test.describe('Home page', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('checks all the elements are visible', async ({ page }) => {
// Check header content
const header = page.locator('header').first();
const footer = page.locator('footer').first();
await expect(header).toBeVisible();
await expect(
header.getByRole('button', { name: /Language/ }),
).toBeVisible();
await expect(
header.getByRole('button', { name: 'Les services de La Suite numé' }),
).toBeVisible();
await expect(
header.getByRole('img', { name: 'Gouvernement Logo' }),
).toBeVisible();
await expect(header.getByRole('img', { name: 'Docs logo' })).toBeVisible();
await expect(header.getByRole('heading', { name: 'Docs' })).toBeVisible();
await expect(header.getByText('BETA')).toBeVisible();
// Check the titles
const h2 = page.locator('h2');
await expect(
h2.getByText('Collaborative writing, Simplified.'),
).toBeVisible();
await expect(
h2.getByText('An uncompromising writing experience.'),
).toBeVisible();
await expect(
h2.getByText('Simple and secure collaboration.'),
).toBeVisible();
await expect(h2.getByText('Flexible export.')).toBeVisible();
await expect(
h2.getByText('A new way to organize knowledge.'),
).toBeVisible();
await expect(
page.getByText('Docs is already available, log in to use it now.'),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Proconnect Login' }),
).toHaveCount(2);
await expect(footer).toBeVisible();
});
});
@@ -9,20 +9,19 @@ test.describe('Language', () => {
await expect(page.getByLabel('Logout')).toBeVisible();
const header = page.locator('header').first();
await header
.getByRole('button', { name: /Language/ })
.getByText('English')
.click();
await page.getByRole('menuitem', { name: 'Français' }).click();
await header.getByRole('combobox').getByText('English').click();
await header.getByRole('option', { name: 'Français' }).click();
await expect(
header.getByRole('button').getByText('Français'),
header.getByRole('combobox').getByText('Français'),
).toBeVisible();
await expect(page.getByLabel('Se déconnecter')).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 header.getByRole('combobox').getByText('Français').click();
await header.getByRole('option', { name: 'Deutsch' }).click();
await expect(
header.getByRole('combobox').getByText('Deutsch'),
).toBeVisible();
await expect(page.getByLabel('Abmelden')).toBeVisible();
});
@@ -54,11 +53,8 @@ test.describe('Language', () => {
// Switch language to French
const header = page.locator('header').first();
await header
.getByRole('button', { name: /Language/ })
.getByText('English')
.click();
await page.getByRole('menuitem', { name: 'Français' }).click();
await header.getByRole('combobox').getByText('English').click();
await header.getByRole('option', { 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('button', { name: /Language/ });
const languageButton = page.getByRole('combobox', { name: 'Language' });
const logoutButton = page.getByRole('button', { name: 'Logout' });
await expect(homeButton).not.toBeInViewport();
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "app-e2e",
"version": "2.2.0",
"version": "2.1.0",
"private": true,
"scripts": {
"lint": "eslint . --ext .ts",
@@ -12,7 +12,7 @@
"test:ui::chromium": "yarn test:ui --project=chromium"
},
"devDependencies": {
"@playwright/test": "1.50.1",
"@playwright/test": "1.50.0",
"@types/node": "*",
"@types/pdf-parse": "1.1.4",
"eslint-config-impress": "*",
+5 -1
View File
@@ -38,9 +38,10 @@ 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: {
@@ -52,6 +53,7 @@ export default defineConfig({
permissions: ['clipboard-read', 'clipboard-write'],
},
},
dependencies: ['setup'],
},
{
name: 'webkit',
@@ -61,6 +63,7 @@ export default defineConfig({
timezoneId: 'Europe/Paris',
storageState: 'playwright/.auth/user-webkit.json',
},
dependencies: ['setup'],
},
{
name: 'firefox',
@@ -76,6 +79,7 @@ export default defineConfig({
},
},
},
dependencies: ['setup'],
},
],
});
+3 -17
View File
@@ -1,4 +1,3 @@
const config = {
themes: {
default: {
@@ -6,7 +5,6 @@ const config = {
colors: {
'card-border': '#ededed',
'primary-bg': '#FAFAFA',
'primary-action': '#1212FF',
'primary-050': '#F5F5FE',
'primary-100': '#EDF5FA',
'primary-150': '#E5EEFA',
@@ -61,11 +59,6 @@ const config = {
h4: '1.375rem',
h5: '1.25rem',
h6: '1.125rem',
'xl-alt': '5rem',
'lg-alt': '4.5rem',
'md-alt': '4rem',
'sm-alt': '3.5rem',
'xs-alt': '3rem',
},
weights: {
thin: 100,
@@ -231,7 +224,7 @@ const config = {
'color-hover': 'var(--c--theme--colors--primary-700)',
},
border: {
color: 'var(--c--theme--colors--greyscale-300)',
color: 'var(--c--theme--colors--primary-200)',
},
},
tertiary: {
@@ -254,9 +247,6 @@ const config = {
'la-gauffre': {
activated: false,
},
'home-proconnect': {
activated: false,
},
},
},
dsfr: {
@@ -382,7 +372,6 @@ 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: {
@@ -390,8 +379,8 @@ const config = {
'color-active': '#EDEDED',
},
border: {
color: 'var(--c--theme--colors--greyscale-300)',
'color-hover': 'var(--c--theme--colors--greyscale-300)',
color: 'var(--c--theme--colors--primary-600)',
'color-hover': 'var(--c--theme--colors--primary-600)',
},
color: 'var(--c--theme--colors--primary-text)',
},
@@ -473,9 +462,6 @@ const config = {
'la-gauffre': {
activated: true,
},
'home-proconnect': {
activated: true,
},
},
},
},
+13 -20
View File
@@ -1,6 +1,6 @@
{
"name": "app-impress",
"version": "2.2.0",
"version": "2.1.0",
"private": true,
"scripts": {
"dev": "next dev",
@@ -15,40 +15,34 @@
"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",
"@blocknote/core": "0.21.0",
"@blocknote/mantine": "0.21.0",
"@blocknote/react": "0.21.0",
"@blocknote/xl-docx-exporter": "0.21.0",
"@blocknote/xl-pdf-exporter": "0.21.0",
"@gouvfr-lasuite/integration": "1.0.2",
"@hocuspocus/provider": "2.15.2",
"@hocuspocus/provider": "2.15.1",
"@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",
"@sentry/nextjs": "8.52.0",
"@tanstack/react-query": "5.65.1",
"cmdk": "1.0.4",
"crisp-sdk-web": "1.0.25",
"docx": "9.1.1",
"i18next": "24.2.2",
"i18next-browser-languagedetector": "8.0.2",
"idb": "8.0.2",
"idb": "8.0.1",
"lodash": "4.17.21",
"luxon": "3.5.0",
"next": "15.1.6",
"posthog-js": "1.215.6",
"posthog-js": "1.211.3",
"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",
"styled-components": "6.1.14",
"use-debounce": "10.0.4",
"y-protocols": "1.0.6",
"yjs": "13.6.23",
@@ -56,7 +50,7 @@
},
"devDependencies": {
"@svgr/webpack": "8.1.0",
"@tanstack/react-query-devtools": "5.66.0",
"@tanstack/react-query-devtools": "5.65.1",
"@testing-library/dom": "10.4.0",
"@testing-library/jest-dom": "6.6.3",
"@testing-library/react": "16.2.0",
@@ -79,7 +73,6 @@
"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"
}
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 267 KiB

@@ -1,12 +0,0 @@
<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"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M4.58203 26.405V7.5977C4.58203 6.45251 4.88938 5.58519 5.50408 4.99575C6.1272 4.40631 6.95242 4.08212 7.97972 4.02318C9.49542 3.93055 10.9311 3.80425 12.2868 3.64425C13.6425 3.47584 14.9393 3.28217 16.1771 3.06324C17.4234 2.8443 18.6359 2.60011 19.8148 2.33065C21.0274 2.04435 21.9578 2.1875 22.6062 2.7601C23.2546 3.33269 23.5788 4.24632 23.5788 5.50099V23.9925C23.5788 25.0956 23.3893 25.9166 23.0104 26.4555C22.6315 27.0029 21.9915 27.4028 21.0905 27.6554C19.4906 28.0933 17.9833 28.4386 16.5687 28.6912C15.154 28.9522 13.7731 29.1501 12.4258 29.2848C11.0785 29.4196 9.69751 29.5248 8.28286 29.6006C7.11241 29.668 6.20299 29.4238 5.5546 28.868C4.90622 28.3207 4.58203 27.4997 4.58203 26.405ZM9.20865 11.0124C11.0635 10.8944 12.7632 10.7131 14.3075 10.4683C14.6822 10.4072 15.0564 10.3436 15.4291 10.2776C15.8192 10.2085 16.1013 9.86859 16.1013 9.47337C16.1013 8.96154 15.638 8.57609 15.135 8.66189C14.846 8.71118 14.5555 8.75909 14.2635 8.80562C12.7346 9.04923 11.0452 9.22998 9.19523 9.3477C8.91819 9.36558 8.69776 9.45188 8.55608 9.62391C8.42209 9.78661 8.35645 9.98229 8.35645 10.2053C8.35645 10.4321 8.43296 10.6295 8.58568 10.7918L8.58783 10.7939C8.75336 10.9595 8.96369 11.0311 9.20865 11.0124ZM9.20801 15.206C11.0631 15.088 12.763 14.9066 14.3075 14.6619C15.8588 14.4089 17.3936 14.1138 18.9112 13.7766C19.2191 13.7081 19.4498 13.6003 19.5652 13.433C19.6786 13.2721 19.7347 13.0876 19.7347 12.8832C19.7347 12.6526 19.6469 12.454 19.476 12.2926C19.2921 12.1189 19.0348 12.0784 18.7304 12.1411L18.7285 12.1415C17.2823 12.4694 15.794 12.7553 14.2635 12.9992C12.7346 13.2428 11.0452 13.4235 9.19523 13.5413C8.91819 13.5591 8.69776 13.6454 8.55608 13.8175C8.42276 13.9794 8.35645 14.1705 8.35645 14.3863C8.35645 14.6203 8.43209 14.8223 8.58558 14.9854L8.59 14.9896C8.75499 15.1449 8.96316 15.2155 9.20551 15.2062L9.20801 15.206ZM9.20847 19.3994C11.0634 19.2729 12.7631 19.0874 14.3075 18.8427C15.8589 18.5982 17.3934 18.3073 18.9112 17.97C19.2199 17.9014 19.4508 17.7891 19.566 17.6127C19.6783 17.4529 19.7347 17.2733 19.7347 17.0766C19.7347 16.8461 19.6469 16.6474 19.476 16.4861C19.2921 16.3123 19.0348 16.2718 18.7304 16.3345L18.729 16.3348C17.2827 16.6543 15.7942 16.9361 14.2635 17.18C12.7345 17.4236 11.045 17.6086 9.19495 17.7347C8.91804 17.7526 8.69771 17.8389 8.55608 18.0109C8.42276 18.1728 8.35645 18.3639 8.35645 18.5797C8.35645 18.8137 8.43209 19.0158 8.58558 19.1789L8.59 19.183C8.75499 19.3383 8.96316 19.4089 9.20551 19.3996L9.20847 19.3994ZM14.3075 23.007C12.7632 23.2518 11.0635 23.4331 9.20867 23.5512C8.9637 23.5698 8.75337 23.4982 8.58783 23.3326L8.58572 23.3305C8.433 23.1682 8.35645 22.9708 8.35645 22.7441C8.35645 22.521 8.42209 22.3253 8.55608 22.1626C8.69776 21.9906 8.91827 21.9043 9.19531 21.8864C11.0453 21.7687 12.7346 21.588 14.2635 21.3443C14.5555 21.2978 14.846 21.2499 15.135 21.2006C15.638 21.1148 16.1013 21.5003 16.1013 22.0121C16.1013 22.4073 15.8192 22.7472 15.4291 22.8163C15.0564 22.8823 14.6822 22.9459 14.3075 23.007Z"
fill="#000091"
/>
</svg>

Before

Width:  |  Height:  |  Size: 3.6 KiB

@@ -8,20 +8,17 @@ import {
import { Button, Popover } from 'react-aria-components';
import styled from 'styled-components';
import { BoxProps } from './Box';
const StyledPopover = styled(Popover)`
background-color: white;
border-radius: 4px;
box-shadow: 1px 1px 5px rgba(0, 0, 0, 0.1);
border: 1px solid #dddddd;
transition: opacity 0.2s ease-in-out;
`;
interface StyledButtonProps {
$css?: BoxProps['$css'];
}
const StyledButton = styled(Button)<StyledButtonProps>`
const StyledButton = styled(Button)`
cursor: pointer;
border: none;
background: none;
@@ -32,12 +29,10 @@ const StyledButton = styled(Button)<StyledButtonProps>`
font-size: 0.938rem;
padding: 0;
text-wrap: nowrap;
${({ $css }) => $css};
`;
export interface DropButtonProps {
button: ReactNode;
buttonCss?: BoxProps['$css'];
isOpen?: boolean;
onOpenChange?: (isOpen: boolean) => void;
label?: string;
@@ -45,7 +40,6 @@ export interface DropButtonProps {
export const DropButton = ({
button,
buttonCss,
isOpen = false,
onOpenChange,
children,
@@ -64,24 +58,12 @@ export const DropButton = ({
onOpenChange?.(isOpen);
};
const props = {
onClick: (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
e.preventDefault();
onOpenChangeHandler(!isLocalOpen);
},
};
return (
<>
<StyledButton
// {...props}
ref={triggerRef}
onPress={(e) => {
onOpenChangeHandler(!isLocalOpen);
}}
onPress={() => onOpenChangeHandler(true)}
aria-label={label}
$css={buttonCss}
>
{button}
</StyledButton>
@@ -1,4 +1,4 @@
import { PropsWithChildren, useRef, useState } from 'react';
import { PropsWithChildren, useState } from 'react';
import { css } from 'styled-components';
import { Box, BoxButton, BoxProps, DropButton, Icon, Text } from '@/components';
@@ -8,7 +8,6 @@ export type DropdownMenuOption = {
icon?: string;
label: string;
testId?: string;
value?: string;
callback?: () => void | Promise<unknown>;
danger?: boolean;
isSelected?: boolean;
@@ -21,11 +20,8 @@ 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 = ({
@@ -34,21 +30,16 @@ 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) {
@@ -60,17 +51,10 @@ export const DropdownMenu = ({
isOpen={isOpen}
onOpenChange={onOpenChange}
label={label}
buttonCss={buttonCss}
button={
showArrow ? (
<Box
ref={blockButtonRef}
$direction="row"
$align="center"
$position="relative"
aria-controls="menu"
>
<Box>{children}</Box>
<Box $direction="row" $align="center">
<div>{children}</div>
<Icon
$variation="600"
$css={
@@ -83,17 +67,11 @@ export const DropdownMenu = ({
/>
</Box>
) : (
<Box ref={blockButtonRef} aria-controls="menu">
{children}
</Box>
children
)
}
>
<Box
$maxWidth="320px"
$minWidth={`${blockButtonRef.current?.clientWidth}px`}
role="menu"
>
<Box $maxWidth="320px">
{topMessage && (
<Text
$variation="700"
@@ -112,7 +90,6 @@ export const DropdownMenu = ({
const isDisabled = option.disabled !== undefined && option.disabled;
return (
<BoxButton
role="menuitem"
aria-label={option.label}
data-testid={option.testId}
$direction="row"
@@ -167,8 +144,7 @@ export const DropdownMenu = ({
{option.label}
</Text>
</Box>
{(option.isSelected ||
selectedValues?.includes(option.value ?? '')) && (
{option.isSelected && (
<Icon iconName="check" $size="20px" $theme="greyscale" />
)}
</BoxButton>
@@ -5,19 +5,10 @@ import { useCunninghamTheme } from '@/cunningham';
type IconProps = TextType & {
iconName: string;
isFilled?: boolean;
};
export const Icon = ({ iconName, isFilled, ...textProps }: IconProps) => {
export const Icon = ({ iconName, ...textProps }: IconProps) => {
return (
<Text
$isMaterialIcon={!isFilled}
{...textProps}
className={
isFilled
? `material-icons-filled ${textProps.className}`
: textProps.className
}
>
<Text $isMaterialIcon {...textProps}>
{iconName}
</Text>
);
@@ -25,9 +25,7 @@ export interface TextProps extends BoxProps {
$size?: TextSizes | (string & {});
$theme?:
| 'primary'
| 'primary-text'
| 'secondary'
| 'secondary-text'
| 'info'
| 'success'
| 'warning'
@@ -1,9 +0,0 @@
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(' ')} />;
};
@@ -1,40 +0,0 @@
.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);
}
}
@@ -1,352 +0,0 @@
/* 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>
);
};
@@ -1,186 +0,0 @@
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 };
});
@@ -1,46 +0,0 @@
.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);
}
@@ -1,63 +0,0 @@
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,4 +1,3 @@
/* eslint-disable jsx-a11y/no-static-element-interactions */
import { Command } from 'cmdk';
import { ReactNode, useRef } from 'react';
@@ -49,12 +48,7 @@ export const QuickSearch = ({
return (
<>
<QuickSearchStyle />
<div
className="quick-search-container"
onKeyDown={(e) => {
e.stopPropagation();
}}
>
<div className="quick-search-container">
<Command label={label} shouldFilter={false} ref={ref}>
{showInput && (
<QuickSearchInput
@@ -57,9 +57,6 @@ 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')}

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